From f144d510898d8aa1dfdda28f75c87e1c7cf66f1b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:08:32 -0500 Subject: [PATCH 001/403] fsmonitor: require content checks for reported paths Clearing CE_FSMONITOR_VALID is not enough to make a provider event authoritative. With core.trustctime disabled, core.checkStat set to minimal, and a restored modification time, stat matching can still accept changed file contents. The same stale match can affect diff, apply, checkout, and unpack-trees. Mark a reported entry with the in-memory CE_CONTENT_CHECK_REQUIRED flag, clear CE_UPTODATE, and discard its cached stat data. Route diff, apply, checkout, and unpack-trees comparisons through ie_match_stat_with_content_check(), which calls ie_modified() only for marked non-gitlinks. Other direct ie_match_stat() callers retain their existing paths. Marking an entry up to date clears the transient flag. Ordinary entries, gitlinks, and unmarked zero-stat entries retain their existing stat behavior. Add hook regressions for restored timestamps, diff and status, indexed apply, checkout, case-insensitive unpacking, unchanged reset, and ordinary zero-stat behavior in t/t7519-status-fsmonitor.sh. Signed-off-by: Taylor Blau --- apply.c | 5 +- diff-lib.c | 5 +- entry.c | 5 +- fsmonitor.c | 17 +++-- fsmonitor.h | 7 ++ read-cache-ll.h | 21 +++++- read-cache.c | 26 +++++++ t/helper/test-read-cache.c | 37 +++++++++ t/t7519-status-fsmonitor.sh | 142 +++++++++++++++++++++++++++++++++++ t/t7527-builtin-fsmonitor.sh | 27 ++++++- unpack-trees.c | 12 ++- 11 files changed, 280 insertions(+), 24 deletions(-) diff --git a/apply.c b/apply.c index f00b7ba4d3a7e6..1f5dda3b6f3fcc 100644 --- a/apply.c +++ b/apply.c @@ -3539,8 +3539,9 @@ static int verify_index_match(struct apply_state *state, return -1; return 0; } - return ie_match_stat(state->repo->index, ce, st, - CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); + return ie_match_stat_with_content_check( + state->repo->index, ce, st, + CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); } #define SUBMODULE_PATCH_WITHOUT_INDEX 1 diff --git a/diff-lib.c b/diff-lib.c index 46cae637ecda83..748f9ec3f3e7e7 100644 --- a/diff-lib.c +++ b/diff-lib.c @@ -90,7 +90,10 @@ static int match_stat_with_submodule(struct diff_options *diffopt, struct stat *st, unsigned ce_option, unsigned *dirty_submodule) { - int changed = ie_match_stat(diffopt->repo->index, ce, st, ce_option); + int changed; + + changed = ie_match_stat_with_content_check( + diffopt->repo->index, ce, st, ce_option); if (S_ISGITLINK(ce->ce_mode)) { struct diff_flags orig_flags = diffopt->flags; if (!diffopt->flags.override_submodule_config) diff --git a/entry.c b/entry.c index 1c4f0f44070ea3..9284e0d0d49123 100644 --- a/entry.c +++ b/entry.c @@ -512,8 +512,9 @@ int checkout_entry_ca(struct cache_entry *ce, struct conv_attrs *ca, if (!check_path(path.buf, path.len, &st, state->base_dir_len)) { const struct submodule *sub; - unsigned changed = ie_match_stat(state->istate, ce, &st, - CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); + unsigned changed = ie_match_stat_with_content_check( + state->istate, ce, &st, + CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); /* * Needs to be checked before !changed returns early, * as the possibly empty directory was not changed diff --git a/fsmonitor.c b/fsmonitor.c index 107767527ebec7..175377982d6ec9 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -189,13 +189,14 @@ static int query_fsmonitor_hook(struct repository *r, } /* - * Invalidate the FSM bit on this CE. This is like mark_fsmonitor_invalid() - * but we've already handled the untracked-cache, so let's not repeat that - * work. This also lets us have a different trace message so that we can - * see everything that was done as part of the refresh-callback. + * Strongly invalidate one cache entry without touching attributes or the + * untracked cache. Callers choose those wider invalidation scopes explicitly. */ -static void invalidate_ce_fsm(struct cache_entry *ce) +void fsmonitor_invalidate_cache_entry(struct cache_entry *ce) { + ce->ce_flags &= ~CE_UPTODATE; + memset(&ce->ce_stat_data, 0, sizeof(ce->ce_stat_data)); + ce->ce_flags |= CE_CONTENT_CHECK_REQUIRED; if (ce->ce_flags & CE_FSMONITOR_VALID) { trace_printf_key(&trace_fsmonitor, "fsmonitor_refresh_callback INV: '%s'", @@ -254,7 +255,7 @@ static size_t handle_using_name_hash_icase( */ untracked_cache_invalidate_trimmed_path(istate, ce->name, 0); - invalidate_ce_fsm(ce); + fsmonitor_invalidate_cache_entry(ce); return 1; } @@ -347,7 +348,7 @@ static size_t handle_path_without_trailing_slash( * cache-entry with the same pathname, nor for a cone * at that directory. (That is, assume no D/F conflicts.) */ - invalidate_ce_fsm(istate->cache[pos]); + fsmonitor_invalidate_cache_entry(istate->cache[pos]); return 1; } else { size_t nr_in_cone; @@ -425,7 +426,7 @@ static size_t handle_path_with_trailing_slash( for (i = pos; i < istate->cache_nr; i++) { if (!starts_with(istate->cache[i]->name, name)) break; - invalidate_ce_fsm(istate->cache[i]); + fsmonitor_invalidate_cache_entry(istate->cache[i]); nr_in_cone++; } diff --git a/fsmonitor.h b/fsmonitor.h index 5195a8624db82b..4027ca83f2b8cd 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -8,6 +8,13 @@ #include "read-cache-ll.h" #include "trace.h" +/* + * Force the next stat-aware caller to verify this entry's content. Wider + * invalidation, such as attributes or untracked-cache state, is the caller's + * responsibility. + */ +void fsmonitor_invalidate_cache_entry(struct cache_entry *ce); + /* * Check if refresh_fsmonitor has been called at least once. * refresh_fsmonitor is idempotent. Returns true if fsmonitor is diff --git a/read-cache-ll.h b/read-cache-ll.h index 71b87615ebc6d3..ed7d7aac38257e 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -69,14 +69,18 @@ struct cache_entry { */ #define CE_INTENT_TO_ADD (1 << 29) #define CE_SKIP_WORKTREE (1 << 30) -/* CE_EXTENDED2 is for future extension */ -#define CE_EXTENDED2 (1U << 31) +/* + * In-memory only. The cached stat data cannot be trusted, and callers which + * normally trust stat differences must verify content. This occupies the + * former never-persisted extension slot. + */ +#define CE_CONTENT_CHECK_REQUIRED (1U << 31) #define CE_EXTENDED_FLAGS (CE_INTENT_TO_ADD | CE_SKIP_WORKTREE) /* * Safeguard to avoid saving wrong flags: - * - CE_EXTENDED2 won't get saved until its semantic is known + * - CE_CONTENT_CHECK_REQUIRED is transient and must not be saved * - Bits in 0x0000FFFF have been saved in ce_flags already * - Bits in 0x003F0000 are currently in-memory flags */ @@ -120,7 +124,9 @@ static inline unsigned create_ce_flags(unsigned stage) #define ce_stage(ce) ((CE_STAGEMASK & (ce)->ce_flags) >> CE_STAGESHIFT) #define ce_uptodate(ce) ((ce)->ce_flags & CE_UPTODATE) #define ce_skip_worktree(ce) ((ce)->ce_flags & CE_SKIP_WORKTREE) -#define ce_mark_uptodate(ce) ((ce)->ce_flags |= CE_UPTODATE) +#define ce_mark_uptodate(ce) \ + ((ce)->ce_flags = ((ce)->ce_flags | CE_UPTODATE) & \ + ~CE_CONTENT_CHECK_REQUIRED) #define ce_intent_to_add(ce) ((ce)->ce_flags & CE_INTENT_TO_ADD) #define cache_entry_size(len) (offsetof(struct cache_entry,name) + (len) + 1) @@ -431,6 +437,13 @@ int is_racy_timestamp(const struct index_state *istate, int has_racy_timestamp(struct index_state *istate); int ie_match_stat(struct index_state *, const struct cache_entry *, struct stat *, unsigned int); int ie_modified(struct index_state *, const struct cache_entry *, struct stat *, unsigned int); +/* + * Unlike ie_match_stat(), verify content for marked non-gitlinks. Ordinary + * entries, including unmarked zero-stat entries, retain stat-only matching. + */ +int ie_match_stat_with_content_check(struct index_state *, + const struct cache_entry *, + struct stat *, unsigned int); int match_stat_data_racy(const struct index_state *istate, const struct stat_data *sd, struct stat *st); diff --git a/read-cache.c b/read-cache.c index 6c449f393d8d4d..fe989ce636003b 100644 --- a/read-cache.c +++ b/read-cache.c @@ -492,6 +492,32 @@ int ie_modified(struct index_state *istate, return 0; } +int ie_match_stat_with_content_check(struct index_state *istate, + const struct cache_entry *ce, + struct stat *st, unsigned int options) +{ + struct cache_entry *current; + int changed, pos; + + if (S_ISGITLINK(ce->ce_mode) || + !(ce->ce_flags & CE_CONTENT_CHECK_REQUIRED)) + return ie_match_stat(istate, ce, st, options); + + changed = ie_modified(istate, ce, st, options); + if (changed) + return changed; + + pos = index_name_pos(istate, ce->name, ce_namelen(ce)); + if (pos < 0 || istate->cache[pos] != ce) + return 0; + + current = istate->cache[pos]; + fill_stat_data(¤t->ce_stat_data, st); + current->ce_flags |= CE_UPDATE_IN_BASE; + istate->cache_changed |= CE_ENTRY_CHANGED; + return 0; +} + static int cache_name_stage_compare(const char *name1, int len1, int stage1, const char *name2, int len2, int stage2) { diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 6b08ba8f078d00..f5dae8ecfcc485 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -3,15 +3,52 @@ #include "test-tool.h" #include "config.h" #include "environment.h" +#include "fsmonitor.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" +static int test_fsmonitor_content_recovery(const char *path) +{ + struct index_state *istate; + struct cache_entry *ce; + struct stat_data empty = { 0 }; + struct stat st; + int pos; + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + istate = the_repository->index; + pos = index_name_pos(istate, path, strlen(path)); + if (pos < 0) + return error("path is not indexed: %s", path); + ce = istate->cache[pos]; + if (lstat(path, &st)) + return error_errno("unable to stat indexed path"); + + fsmonitor_invalidate_cache_entry(ce); + if (memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("invalidation did not poison cached stat data"); + if (ie_match_stat_with_content_check(istate, ce, &st, 0)) + return error("clean content did not match"); + if (!memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("verified clean entry retained poisoned stat data"); + if (!(ce->ce_flags & CE_UPDATE_IN_BASE) || + !(istate->cache_changed & CE_ENTRY_CHANGED)) + return error("verified stat refresh was not marked for persistence"); + return 0; +} + int cmd__read_cache(int argc, const char **argv) { int i, cnt = 1; const char *name = NULL; + if (argc == 3 && + !strcmp(argv[1], "--test-fsmonitor-content-recovery")) + return test_fsmonitor_content_recovery(argv[2]); if (argc > 1 && skip_prefix(argv[1], "--print-and-refresh=", &name)) { argc--; argv++; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 93973ed25a448b..1160612ea82177 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -477,4 +477,146 @@ test_expect_success 'status succeeds with sparse index' ' ) ' +test_expect_success 'reported events poison weak stat-cache matches' ' + test_create_repo reported-event && + ( + cd reported-event && + printf "aaaa\n" >tracked && + printf "clean\n" >clean && + git add tracked clean && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + test-tool chmtime =-60 tracked clean && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked) && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0clean\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime tracked && + + GIT_OPTIONAL_LOCKS=0 git diff-index --name-status HEAD \ + >.git/diff-index && + test_grep "^M.*tracked$" .git/diff-index && + test_grep ! "clean$" .git/diff-index && + git status --porcelain=v2 >.git/status && + test_grep "^1 \.M .* tracked$" .git/status && + test_grep ! " clean$" .git/status + ) +' + +test_expect_success 'reported path permits apply --index content match' ' + test_create_repo apply-marker && + ( + cd apply-marker && + test_commit base tracked && + test_write_lines next >tracked && + git diff >../apply-marker.patch && + git checkout -- tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git apply --index ../apply-marker.patch + ) +' + +test_expect_success 'reported path permits checkout-index content match' ' + test_create_repo checkout-marker && + ( + cd checkout-marker && + test_commit base tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git checkout-index tracked + ) +' + +test_expect_success CASE_INSENSITIVE_FS \ + 'reported path permits case-folded unpack match' ' + test_create_repo icase-marker && + ( + cd icase-marker && + test_write_lines same >foo && + git add foo && + git commit -m base && + base=$(git rev-parse HEAD) && + git mv foo intermediate && + git mv intermediate FOO && + git commit -m target && + target=$(git rev-parse HEAD) && + git checkout "$base" && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0foo\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git read-tree -m -u "$target" && + echo FOO >expect && + git ls-files >actual && + test_cmp expect actual + ) +' + +test_expect_success 'reported unchanged path avoids reset checkout' ' + test_create_repo reset-marker && + ( + cd reset-marker && + test_commit base tracked && + git config core.trustctime false && + git config core.checkStat minimal && + test-tool chmtime =-60 tracked && + git update-index --refresh && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + before=$(test-tool chmtime --get tracked) && + git reset --hard HEAD && + after=$(test-tool chmtime --get tracked) && + test "$before" = "$after" + ) +' + +test_expect_success 'ordinary zero-stat entries retain diff-index behavior' ' + test_create_repo ordinary-zero-stat && + ( + cd ordinary-zero-stat && + echo content >tracked && + git add tracked && + git commit -m base && + oid=$(git rev-parse :tracked) && + git update-index --cacheinfo 100644,$oid,tracked && + git -c core.fsmonitor=false diff-index --name-status HEAD >actual && + test_grep "^M.*tracked$" actual + ) +' + +test_expect_success 'verified reported paths restore poisoned stat data' ' + test_create_repo fsmonitor-stat-recovery && + ( + cd fsmonitor-stat-recovery && + echo content >tracked && + git add tracked && + git commit -m base && + test-tool read-cache \ + --test-fsmonitor-content-recovery tracked + ) +' + test_done diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 86195770e97779..de7134af8b5c1a 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1353,12 +1353,31 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep ! -q "fsmonitor_refresh_callback.*FILE-4-A.*pos" "$PWD/file_case_wrong-try2.log" && test_grep ! -q "fsmonitor_refresh_callback.*file-4-a.*pos" "$PWD/file_case_wrong-try2.log" && - # FSM refresh saw nothing, so it will mark all files as valid, - # so they should now have "h" status. + # A late directory event can arrive without repeating the file + # events checked above. Such an event invalidates its entire cone, + # so those entries remain "H" until the next quiet refresh. git -C file_case_wrong ls-files -f >"$PWD/file_case_wrong-lsf2.out" && - test_grep -q "h dir1/dir2/dir3/file-3-a" "$PWD/file_case_wrong-lsf2.out" && - test_grep -q "h dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-lsf2.out" && + if test_grep -E -q \ + "fsmonitor_refresh_callback .dir1(/dir2(/dir3)?)?/?. .*pos " \ + "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null + then + expected_3=H + else + expected_3=h + fi && + test_grep -q "$expected_3 dir1/dir2/dir3/file-3-a" \ + "$PWD/file_case_wrong-lsf2.out" && + if test_grep -E -q \ + "fsmonitor_refresh_callback .dir1(/dir2(/dir4)?)?/?. .*pos " \ + "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null + then + expected_4=H + else + expected_4=h + fi && + test_grep -q "$expected_4 dir1/dir2/dir4/FILE-4-A" \ + "$PWD/file_case_wrong-lsf2.out" && # We now have files with clean content, but with case-incorrect diff --git a/unpack-trees.c b/unpack-trees.c index 154d6d40a15934..44d3567c83844b 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -2241,7 +2241,8 @@ static int verify_uptodate_1(const struct cache_entry *ce, if (!lstat(ce->name, &st)) { int flags = CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE; - unsigned changed = ie_match_stat(o->src_index, ce, &st, flags); + unsigned changed = ie_match_stat_with_content_check( + o->src_index, ce, &st, flags); if (submodule_from_ce(ce)) { int r = check_submodule_move_head(ce, @@ -2407,7 +2408,9 @@ static int icase_exists(struct unpack_trees_options *o, const char *name, int le const struct cache_entry *src; src = index_file_exists(o->src_index, name, len, 1); - return src && !ie_match_stat(o->src_index, src, st, CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE); + return src && !ie_match_stat_with_content_check( + o->src_index, src, st, + CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); } enum absent_checking_type { @@ -3038,7 +3041,10 @@ int oneway_merge(const struct cache_entry * const *src, !(old->ce_flags & CE_FSMONITOR_VALID)) { struct stat st; if (lstat(old->name, &st) || - ie_match_stat(o->src_index, old, &st, CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE)) + ie_match_stat_with_content_check( + o->src_index, old, &st, + CE_MATCH_IGNORE_VALID | + CE_MATCH_IGNORE_SKIP_WORKTREE)) update |= CE_UPDATE; } if (o->update && S_ISGITLINK(old->ce_mode) && From b5c6791d3422ec40a8aaacd289934b0e78eb5b55 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 11:30:07 -0700 Subject: [PATCH 002/403] fsmonitor: support provider-wide invalidation A filesystem-monitor provider can know that its event history is incomplete without being able to identify every affected path. Treating such a response as an ordinary path leaves tracked entries, cached attributes, and untracked-cache state falsely valid. Reserve // as a provider-only global invalidation record. It cannot collide with a worktree-relative path. When the client receives it, discard cached attribute stacks and untracked-cache state, invalidate every tracked entry, and mark the fsmonitor extension changed. Recognize the existing trivial response only when a complete record consists of a single slash and NUL, newline, or carriage-return terminators. This prevents the new double-slash record from being discarded as a trivial response while preserving existing hook forms. Add a hook regression in t/t7519-status-fsmonitor.sh that changes a tracked file, restores its timestamp, emits the global marker, and requires status to report the change. Global invalidation intentionally scans the tracked index. Signed-off-by: Taylor Blau --- attr.c | 5 +++++ attr.h | 3 +++ dir.c | 9 +++++++++ dir.h | 1 + fsmonitor-ll.h | 3 +++ fsmonitor.c | 39 ++++++++++++++++++++++++++++++++----- t/t7519-status-fsmonitor.sh | 33 +++++++++++++++++++++++++++++++ 7 files changed, 88 insertions(+), 5 deletions(-) diff --git a/attr.c b/attr.c index 0e63f1b6de8f53..87808ba3755d04 100644 --- a/attr.c +++ b/attr.c @@ -536,6 +536,11 @@ static void drop_all_attr_stacks(void) vector_unlock(); } +void git_attr_invalidate_all(void) +{ + drop_all_attr_stacks(); +} + struct attr_check *attr_check_alloc(void) { struct attr_check *c = xcalloc(1, sizeof(struct attr_check)); diff --git a/attr.h b/attr.h index a04a5210921e22..cca94379362f10 100644 --- a/attr.h +++ b/attr.h @@ -227,6 +227,9 @@ enum git_attr_direction { }; void git_attr_set_direction(enum git_attr_direction new_direction); +/* Discard cached attributes after a provider-wide invalidation. */ +void git_attr_invalidate_all(void); + void attr_start(void); /* Return the system gitattributes file. */ diff --git a/dir.c b/dir.c index 95d8a1cce90f77..ad8f43f59536f8 100644 --- a/dir.c +++ b/dir.c @@ -1112,6 +1112,15 @@ static void invalidate_gitignore(struct untracked_cache *uc, do_invalidate_gitignore(dir); } +void untracked_cache_invalidate_all(struct index_state *istate) +{ + if (!istate->untracked || !istate->untracked->root) + return; + invalidate_gitignore(istate->untracked, istate->untracked->root); + istate->untracked->use_fsmonitor = 0; + istate->cache_changed |= UNTRACKED_CHANGED; +} + static void invalidate_directory(struct untracked_cache *uc, struct untracked_cache_dir *dir) { diff --git a/dir.h b/dir.h index 83e0f648a81f36..815225ee147e01 100644 --- a/dir.h +++ b/dir.h @@ -597,6 +597,7 @@ int cmp_dir_entry(const void *p1, const void *p2); int check_dir_entry_contains(const struct dir_entry *out, const struct dir_entry *in); void untracked_cache_invalidate_path(struct index_state *, const char *, int safe_path); +void untracked_cache_invalidate_all(struct index_state *); /* * Invalidate the untracked-cache for this path, but first strip * off a trailing slash, if present. diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 0504ca07d62fa1..a409b15e68bc51 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -4,6 +4,9 @@ struct index_state; struct strbuf; +/* A provider-only marker; worktree-relative paths cannot begin with '/'. */ +#define FSMONITOR_PATH_GLOBAL_INVALIDATE "//" + extern struct trace_key trace_fsmonitor; /* diff --git a/fsmonitor.c b/fsmonitor.c index 175377982d6ec9..6c119b17bd391e 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -2,6 +2,7 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "git-compat-util.h" +#include "attr.h" #include "config.h" #include "dir.h" #include "environment.h" @@ -436,12 +437,26 @@ static size_t handle_path_with_trailing_slash( static void fsmonitor_refresh_callback(struct index_state *istate, char *name) { int len = strlen(name); - int pos = index_name_pos(istate, name, len); + int pos; size_t nr_in_cone; trace_printf_key(&trace_fsmonitor, "fsmonitor_refresh_callback '%s' (pos %d)", - name, pos); + name, !strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE) ? + -1 : index_name_pos(istate, name, len)); + if (!strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE)) { + unsigned int i; + + git_attr_invalidate_all(); + untracked_cache_invalidate_all(istate); + for (i = 0; i < istate->cache_nr; i++) + fsmonitor_invalidate_cache_entry(istate->cache[i]); + istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", istate->repo, + "apply/global-invalidation", 1); + return; + } + pos = index_name_pos(istate, name, len); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); @@ -504,6 +519,19 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) */ static int fsmonitor_force_update_threshold = 100; +static int is_trivial_response_at(const struct strbuf *result, size_t offset) +{ + size_t i; + + if (offset >= result->len || result->buf[offset] != '/') + return 0; + for (i = offset + 1; i < result->len; i++) + if (result->buf[i] != '\0' && result->buf[i] != '\n' && + result->buf[i] != '\r') + return 0; + return 1; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -552,7 +580,7 @@ void refresh_fsmonitor(struct index_state *istate) buf = query_result.buf; strbuf_addstr(&last_update_token, buf); bol = last_update_token.len + 1; - is_trivial = query_result.buf[bol] == '/'; + is_trivial = is_trivial_response_at(&query_result, bol); if (is_trivial) trace2_data_intmax("fsm_client", NULL, "query/trivial-response", 1); @@ -613,7 +641,8 @@ void refresh_fsmonitor(struct index_state *istate) query_success = 0; } else { bol = last_update_token.len + 1; - is_trivial = query_result.buf[bol] == '/'; + is_trivial = is_trivial_response_at( + &query_result, bol); } } else if (hook_version < 0) { hook_version = HOOK_INTERFACE_VERSION1; @@ -627,7 +656,7 @@ void refresh_fsmonitor(struct index_state *istate) r, HOOK_INTERFACE_VERSION1, istate->fsmonitor_last_update, &query_result); if (query_success) - is_trivial = query_result.buf[0] == '/'; + is_trivial = is_trivial_response_at(&query_result, 0); } if (is_trivial) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 1160612ea82177..a0a20aa80e4a8f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -619,4 +619,37 @@ test_expect_success 'verified reported paths restore poisoned stat data' ' ) ' +test_expect_success 'provider global marker invalidates every tracked entry' ' + test_create_repo global-invalidate && + ( + cd global-invalidate && + printf "aaaa\n" >tracked && + git add tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + test-tool chmtime =-60 tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked) && + test_hook --setup fsmonitor-test <<-\EOF && + if test -f .git/global + then + printf "token1\0//\0" + else + printf "token0\0" + fi + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime tracked && + > .git/global && + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* tracked$" .git/actual + ) +' + test_done From 1dc10930b447ded12a9d2f8b64326cbec5e1116f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:11:47 -0700 Subject: [PATCH 003/403] status: pin semantic verification to the worktree root A worktree pathname can be replaced while a content verifier is opening files. Resolving later paths against that name can therefore hash a different tree from the one the index was meant to describe. Retain a no-follow descriptor and the complete stat identity of the repository worktree. On Linux, probe openat2() and require beneath-root resolution without symlink, magic-link, or mount crossings. Resolve SYS_openat2 through __NR_openat2 or the x86 syscall number 437 when older headers omit it; still require the complete runtime probe. On macOS, provide no-follow descriptor-relative opens. If the required platform support or the stable root is unavailable, return an error instead of attempting an unanchored proof. Register the root implementation with both Make and Meson. This patch introduces a compiled, isolated root primitive; it does not yet publish a proof, add a status caller, or check final root stability. Signed-off-by: Taylor Blau --- Makefile | 1 + meson.build | 1 + semantic-verify-internal.h | 43 ++++++++++ semantic-verify-root.c | 155 +++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 semantic-verify-internal.h create mode 100644 semantic-verify-root.c diff --git a/Makefile b/Makefile index 97a4936b146df3..d31f118893a214 100644 --- a/Makefile +++ b/Makefile @@ -1317,6 +1317,7 @@ LIB_OBJS += resolve-undo.o LIB_OBJS += revision.o LIB_OBJS += run-command.o LIB_OBJS += send-pack.o +LIB_OBJS += semantic-verify-root.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o LIB_OBJS += server-info.o diff --git a/meson.build b/meson.build index a777f4722a2061..408b0c27ca288d 100644 --- a/meson.build +++ b/meson.build @@ -523,6 +523,7 @@ libgit_sources = [ 'run-command.c', 'send-pack.c', 'sequencer.c', + 'semantic-verify-root.c', 'serve.c', 'server-info.c', 'setup.c', diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h new file mode 100644 index 00000000000000..fd7dd9897f8e45 --- /dev/null +++ b/semantic-verify-internal.h @@ -0,0 +1,43 @@ +#ifndef SEMANTIC_VERIFY_INTERNAL_H +#define SEMANTIC_VERIFY_INTERNAL_H + +#include "statinfo.h" + +#ifdef __linux__ +#include +#if !defined(SYS_openat2) && defined(__NR_openat2) +#define SYS_openat2 __NR_openat2 +#elif !defined(SYS_openat2) && \ + (defined(__x86_64__) || defined(__i386__)) +#define SYS_openat2 437 +#endif +#endif + +#if defined(__APPLE__) && defined(O_NONBLOCK) && \ + defined(O_NOFOLLOW) && defined(O_DIRECTORY) && \ + defined(AT_SYMLINK_NOFOLLOW) +#define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 1 +#elif defined(__linux__) && defined(SYS_openat2) && \ + defined(O_CLOEXEC) && defined(O_NONBLOCK) && \ + defined(O_NOFOLLOW) && defined(O_DIRECTORY) && \ + defined(AT_SYMLINK_NOFOLLOW) +#define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 1 +#else +#define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 0 +#endif + +struct repository; + +struct semantic_verify_root { + int fd; + char *path; + struct stat stat; +}; + +int semantic_verify_root_init(struct repository *repo, + struct semantic_verify_root **root_out); +void semantic_verify_root_clear(struct semantic_verify_root *root); + +int semantic_verify_openat(int dirfd, const char *path, int flags); + +#endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-root.c b/semantic-verify-root.c new file mode 100644 index 00000000000000..e896cbe51f7c94 --- /dev/null +++ b/semantic-verify-root.c @@ -0,0 +1,155 @@ +#include "git-compat-util.h" +#include "path-namespace.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "wrapper.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN && defined(__linux__) +struct semantic_open_how { + uint64_t flags; + uint64_t mode; + uint64_t resolve; +}; + +#define SEMANTIC_RESOLVE_NO_XDEV 0x01 +#define SEMANTIC_RESOLVE_NO_MAGICLINKS 0x02 +#define SEMANTIC_RESOLVE_NO_SYMLINKS 0x04 +#define SEMANTIC_RESOLVE_BENEATH 0x08 +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN && !defined(__linux__) +static int set_fd_cloexec(int fd) +{ +#if defined(F_GETFD) && defined(F_SETFD) && defined(FD_CLOEXEC) + int flags = fcntl(fd, F_GETFD); + + if (flags < 0 || fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) + return -1; +#endif + return 0; +} +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +int semantic_verify_openat(int dirfd, const char *path, int flags) +{ +#ifdef __linux__ + struct semantic_open_how how = { + .flags = flags | O_CLOEXEC, + .resolve = SEMANTIC_RESOLVE_BENEATH | + SEMANTIC_RESOLVE_NO_SYMLINKS | + SEMANTIC_RESOLVE_NO_MAGICLINKS | + SEMANTIC_RESOLVE_NO_XDEV, + }; + + return syscall(SYS_openat2, dirfd, path, &how, sizeof(how)); +#else + int fd; + int saved_errno; + +#ifdef O_CLOEXEC + fd = openat(dirfd, path, flags | O_CLOEXEC); + if (fd >= 0) + return fd; + if (errno != EINVAL) + return -1; +#endif + fd = openat(dirfd, path, flags); + if (fd < 0) + return -1; + if (!set_fd_cloexec(fd)) + return fd; + saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; +#endif +} +#else +int semantic_verify_openat(int dirfd UNUSED, const char *path UNUSED, + int flags UNUSED) +{ + errno = ENOSYS; + return -1; +} +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +int semantic_verify_root_init(struct repository *repo, + struct semantic_verify_root **root_out) +{ + struct semantic_verify_root *root; + const char *path = repo_get_work_tree(repo); + + if (!path) { + errno = ENOENT; + return -1; + } + CALLOC_ARRAY(root, 1); + root->fd = -1; + root->path = xstrdup(path); + root->fd = git_open_cloexec(root->path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (root->fd < 0 || fstat(root->fd, &root->stat) || + !S_ISDIR(root->stat.st_mode)) { + int saved_errno = errno ? errno : ENOTDIR; + + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } +#ifdef __linux__ + { + struct stat probe_stat; + int probe_fd = semantic_verify_openat( + root->fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + int saved_errno; + + if (probe_fd < 0) { + saved_errno = errno; + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } + if (fstat(probe_fd, &probe_stat)) { + saved_errno = errno; + close(probe_fd); + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } + if (!path_namespace_stat_equal(&root->stat, &probe_stat)) { + close(probe_fd); + semantic_verify_root_clear(root); + errno = EAGAIN; + return -1; + } + if (close(probe_fd)) { + saved_errno = errno; + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } + } +#endif + *root_out = root; + return 0; +} +#else +int semantic_verify_root_init(struct repository *repo UNUSED, + struct semantic_verify_root **root_out UNUSED) +{ + errno = ENOSYS; + return -1; +} +#endif + +void semantic_verify_root_clear(struct semantic_verify_root *root) +{ + if (!root) + return; + if (root->fd >= 0) + close(root->fd); + free(root->path); + free(root); +} From 84367056e50557fe01dd96cfbeafd3dad4a82f9a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:12:41 -0700 Subject: [PATCH 004/403] status: resolve semantic proof paths through pinned parents Holding the worktree root does not keep a nested directory from being renamed or replaced while indexed paths are visited. Reopening a whole pathname can silently switch verification into the replacement tree. Resolve each indexed path one component at a time beneath the retained root. Keep matching ancestor descriptors while walking sorted index names, reject empty and dot components and device crossings, and reopen each outgoing directory through its still-pinned parent. If the reopened identity changes, record the earliest affected index position so a caller cannot retain a clean result from that namespace. Register the resolver in both Make and Meson. Unsupported platforms return ENOSYS rather than falling back to ordinary pathname resolution. The resolver is an internal, independently buildable primitive; this patch does not yet run a status scan or construct a complete proof. Signed-off-by: Taylor Blau --- Makefile | 1 + meson.build | 1 + semantic-verify-internal.h | 10 ++ semantic-verify-path.c | 186 +++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 semantic-verify-path.c diff --git a/Makefile b/Makefile index d31f118893a214..afb57d02604aec 100644 --- a/Makefile +++ b/Makefile @@ -1317,6 +1317,7 @@ LIB_OBJS += resolve-undo.o LIB_OBJS += revision.o LIB_OBJS += run-command.o LIB_OBJS += send-pack.o +LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o diff --git a/meson.build b/meson.build index 408b0c27ca288d..35c693e1f1503e 100644 --- a/meson.build +++ b/meson.build @@ -523,6 +523,7 @@ libgit_sources = [ 'run-command.c', 'send-pack.c', 'sequencer.c', + 'semantic-verify-path.c', 'semantic-verify-root.c', 'serve.c', 'server-info.c', diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index fd7dd9897f8e45..1f881af44fba6f 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -27,6 +27,7 @@ #endif struct repository; +struct semantic_verify_path; struct semantic_verify_root { int fd; @@ -40,4 +41,13 @@ void semantic_verify_root_clear(struct semantic_verify_root *root); int semantic_verify_openat(int dirfd, const char *path, int flags); +struct semantic_verify_path *semantic_verify_path_new( + struct semantic_verify_root *root); +int semantic_verify_resolve_parent(struct semantic_verify_path *path, + const char *name, size_t cache_pos, + int *parent_fd, const char **basename); +void semantic_verify_path_free(struct semantic_verify_path *path, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from); + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-path.c b/semantic-verify-path.c new file mode 100644 index 00000000000000..db7fd874084c42 --- /dev/null +++ b/semantic-verify-path.c @@ -0,0 +1,186 @@ +#include "git-compat-util.h" +#include "path-namespace.h" +#include "semantic-verify-internal.h" +#include "strbuf.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +struct anchored_dir { + char *component; + int fd; + struct stat stat; + size_t first_cache_pos; +}; + +struct semantic_verify_path { + struct semantic_verify_root *root; + struct anchored_dir *dirs; + size_t dirs_nr; + size_t dirs_alloc; + size_t namespace_unstable_from; + unsigned int namespace_unstable; + struct strbuf component; +}; + +static void note_namespace_unstable(struct semantic_verify_path *path, + size_t from) +{ + path->namespace_unstable = 1; + if (from < path->namespace_unstable_from) + path->namespace_unstable_from = from; +} + +static void pop_anchored_dir(struct semantic_verify_path *path) +{ + struct anchored_dir *dir = &path->dirs[path->dirs_nr - 1]; + int parent_fd = path->dirs_nr == 1 ? path->root->fd : + path->dirs[path->dirs_nr - 2].fd; + int named_fd; + struct stat named_stat; + + named_fd = semantic_verify_openat(parent_fd, dir->component, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (named_fd < 0 || fstat(named_fd, &named_stat) || + !path_namespace_stat_equal(&dir->stat, &named_stat)) + note_namespace_unstable(path, dir->first_cache_pos); + if (named_fd >= 0) + close(named_fd); + close(dir->fd); + free(dir->component); + path->dirs_nr--; +} + +struct semantic_verify_path *semantic_verify_path_new( + struct semantic_verify_root *root) +{ + struct semantic_verify_path *path; + + CALLOC_ARRAY(path, 1); + path->root = root; + path->namespace_unstable_from = SIZE_MAX; + path->component = (struct strbuf)STRBUF_INIT; + return path; +} + +int semantic_verify_resolve_parent(struct semantic_verify_path *path, + const char *name, size_t cache_pos, + int *parent_fd, const char **basename) +{ + const char *slash = strrchr(name, '/'); + size_t parent_len = slash ? (size_t)(slash - name) : 0; + size_t begin = 0, depth = 0; + + *basename = slash ? slash + 1 : name; + if (!**basename) { + errno = EINVAL; + return -1; + } + + /* Find the component-aligned prefix already pinned by this worker. */ + while (begin < parent_len && depth < path->dirs_nr) { + size_t end = begin; + struct anchored_dir *dir = &path->dirs[depth]; + + while (end < parent_len && name[end] != '/') + end++; + if (strlen(dir->component) != end - begin || + memcmp(dir->component, name + begin, end - begin)) + break; + depth++; + begin = end + 1; + } + while (path->dirs_nr > depth) + pop_anchored_dir(path); + + while (begin < parent_len) { + size_t end = begin; + struct anchored_dir *dir; + int dirfd, fd; + struct stat st; + + while (end < parent_len && name[end] != '/') + end++; + if (end == begin || + (end - begin == 1 && name[begin] == '.') || + (end - begin == 2 && name[begin] == '.' && + name[begin + 1] == '.')) { + errno = EINVAL; + return -1; + } + strbuf_reset(&path->component); + strbuf_add(&path->component, name + begin, end - begin); + dirfd = path->dirs_nr ? path->dirs[path->dirs_nr - 1].fd : + path->root->fd; + fd = semantic_verify_openat(dirfd, path->component.buf, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (fd < 0) + return -1; + if (fstat(fd, &st)) { + int saved_errno = errno; + + close(fd); + errno = saved_errno; + return -1; + } + if (!S_ISDIR(st.st_mode) || st.st_dev != path->root->stat.st_dev) { + close(fd); + errno = EXDEV; + return -1; + } + ALLOC_GROW(path->dirs, path->dirs_nr + 1, path->dirs_alloc); + dir = &path->dirs[path->dirs_nr++]; + dir->component = xstrdup(path->component.buf); + dir->fd = fd; + memcpy(&dir->stat, &st, sizeof(st)); + dir->first_cache_pos = cache_pos; + begin = end + 1; + } + + *parent_fd = path->dirs_nr ? path->dirs[path->dirs_nr - 1].fd : + path->root->fd; + return 0; +} + +void semantic_verify_path_free(struct semantic_verify_path *path, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from) +{ + if (!path) + return; + while (path->dirs_nr) + pop_anchored_dir(path); + if (namespace_unstable) + *namespace_unstable = path->namespace_unstable; + if (namespace_unstable_from) + *namespace_unstable_from = path->namespace_unstable_from; + free(path->dirs); + strbuf_release(&path->component); + free(path); +} +#else +struct semantic_verify_path *semantic_verify_path_new( + struct semantic_verify_root *root UNUSED) +{ + errno = ENOSYS; + return NULL; +} + +int semantic_verify_resolve_parent( + struct semantic_verify_path *path UNUSED, + const char *name UNUSED, size_t cache_pos UNUSED, + int *parent_fd UNUSED, const char **basename UNUSED) +{ + errno = ENOSYS; + return -1; +} + +void semantic_verify_path_free( + struct semantic_verify_path *path UNUSED, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from) +{ + if (namespace_unstable) + *namespace_unstable = 0; + if (namespace_unstable_from) + *namespace_unstable_from = SIZE_MAX; +} +#endif From 35490ae4651794bd31010b6cae1b756a8fcf009e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:45:06 -0500 Subject: [PATCH 005/403] path-namespace: check reopened component identity A successful descriptor-relative reopen proves only that a component currently exists. If its parent entry has been replaced, the reopened descriptor can refer to a different object from the file that was originally observed. Add path_namespace_reopen_component() to reopen exactly one component with a caller-supplied anchored-open function and compare the complete stat identity with the expected object. Reject empty components, dot components, and embedded separators with EINVAL; report a replacement as EAGAIN and close the reopened descriptor on every outcome. When file identity is unreliable, return EAGAIN before opening the component. Extend the already registered path-namespace unit suite with matching and replaced temporary objects and a parent-traversal attempt. The primitive does not infer that equal content or a successful open is sufficient to establish namespace identity. Signed-off-by: Taylor Blau --- path-namespace.c | 42 +++++++++++++++++++++++++++++ path-namespace.h | 5 ++++ t/unit-tests/u-path-namespace.c | 48 +++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/path-namespace.c b/path-namespace.c index 48fc0aae434ef7..151634b886b663 100644 --- a/path-namespace.c +++ b/path-namespace.c @@ -45,3 +45,45 @@ int path_namespace_stat_equal(const struct stat *a, const struct stat *b) path_stat_identity_init(&second, b); return path_stat_identity_equal(&first, &second); } + +int path_namespace_reopen_component( + int parent_fd, const char *component, int flags, + path_namespace_open_fn open_fn, const struct stat *expected) +{ + struct stat reopened; + int fd, saved_errno; + + if (!open_fn || !component || !*component || + !strcmp(component, ".") || !strcmp(component, "..")) { + errno = EINVAL; + return -1; + } + for (const char *p = component; *p; p++) { + if (is_dir_sep(*p)) { + errno = EINVAL; + return -1; + } + } + if (!fstat_is_reliable()) { + errno = EAGAIN; + return -1; + } + + fd = open_fn(parent_fd, component, flags); + if (fd < 0) + return -1; + if (fstat(fd, &reopened)) { + saved_errno = errno; + goto error; + } + if (!path_namespace_stat_equal(expected, &reopened)) { + saved_errno = EAGAIN; + goto error; + } + return close(fd); + +error: + close(fd); + errno = saved_errno; + return -1; +} diff --git a/path-namespace.h b/path-namespace.h index 0a93683e0b2de1..c26f4f12aebd48 100644 --- a/path-namespace.h +++ b/path-namespace.h @@ -3,6 +3,8 @@ struct stat; +typedef int (*path_namespace_open_fn)(int dirfd, const char *path, int flags); + #define PATH_STAT_IDENTITY_FIELDS 14 struct path_stat_identity { @@ -14,5 +16,8 @@ void path_stat_identity_init(struct path_stat_identity *identity, int path_stat_identity_equal(const struct path_stat_identity *a, const struct path_stat_identity *b); int path_namespace_stat_equal(const struct stat *a, const struct stat *b); +int path_namespace_reopen_component( + int parent_fd, const char *component, int flags, + path_namespace_open_fn open_fn, const struct stat *expected); #endif /* PATH_NAMESPACE_H */ diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c index 702104597b1df9..4e0d9dfab24d5d 100644 --- a/t/unit-tests/u-path-namespace.c +++ b/t/unit-tests/u-path-namespace.c @@ -1,6 +1,7 @@ #include "unit-test.h" #include "path-namespace.h" +#include "tempfile.h" #define ASSERT_STAT_FIELD_MATTERS(base, changed, field) do { \ (changed) = (base); \ @@ -67,3 +68,50 @@ void test_path_namespace__stat_fields(void) ASSERT_STAT_FIELD_MATTERS(st, changed, st_gen); #endif } + +static int source_fd = -1; + +static int reopen_source(int dirfd UNUSED, const char *path, int flags UNUSED) +{ + if (strcmp(path, "source")) { + errno = ENOENT; + return -1; + } + return dup(source_fd); +} + +void test_path_namespace__reopen_component(void) +{ + struct tempfile *first = mks_tempfile_t("path-namespace-one-XXXXXX"); + struct tempfile *second = mks_tempfile_t("path-namespace-two-XXXXXX"); + struct stat expected; + + cl_assert(first != NULL); + cl_assert(second != NULL); + cl_must_pass(fstat(get_tempfile_fd(first), &expected)); + + source_fd = get_tempfile_fd(first); + if (!fstat_is_reliable()) { + cl_assert(path_namespace_reopen_component( + -1, "source", O_RDONLY, + reopen_source, &expected) < 0); + cl_assert_equal_i(errno, EAGAIN); + goto invalid_component; + } + cl_must_pass(path_namespace_reopen_component( + -1, "source", O_RDONLY, reopen_source, &expected)); + + source_fd = get_tempfile_fd(second); + cl_assert(path_namespace_reopen_component( + -1, "source", O_RDONLY, reopen_source, &expected) < 0); + cl_assert_equal_i(errno, EAGAIN); + +invalid_component: + cl_assert(path_namespace_reopen_component( + -1, "../source", O_RDONLY, reopen_source, &expected) < 0); + cl_assert_equal_i(errno, EINVAL); + + source_fd = -1; + cl_must_pass(delete_tempfile(&first)); + cl_must_pass(delete_tempfile(&second)); +} From 60039b43827c5488432ad5e4e8c86c3a5d6416ee Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:13:45 -0700 Subject: [PATCH 006/403] status: hash tracked files through anchored descriptors Size and modification time cannot establish that worktree content matches an indexed blob. A replacement can preserve those values, and reopening a pathname after hashing can reach a different object. Open a regular file relative to its pinned parent and compare the pathname observation with the held descriptor before hashing. Stream the Git blob header and exact observed bytes using the repository hash algorithm, reject short reads and concurrent appends, then repeat the descriptor and pathname identity checks and reopen the final component. Record a matching object as raw-clean only after every check succeeds. A multiply-linked clean file is not persistable, because an unobserved alias can later change its contents. Structural errors, replacements, unsupported anchored opens, and hash mismatches remain explicit results. Register the file verifier with both Make and Meson. Its 256 KiB hash buffer is supplied by its caller; this patch does not yet classify conversion attributes, run a worker, or apply an index update. Signed-off-by: Taylor Blau --- Makefile | 1 + meson.build | 1 + semantic-verify-file.c | 210 +++++++++++++++++++++++++++++++++++++ semantic-verify-internal.h | 24 +++++ semantic-verify.h | 15 +++ 5 files changed, 251 insertions(+) create mode 100644 semantic-verify-file.c create mode 100644 semantic-verify.h diff --git a/Makefile b/Makefile index afb57d02604aec..b7ee8bcb8e9379 100644 --- a/Makefile +++ b/Makefile @@ -1317,6 +1317,7 @@ LIB_OBJS += resolve-undo.o LIB_OBJS += revision.o LIB_OBJS += run-command.o LIB_OBJS += send-pack.o +LIB_OBJS += semantic-verify-file.o LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o LIB_OBJS += sequencer.o diff --git a/meson.build b/meson.build index 35c693e1f1503e..194f2f11456e93 100644 --- a/meson.build +++ b/meson.build @@ -523,6 +523,7 @@ libgit_sources = [ 'run-command.c', 'send-pack.c', 'sequencer.c', + 'semantic-verify-file.c', 'semantic-verify-path.c', 'semantic-verify-root.c', 'serve.c', diff --git a/semantic-verify-file.c b/semantic-verify-file.c new file mode 100644 index 00000000000000..5a06809060e58d --- /dev/null +++ b/semantic-verify-file.c @@ -0,0 +1,210 @@ +#include "git-compat-util.h" +#include "environment.h" +#include "object-file.h" +#include "path-namespace.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static int mode_matches_ce(struct repository *repo, + const struct cache_entry *ce, + const struct stat *st) +{ + if (!S_ISREG(st->st_mode)) + return 0; + if (repo_trust_executable_bit(repo) && + ((ce->ce_mode ^ st->st_mode) & 0100)) + return 0; + return 1; +} + +static int hash_raw_blob(int fd, size_t size, + const struct git_hash_algo *algo, + struct object_id *oid, void *buffer, + size_t *bytes_hashed) +{ + struct git_hash_ctx ctx; + char header[MAX_HEADER_LEN]; + int header_len; + size_t remaining = size; + + header_len = format_object_header(header, sizeof(header), OBJ_BLOB, size); + git_hash_init(&ctx, algo); + git_hash_update(&ctx, header, header_len); + + while (remaining) { + size_t want = remaining < SEMANTIC_VERIFY_HASH_BUFFER_SIZE ? + remaining : SEMANTIC_VERIFY_HASH_BUFFER_SIZE; + ssize_t nr = xread(fd, buffer, want); + + if (nr < 0) + return -1; + if (!nr) { + errno = EIO; + return -1; + } + git_hash_update(&ctx, buffer, nr); + remaining -= nr; + *bytes_hashed += nr; + } + + /* Do not silently omit an append which raced with the declared size. */ + { + char extra; + ssize_t nr = xread(fd, &extra, 1); + + if (nr < 0) + return -1; + if (nr) { + errno = EAGAIN; + return -1; + } + } + + git_hash_final_oid(oid, &ctx); + return 0; +} + +static unsigned int classify_resolve_error(int error) +{ + if (error == ENOENT) + return SEMANTIC_VERIFY_RAW_MODIFIED; + if (error == ELOOP || error == ENOTDIR || error == EXDEV || + error == EINVAL) + return SEMANTIC_VERIFY_STRUCTURAL; + return SEMANTIC_VERIFY_ERROR; +} +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +void semantic_verify_file_at(int parent_fd, const char *basename, + const struct stat *observed, + dev_t root_dev, + const struct cache_entry *ce, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result) +{ + struct stat path_before = *observed, fd_before, fd_after, path_after; + struct object_id oid; + int fd = -1; + int saved_errno; + + memset(result, 0, sizeof(*result)); + if (!mode_matches_ce(repo, ce, &path_before)) { + result->kind = SEMANTIC_VERIFY_RAW_MODIFIED; + return; + } + if (path_before.st_size < 0 || + (uintmax_t)path_before.st_size > (uintmax_t)SIZE_MAX) { + result->kind = SEMANTIC_VERIFY_SENSITIVE; + return; + } + + fd = semantic_verify_openat(parent_fd, basename, + O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0) { + result->error = errno; + result->kind = errno == ENOENT || errno == ENOTDIR || + errno == ELOOP ? + SEMANTIC_VERIFY_UNSTABLE : SEMANTIC_VERIFY_ERROR; + return; + } + if (fstat(fd, &fd_before)) + goto unstable; + if (fd_before.st_dev != root_dev || + !path_namespace_stat_equal(&path_before, &fd_before)) { + errno = EAGAIN; + goto unstable; + } + if (hash_raw_blob(fd, (size_t)fd_before.st_size, repo->hash_algo, &oid, + buffer, &result->bytes_hashed)) + goto unstable; + if (fstat(fd, &fd_after)) + goto unstable; + if (fstatat(parent_fd, basename, &path_after, AT_SYMLINK_NOFOLLOW)) + goto unstable; + if (!path_namespace_stat_equal(&fd_before, &fd_after) || + !path_namespace_stat_equal(&fd_after, &path_after)) { + errno = EAGAIN; + goto unstable; + } + if (path_namespace_reopen_component( + parent_fd, basename, O_RDONLY | O_NONBLOCK | O_NOFOLLOW, + semantic_verify_openat, &fd_after)) + goto unstable; + close(fd); + + if (!oideq(&oid, &ce->oid)) { + result->kind = SEMANTIC_VERIFY_RAW_MODIFIED; + return; + } + result->kind = SEMANTIC_VERIFY_RAW_CLEAN; + result->persistable = fd_after.st_nlink == 1; + fill_stat_data(&result->stat_data, &fd_after); + return; + +unstable: + saved_errno = errno; + close(fd); + result->kind = SEMANTIC_VERIFY_UNSTABLE; + result->error = saved_errno; +} + +void semantic_verify_file(struct semantic_verify_root *root, + struct semantic_verify_path *path, + const struct cache_entry *ce, size_t cache_pos, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result) +{ + struct stat path_before; + const char *basename; + int parent_fd; + + memset(result, 0, sizeof(*result)); + if (semantic_verify_resolve_parent(path, ce->name, cache_pos, + &parent_fd, &basename)) { + result->error = errno; + result->kind = classify_resolve_error(errno); + return; + } + if (fstatat(parent_fd, basename, &path_before, AT_SYMLINK_NOFOLLOW)) { + result->error = errno; + result->kind = errno == ENOENT || errno == ENOTDIR ? + SEMANTIC_VERIFY_RAW_MODIFIED : SEMANTIC_VERIFY_ERROR; + return; + } + semantic_verify_file_at(parent_fd, basename, &path_before, + root->stat.st_dev, ce, repo, buffer, result); +} +#else +static void semantic_verify_file_unavailable( + struct semantic_verify_file_result *result) +{ + memset(result, 0, sizeof(*result)); + result->kind = SEMANTIC_VERIFY_ERROR; + result->error = ENOSYS; +} + +void semantic_verify_file_at( + int parent_fd UNUSED, const char *basename UNUSED, + const struct stat *observed UNUSED, + dev_t root_dev UNUSED, + const struct cache_entry *ce UNUSED, + struct repository *repo UNUSED, void *buffer UNUSED, + struct semantic_verify_file_result *result) +{ + semantic_verify_file_unavailable(result); +} + +void semantic_verify_file( + struct semantic_verify_root *root UNUSED, + struct semantic_verify_path *path UNUSED, + const struct cache_entry *ce UNUSED, size_t cache_pos UNUSED, + struct repository *repo UNUSED, void *buffer UNUSED, + struct semantic_verify_file_result *result) +{ + semantic_verify_file_unavailable(result); +} +#endif diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index 1f881af44fba6f..a1451feb0e0e30 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -27,8 +27,12 @@ #endif struct repository; +struct cache_entry; +struct git_hash_algo; struct semantic_verify_path; +#define SEMANTIC_VERIFY_HASH_BUFFER_SIZE (256 * 1024) + struct semantic_verify_root { int fd; char *path; @@ -50,4 +54,24 @@ void semantic_verify_path_free(struct semantic_verify_path *path, unsigned int *namespace_unstable, size_t *namespace_unstable_from); +struct semantic_verify_file_result { + struct stat_data stat_data; + size_t bytes_hashed; + int error; + unsigned int kind; + unsigned int persistable; +}; + +void semantic_verify_file(struct semantic_verify_root *root, + struct semantic_verify_path *path, + const struct cache_entry *ce, size_t cache_pos, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result); +void semantic_verify_file_at(int parent_fd, const char *basename, + const struct stat *observed, + dev_t root_dev, + const struct cache_entry *ce, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result); + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify.h b/semantic-verify.h new file mode 100644 index 00000000000000..17b13de3765c53 --- /dev/null +++ b/semantic-verify.h @@ -0,0 +1,15 @@ +#ifndef SEMANTIC_VERIFY_H +#define SEMANTIC_VERIFY_H + +enum semantic_verify_kind { + SEMANTIC_VERIFY_UNCHECKED = 0, + SEMANTIC_VERIFY_SKIPPED, + SEMANTIC_VERIFY_RAW_CLEAN, + SEMANTIC_VERIFY_RAW_MODIFIED, + SEMANTIC_VERIFY_SENSITIVE, + SEMANTIC_VERIFY_STRUCTURAL, + SEMANTIC_VERIFY_UNSTABLE, + SEMANTIC_VERIFY_ERROR, +}; + +#endif /* SEMANTIC_VERIFY_H */ From 1f7d58a97c4ad4142ed6e136df13b99e08ba61b5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:45:25 -0700 Subject: [PATCH 007/403] convert: support independent conversion attribute checks convert_attrs() evaluates conversion attributes through a single process-global attr_check. Sharing that mutable check between workers would let concurrent path evaluations overwrite each other's results. Separate singleton initialization from attribute evaluation. Provide an allocator for the same six conversion attributes and an evaluator that accepts a caller-owned check. Keep convert_attrs() on its existing initialized singleton, so ordinary filters, encoding, ident expansion, and line-ending decisions retain their previous behavior. Each concurrent caller must provide a distinct six-attribute check while global conversion and attribute state remains unchanged. This patch does not introduce the raw-safe predicate, prepare conversion state for workers, or claim that existing conversion tests were run at this intermediate commit. Signed-off-by: Taylor Blau --- convert.c | 45 +++++++++++++++++++++++++++++++++++++++------ convert.h | 19 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/convert.c b/convert.c index 036506842c3d41..9f44eba7d16439 100644 --- a/convert.c +++ b/convert.c @@ -1318,11 +1318,8 @@ static int git_path_check_ident(struct attr_check_item *check) static struct attr_check *check; -void convert_attrs(struct index_state *istate, - struct conv_attrs *ca, const char *path) +static void convert_attrs_init(void) { - struct attr_check_item *ccheck = NULL; - if (!check) { check = attr_check_initl("crlf", "ident", "filter", "eol", "text", "working-tree-encoding", @@ -1330,9 +1327,26 @@ void convert_attrs(struct index_state *istate, user_convert_tail = &user_convert; repo_config(the_repository, read_convert_config, NULL); } +} - git_check_attr(istate, path, check); - ccheck = check->items; +struct attr_check *convert_attrs_check_alloc(void) +{ + return attr_check_initl("crlf", "ident", "filter", + "eol", "text", "working-tree-encoding", + NULL); +} + +void convert_attrs_with_check(struct index_state *istate, + struct conv_attrs *ca, const char *path, + struct attr_check *attr_check) +{ + struct attr_check_item *ccheck; + + if (!attr_check || attr_check->nr != 6) + BUG("invalid per-thread conversion attribute check"); + + git_check_attr(istate, path, attr_check); + ccheck = attr_check->items; ca->crlf_action = git_path_check_crlf(ccheck + 4); if (ca->crlf_action == CRLF_UNDEFINED) ca->crlf_action = git_path_check_crlf(ccheck + 0); @@ -1363,6 +1377,25 @@ void convert_attrs(struct index_state *istate, ca->crlf_action = CRLF_AUTO_INPUT; } +void convert_attrs(struct index_state *istate, + struct conv_attrs *ca, const char *path) +{ + convert_attrs_init(); + convert_attrs_with_check(istate, ca, path, check); +} + +int convert_attrs_has_clean_filter(const struct conv_attrs *ca) +{ + return ca->drv && + (ca->drv->clean || ca->drv->process || ca->drv->required); +} + +int convert_attrs_are_raw_safe(const struct conv_attrs *ca) +{ + return !ca->drv && !ca->working_tree_encoding && !ca->ident && + ca->crlf_action == CRLF_BINARY; +} + void reset_parsed_attributes(void) { struct convert_driver *drv, *next; diff --git a/convert.h b/convert.h index 0a6e4086b8f932..b855919fa0bd3a 100644 --- a/convert.h +++ b/convert.h @@ -8,6 +8,7 @@ #include "string-list.h" struct index_state; +struct attr_check; struct strbuf; #define CONV_EOL_RNDTRP_DIE (1<<0) /* Die if CRLF to LF to CRLF is different */ @@ -91,6 +92,24 @@ struct conv_attrs { void convert_attrs(struct index_state *istate, struct conv_attrs *ca, const char *path); +/* Allocate the exact six-attribute check used by convert_attrs(). */ +struct attr_check *convert_attrs_check_alloc(void); + +/* + * Thread-friendly variant. Each concurrent caller must supply a distinct + * check allocated by convert_attrs_check_alloc(), and conversion/attribute + * global state must remain immutable until all callers have finished. + */ +void convert_attrs_with_check(struct index_state *istate, + struct conv_attrs *ca, const char *path, + struct attr_check *check); + +/* True when the selected driver can affect conversion into the index. */ +int convert_attrs_has_clean_filter(const struct conv_attrs *ca); + +/* True only when hashing the worktree bytes verbatim is exact. */ +int convert_attrs_are_raw_safe(const struct conv_attrs *ca); + extern enum eol core_eol; extern char *check_roundtrip_encoding; const char *get_cached_convert_stats_ascii(struct index_state *istate, From 9f4f43158bdaa6fbed0b16ca1515346e5acc1481 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:15:13 -0700 Subject: [PATCH 008/403] status: classify candidates for semantic verification Comparing raw worktree bytes with an indexed object is valid only when the indexed entry is an ordinary file and conversion cannot alter its canonical content. Index promises, sparse entries, staged conflicts, intent-to-add entries, and active conversion cannot share that proof. Introduce convert_attrs_is_raw_safe() and require a binary conversion action with no filter, working-tree encoding, or ident expansion. Classify skip-worktree and assumed-valid entries as skipped; classify conflicts, intent-to-add entries, and sparse directories as structural; leave converted and nonregular files to the ordinary refresh path. Run eligible entries over one contiguous index range using an independent attribute check, a private hash buffer, and a pinned parent resolver. Collect results and replacement stat data without modifying the index. If a cached ancestor changes, downgrade affected clean results to unstable. Register the worker in both Make and Meson. This patch introduces the raw-safe predicate and internal range worker; it does not yet expose a complete proof, add the test helper, or start verifier threads. Signed-off-by: Taylor Blau --- Makefile | 1 + convert.c | 9 ++++ convert.h | 4 ++ meson.build | 1 + semantic-verify-file.c | 38 +++++++++++++ semantic-verify-internal.h | 37 +++++++++++++ semantic-verify-worker.c | 108 +++++++++++++++++++++++++++++++++++++ semantic-verify.h | 5 ++ 8 files changed, 203 insertions(+) create mode 100644 semantic-verify-worker.c diff --git a/Makefile b/Makefile index b7ee8bcb8e9379..9e159a28fb6959 100644 --- a/Makefile +++ b/Makefile @@ -1320,6 +1320,7 @@ LIB_OBJS += send-pack.o LIB_OBJS += semantic-verify-file.o LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o +LIB_OBJS += semantic-verify-worker.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o LIB_OBJS += server-info.o diff --git a/convert.c b/convert.c index 9f44eba7d16439..c0ec8781ab2a76 100644 --- a/convert.c +++ b/convert.c @@ -1396,6 +1396,15 @@ int convert_attrs_are_raw_safe(const struct conv_attrs *ca) ca->crlf_action == CRLF_BINARY; } +int convert_attrs_is_raw_safe(struct index_state *istate, const char *path, + struct attr_check *attr_check) +{ + struct conv_attrs ca; + + convert_attrs_with_check(istate, &ca, path, attr_check); + return convert_attrs_are_raw_safe(&ca); +} + void reset_parsed_attributes(void) { struct convert_driver *drv, *next; diff --git a/convert.h b/convert.h index b855919fa0bd3a..017f5966d5d260 100644 --- a/convert.h +++ b/convert.h @@ -110,6 +110,10 @@ int convert_attrs_has_clean_filter(const struct conv_attrs *ca); /* True only when hashing the worktree bytes verbatim is exact. */ int convert_attrs_are_raw_safe(const struct conv_attrs *ca); +/* True only when hashing the worktree bytes verbatim is exact. */ +int convert_attrs_is_raw_safe(struct index_state *istate, const char *path, + struct attr_check *check); + extern enum eol core_eol; extern char *check_roundtrip_encoding; const char *get_cached_convert_stats_ascii(struct index_state *istate, diff --git a/meson.build b/meson.build index 194f2f11456e93..91fde4044ad768 100644 --- a/meson.build +++ b/meson.build @@ -526,6 +526,7 @@ libgit_sources = [ 'semantic-verify-file.c', 'semantic-verify-path.c', 'semantic-verify-root.c', + 'semantic-verify-worker.c', 'serve.c', 'server-info.c', 'setup.c', diff --git a/semantic-verify-file.c b/semantic-verify-file.c index 5a06809060e58d..811b9fc43355fc 100644 --- a/semantic-verify-file.c +++ b/semantic-verify-file.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "convert.h" #include "environment.h" #include "object-file.h" #include "path-namespace.h" @@ -78,6 +79,43 @@ static unsigned int classify_resolve_error(int error) } #endif +int semantic_verify_classify_entry(struct index_state *istate, + const struct cache_entry *ce, + struct attr_check *check, + int validate_filter_scope, + struct semantic_verify_file_result *result) +{ + struct conv_attrs ca; + int attrs_resolved = 0; + + memset(result, 0, sizeof(*result)); + if (validate_filter_scope) { + convert_attrs_with_check(istate, &ca, ce->name, check); + attrs_resolved = 1; + result->active_filter = convert_attrs_has_clean_filter(&ca); + } + if (ce_skip_worktree(ce) || (ce->ce_flags & CE_VALID)) { + result->kind = SEMANTIC_VERIFY_SKIPPED; + return 0; + } + if (ce_stage(ce) || ce_intent_to_add(ce) || + S_ISSPARSEDIR(ce->ce_mode)) { + result->kind = SEMANTIC_VERIFY_STRUCTURAL; + return 0; + } + if (!S_ISREG(ce->ce_mode)) { + result->kind = SEMANTIC_VERIFY_SENSITIVE; + return 0; + } + if (!attrs_resolved) + convert_attrs_with_check(istate, &ca, ce->name, check); + if (!convert_attrs_are_raw_safe(&ca)) { + result->kind = SEMANTIC_VERIFY_SENSITIVE; + return 0; + } + return 1; +} + #if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN void semantic_verify_file_at(int parent_fd, const char *basename, const struct stat *observed, diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index a1451feb0e0e30..16729e60fbddf1 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -26,9 +26,12 @@ #define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 0 #endif +struct attr_check; struct repository; struct cache_entry; struct git_hash_algo; +struct index_state; +struct semantic_verify_result; struct semantic_verify_path; #define SEMANTIC_VERIFY_HASH_BUFFER_SIZE (256 * 1024) @@ -60,8 +63,14 @@ struct semantic_verify_file_result { int error; unsigned int kind; unsigned int persistable; + unsigned int active_filter; }; +int semantic_verify_classify_entry(struct index_state *istate, + const struct cache_entry *ce, + struct attr_check *check, + int validate_filter_scope, + struct semantic_verify_file_result *result); void semantic_verify_file(struct semantic_verify_root *root, struct semantic_verify_path *path, const struct cache_entry *ce, size_t cache_pos, @@ -74,4 +83,32 @@ void semantic_verify_file_at(int parent_fd, const char *basename, struct repository *repo, void *buffer, struct semantic_verify_file_result *result); +struct semantic_verify_stat_update { + uint32_t cache_pos; + struct stat_data stat_data; +}; + +struct semantic_verify_worker { + struct index_state *istate; + struct semantic_verify_root *root; + struct semantic_verify_result *results; + size_t start; + size_t end; + struct semantic_verify_stat_update *updates; + size_t updates_nr; + size_t updates_alloc; + size_t bytes_hashed; + size_t raw_clean; + size_t raw_modified; + size_t sensitive; + size_t structural; + size_t skipped; + size_t unstable; + size_t errors; + size_t hardlinks; + unsigned int namespace_unstable; +}; + +void semantic_verify_worker_run(struct semantic_verify_worker *worker); + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c new file mode 100644 index 00000000000000..5ebd9cd26cc8a0 --- /dev/null +++ b/semantic-verify-worker.c @@ -0,0 +1,108 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "git-compat-util.h" +#include "attr.h" +#include "convert.h" +#include "object.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" + +static void record_stat_update(struct semantic_verify_worker *worker, + uint32_t cache_pos, + const struct stat_data *stat_data) +{ + struct semantic_verify_stat_update *update; + + ALLOC_GROW(worker->updates, worker->updates_nr + 1, + worker->updates_alloc); + update = &worker->updates[worker->updates_nr++]; + update->cache_pos = cache_pos; + memcpy(&update->stat_data, stat_data, sizeof(*stat_data)); +} + +static void count_result(struct semantic_verify_worker *worker, + enum semantic_verify_kind kind) +{ + switch (kind) { + case SEMANTIC_VERIFY_SKIPPED: + worker->skipped++; + break; + case SEMANTIC_VERIFY_RAW_CLEAN: + worker->raw_clean++; + break; + case SEMANTIC_VERIFY_RAW_MODIFIED: + worker->raw_modified++; + break; + case SEMANTIC_VERIFY_SENSITIVE: + worker->sensitive++; + break; + case SEMANTIC_VERIFY_STRUCTURAL: + worker->structural++; + break; + case SEMANTIC_VERIFY_UNSTABLE: + worker->unstable++; + break; + case SEMANTIC_VERIFY_ERROR: + worker->errors++; + break; + case SEMANTIC_VERIFY_UNCHECKED: + BUG("cannot count an unchecked semantic result"); + } +} + +void semantic_verify_worker_run(struct semantic_verify_worker *worker) +{ + struct semantic_verify_path *path = + semantic_verify_path_new(worker->root); + struct attr_check *check = convert_attrs_check_alloc(); + void *buffer = xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); + size_t unstable_from = SIZE_MAX; + + for (size_t i = worker->start; i < worker->end; i++) { + struct cache_entry *ce = worker->istate->cache[i]; + struct semantic_verify_result *result = &worker->results[i]; + struct semantic_verify_file_result file; + + if (!semantic_verify_classify_entry(worker->istate, ce, check, 0, + &file)) { + result->kind = file.kind; + count_result(worker, result->kind); + continue; + } + + semantic_verify_file(worker->root, path, ce, i, + worker->istate->repo, + buffer, &file); + result->kind = file.kind; + result->error = file.error > UINT16_MAX ? EIO : file.error; + worker->bytes_hashed += file.bytes_hashed; + if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) { + if (!file.persistable) + worker->hardlinks++; + if (memcmp(&file.stat_data, &ce->ce_stat_data, + sizeof(file.stat_data))) + record_stat_update(worker, i, &file.stat_data); + } + count_result(worker, result->kind); + } + + semantic_verify_path_free(path, &worker->namespace_unstable, + &unstable_from); + if (worker->namespace_unstable) { + for (size_t i = unstable_from; i < worker->end; i++) { + struct semantic_verify_result *result = &worker->results[i]; + + if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) + continue; + result->kind = SEMANTIC_VERIFY_UNSTABLE; + result->error = EAGAIN; + worker->raw_clean--; + worker->unstable++; + } + } + + free(buffer); + attr_check_free(check); +} diff --git a/semantic-verify.h b/semantic-verify.h index 17b13de3765c53..f6b63a2c2b220b 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -12,4 +12,9 @@ enum semantic_verify_kind { SEMANTIC_VERIFY_ERROR, }; +struct semantic_verify_result { + uint16_t error; + uint8_t kind; +}; + #endif /* SEMANTIC_VERIFY_H */ From 9169e5a1622dc8392eac32b16155d9771fda29ad Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:00 -0700 Subject: [PATCH 009/403] status: assemble and test serial semantic proof candidates The descriptor root, pinned path resolver, file verifier, and range worker cannot demonstrate a complete verification result until one caller coordinates their inputs and exposes the classifications. Introduce semantic_verify_prepare() and assemble one serial proof without changing any cache entry. Initialize conversion and root attribute state on the calling thread, retain per-entry results and stat updates, count each classification, and distinguish persistable single-link files from clean hardlinks. Reject sparse indexes and unavailable anchored opens without marking any entry clean. Add test-tool semantic-verify and register the library, helper, and integration suite in both build systems. The new tests exercise raw, converted, nested, modified, deleted, multiply-linked, structural, and SHA-256 entries, as well as unsupported-platform fallback. This is the first executable consumer of the proof primitives. It does not apply results to the index, create worker threads, or enable semantic verification in a production status command. Signed-off-by: Taylor Blau --- Makefile | 2 + convert.c | 7 ++ convert.h | 6 ++ meson.build | 1 + semantic-verify-internal.h | 20 +++++ semantic-verify-root.c | 13 +++ semantic-verify-worker.c | 5 +- semantic-verify.c | 142 ++++++++++++++++++++++++++++++++ semantic-verify.h | 37 +++++++++ t/helper/meson.build | 1 + t/helper/test-semantic-verify.c | 90 ++++++++++++++++++++ t/helper/test-tool.c | 1 + t/helper/test-tool.h | 1 + t/lib-semantic-verify.sh | 9 ++ t/meson.build | 1 + t/t7531-semantic-verify.sh | 104 +++++++++++++++++++++++ 16 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 semantic-verify.c create mode 100644 t/helper/test-semantic-verify.c create mode 100644 t/lib-semantic-verify.sh create mode 100755 t/t7531-semantic-verify.sh diff --git a/Makefile b/Makefile index 9e159a28fb6959..0c8545ab270abe 100644 --- a/Makefile +++ b/Makefile @@ -866,6 +866,7 @@ TEST_BUILTINS_OBJS += test-repository.o TEST_BUILTINS_OBJS += test-revision-walking.o TEST_BUILTINS_OBJS += test-run-command.o TEST_BUILTINS_OBJS += test-scrap-cache-tree.o +TEST_BUILTINS_OBJS += test-semantic-verify.o TEST_BUILTINS_OBJS += test-serve-v2.o TEST_BUILTINS_OBJS += test-sha1.o TEST_BUILTINS_OBJS += test-sha256.o @@ -1321,6 +1322,7 @@ LIB_OBJS += semantic-verify-file.o LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o LIB_OBJS += semantic-verify-worker.o +LIB_OBJS += semantic-verify.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o LIB_OBJS += server-info.o diff --git a/convert.c b/convert.c index c0ec8781ab2a76..f7ca4d780466e7 100644 --- a/convert.c +++ b/convert.c @@ -1336,6 +1336,13 @@ struct attr_check *convert_attrs_check_alloc(void) NULL); } +void convert_attrs_prepare(struct index_state *istate) +{ + convert_attrs_init(); + /* Prime default_attr_source() and the root attribute stack on main. */ + git_check_attr(istate, "", check); +} + void convert_attrs_with_check(struct index_state *istate, struct conv_attrs *ca, const char *path, struct attr_check *attr_check) diff --git a/convert.h b/convert.h index 017f5966d5d260..241cd65c6e02a9 100644 --- a/convert.h +++ b/convert.h @@ -92,6 +92,12 @@ struct conv_attrs { void convert_attrs(struct index_state *istate, struct conv_attrs *ca, const char *path); +/* + * Prepare conversion configuration and the default attribute source on the + * main thread before using per-thread attribute checks below. + */ +void convert_attrs_prepare(struct index_state *istate); + /* Allocate the exact six-attribute check used by convert_attrs(). */ struct attr_check *convert_attrs_check_alloc(void); diff --git a/meson.build b/meson.build index 91fde4044ad768..ff471a3a092f5a 100644 --- a/meson.build +++ b/meson.build @@ -527,6 +527,7 @@ libgit_sources = [ 'semantic-verify-path.c', 'semantic-verify-root.c', 'semantic-verify-worker.c', + 'semantic-verify.c', 'serve.c', 'server-info.c', 'setup.c', diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index 16729e60fbddf1..bf1753bf17a6cc 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -44,6 +44,7 @@ struct semantic_verify_root { int semantic_verify_root_init(struct repository *repo, struct semantic_verify_root **root_out); +int semantic_verify_root_stable(const struct semantic_verify_root *root); void semantic_verify_root_clear(struct semantic_verify_root *root); int semantic_verify_openat(int dirfd, const char *path, int flags); @@ -111,4 +112,23 @@ struct semantic_verify_worker { void semantic_verify_worker_run(struct semantic_verify_worker *worker); +struct semantic_verify_proof { + struct index_state *istate; + struct semantic_verify_root *root; + struct semantic_verify_result *results; + struct semantic_verify_stat_update *stat_updates; + size_t cache_nr; + size_t stat_updates_nr; + size_t bytes_hashed; + size_t raw_clean; + size_t raw_modified; + size_t sensitive; + size_t structural; + size_t skipped; + size_t unstable; + size_t errors; + size_t hardlinks; + unsigned int namespace_unstable; +}; + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-root.c b/semantic-verify-root.c index e896cbe51f7c94..fbec93ac147607 100644 --- a/semantic-verify-root.c +++ b/semantic-verify-root.c @@ -144,6 +144,19 @@ int semantic_verify_root_init(struct repository *repo UNUSED, } #endif +int semantic_verify_root_stable(const struct semantic_verify_root *root) +{ + struct stat fd_stat, path_stat; + + if (!root || root->fd < 0) + return 0; + if (fstat(root->fd, &fd_stat) || lstat(root->path, &path_stat) || + !S_ISDIR(path_stat.st_mode)) + return 0; + return path_namespace_stat_equal(&root->stat, &fd_stat) && + path_namespace_stat_equal(&fd_stat, &path_stat); +} + void semantic_verify_root_clear(struct semantic_verify_root *root) { if (!root) diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index 5ebd9cd26cc8a0..47e46e8e19b0ee 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -79,7 +79,9 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) result->error = file.error > UINT16_MAX ? EIO : file.error; worker->bytes_hashed += file.bytes_hashed; if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) { - if (!file.persistable) + if (file.persistable) + result->flags |= SEMANTIC_VERIFY_PERSISTABLE; + else worker->hardlinks++; if (memcmp(&file.stat_data, &ce->ce_stat_data, sizeof(file.stat_data))) @@ -97,6 +99,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) continue; result->kind = SEMANTIC_VERIFY_UNSTABLE; + result->flags = 0; result->error = EAGAIN; worker->raw_clean--; worker->unstable++; diff --git a/semantic-verify.c b/semantic-verify.c new file mode 100644 index 00000000000000..d58666ae058d8a --- /dev/null +++ b/semantic-verify.c @@ -0,0 +1,142 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "git-compat-util.h" +#include "convert.h" +#include "object.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" +#include "trace2.h" + +static void combine_worker(struct semantic_verify_proof *proof, + struct semantic_verify_worker *worker) +{ + size_t base = proof->stat_updates_nr; + + if (worker->updates_nr) + COPY_ARRAY(proof->stat_updates + base, worker->updates, + worker->updates_nr); + proof->stat_updates_nr += worker->updates_nr; + proof->bytes_hashed += worker->bytes_hashed; + proof->raw_clean += worker->raw_clean; + proof->raw_modified += worker->raw_modified; + proof->sensitive += worker->sensitive; + proof->structural += worker->structural; + proof->skipped += worker->skipped; + proof->unstable += worker->unstable; + proof->errors += worker->errors; + proof->hardlinks += worker->hardlinks; + proof->namespace_unstable |= worker->namespace_unstable; + free(worker->updates); +} + +int semantic_verify_prepare(struct index_state *istate, + struct semantic_verify_proof **proof_out) +{ + struct semantic_verify_proof *proof; + struct semantic_verify_worker worker = { 0 }; + + if (!istate || !proof_out) + BUG("semantic_verify_prepare requires an index and output"); + + CALLOC_ARRAY(proof, 1); + proof->istate = istate; + proof->cache_nr = istate->cache_nr; + CALLOC_ARRAY(proof->results, proof->cache_nr); + *proof_out = proof; + if (!proof->cache_nr) + return 0; + if (istate->sparse_index != INDEX_EXPANDED) { + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_STRUCTURAL; + proof->structural++; + } + return 0; + } + if (semantic_verify_root_init(istate->repo, &proof->root)) { + int saved_errno = errno; + + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_ERROR; + proof->results[i].error = saved_errno > UINT16_MAX ? + EIO : saved_errno; + } + proof->errors = proof->cache_nr; + return -1; + } + + /* Initialize conversion config and default attribute state serially. */ + convert_attrs_prepare(istate); + trace2_region_enter("semantic_verify", "prepare", istate->repo); + trace2_data_intmax("semantic_verify", istate->repo, "threads", 1); + trace2_data_intmax("semantic_verify", istate->repo, + "result-bytes", sizeof(struct semantic_verify_result)); + + worker.istate = istate; + worker.root = proof->root; + worker.results = proof->results; + worker.end = proof->cache_nr; + semantic_verify_worker_run(&worker); + ALLOC_ARRAY(proof->stat_updates, worker.updates_nr); + combine_worker(proof, &worker); + + trace2_data_intmax("semantic_verify", istate->repo, + "raw-clean", proof->raw_clean); + trace2_data_intmax("semantic_verify", istate->repo, + "raw-modified", proof->raw_modified); + trace2_data_intmax("semantic_verify", istate->repo, + "sensitive", proof->sensitive); + trace2_data_intmax("semantic_verify", istate->repo, + "structural", proof->structural); + trace2_data_intmax("semantic_verify", istate->repo, + "unstable", proof->unstable); + trace2_data_intmax("semantic_verify", istate->repo, + "errors", proof->errors); + trace2_data_intmax("semantic_verify", istate->repo, + "bytes-hashed", proof->bytes_hashed); + trace2_region_leave("semantic_verify", "prepare", istate->repo); + return 0; +} + +int semantic_verify_root_is_stable(const struct semantic_verify_proof *proof) +{ + return proof && semantic_verify_root_stable(proof->root); +} + +void semantic_verify_get_stats(const struct semantic_verify_proof *proof, + struct semantic_verify_stats *stats) +{ + if (!proof || !stats) + BUG("semantic_verify_get_stats requires proof and output"); + stats->cache_nr = proof->cache_nr; + stats->stat_updates_nr = proof->stat_updates_nr; + stats->bytes_hashed = proof->bytes_hashed; + stats->raw_clean = proof->raw_clean; + stats->raw_modified = proof->raw_modified; + stats->sensitive = proof->sensitive; + stats->structural = proof->structural; + stats->skipped = proof->skipped; + stats->unstable = proof->unstable; + stats->errors = proof->errors; + stats->hardlinks = proof->hardlinks; + stats->namespace_unstable = proof->namespace_unstable; +} + +const struct semantic_verify_result *semantic_verify_result_at( + const struct semantic_verify_proof *proof, size_t cache_pos) +{ + if (!proof || cache_pos >= proof->cache_nr) + BUG("semantic verifier result position out of range"); + return &proof->results[cache_pos]; +} + +void semantic_verify_proof_clear(struct semantic_verify_proof *proof) +{ + if (!proof) + return; + semantic_verify_root_clear(proof->root); + free(proof->stat_updates); + free(proof->results); + free(proof); +} diff --git a/semantic-verify.h b/semantic-verify.h index f6b63a2c2b220b..798dad30ae5ae4 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -1,6 +1,9 @@ #ifndef SEMANTIC_VERIFY_H #define SEMANTIC_VERIFY_H +struct index_state; +struct semantic_verify_proof; + enum semantic_verify_kind { SEMANTIC_VERIFY_UNCHECKED = 0, SEMANTIC_VERIFY_SKIPPED, @@ -12,9 +15,43 @@ enum semantic_verify_kind { SEMANTIC_VERIFY_ERROR, }; +enum semantic_verify_result_flags { + /* The clean result may receive persistent fsmonitor validity. */ + SEMANTIC_VERIFY_PERSISTABLE = (1u << 0), +}; + struct semantic_verify_result { uint16_t error; uint8_t kind; + uint8_t flags; }; +struct semantic_verify_stats { + size_t cache_nr; + size_t stat_updates_nr; + size_t bytes_hashed; + size_t raw_clean; + size_t raw_modified; + size_t sensitive; + size_t structural; + size_t skipped; + size_t unstable; + size_t errors; + size_t hardlinks; + unsigned int namespace_unstable; +}; + +/* Build a proof candidate without changing the index. */ +int semantic_verify_prepare(struct index_state *istate, + struct semantic_verify_proof **proof_out); +int semantic_verify_root_is_stable( + const struct semantic_verify_proof *proof); +void semantic_verify_proof_clear(struct semantic_verify_proof *proof); + +/* Introspection used by the semantic verifier test helper. */ +void semantic_verify_get_stats(const struct semantic_verify_proof *proof, + struct semantic_verify_stats *stats); +const struct semantic_verify_result *semantic_verify_result_at( + const struct semantic_verify_proof *proof, size_t cache_pos); + #endif /* SEMANTIC_VERIFY_H */ diff --git a/t/helper/meson.build b/t/helper/meson.build index 3235f10ab8aae1..7c97bfb1e6ec51 100644 --- a/t/helper/meson.build +++ b/t/helper/meson.build @@ -59,6 +59,7 @@ test_tool_sources = [ 'test-rot13-filter.c', 'test-run-command.c', 'test-scrap-cache-tree.c', + 'test-semantic-verify.c', 'test-serve-v2.c', 'test-sha1.c', 'test-sha256.c', diff --git a/t/helper/test-semantic-verify.c b/t/helper/test-semantic-verify.c new file mode 100644 index 00000000000000..bb1cffe99201a1 --- /dev/null +++ b/t/helper/test-semantic-verify.c @@ -0,0 +1,90 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "test-tool.h" +#include "config.h" +#include "parse-options.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "setup.h" + +static const char *kind_name(enum semantic_verify_kind kind) +{ + switch (kind) { + case SEMANTIC_VERIFY_UNCHECKED: + return "unchecked"; + case SEMANTIC_VERIFY_SKIPPED: + return "skipped"; + case SEMANTIC_VERIFY_RAW_CLEAN: + return "raw-clean"; + case SEMANTIC_VERIFY_RAW_MODIFIED: + return "raw-modified"; + case SEMANTIC_VERIFY_SENSITIVE: + return "sensitive"; + case SEMANTIC_VERIFY_STRUCTURAL: + return "structural"; + case SEMANTIC_VERIFY_UNSTABLE: + return "unstable"; + case SEMANTIC_VERIFY_ERROR: + return "error"; + } + BUG("unknown semantic verification kind"); +} + +int cmd__semantic_verify(int argc, const char **argv) +{ + struct semantic_verify_proof *proof = NULL; + struct semantic_verify_stats stats; + int show_results = 0; + int ret; + const char * const usage[] = { + "test-tool semantic-verify []", + NULL + }; + struct option opts[] = { + OPT_BOOL(0, "show-results", &show_results, + "show one result per cache entry"), + OPT_END() + }; + + argc = parse_options(argc, argv, NULL, opts, usage, 0); + if (argc) + usage_with_options(usage, opts); + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + prepare_repo_settings(the_repository); + the_repository->settings.command_requires_full_index = 0; + if (repo_read_index(the_repository) < 0) + die("unable to read index"); + ret = semantic_verify_prepare(the_repository->index, &proof); + semantic_verify_get_stats(proof, &stats); + if (show_results) { + for (size_t i = 0; i < stats.cache_nr; i++) { + const struct semantic_verify_result *result = + semantic_verify_result_at(proof, i); + + printf("%s %s persist=%d error=%u\n", + the_repository->index->cache[i]->name, + kind_name(result->kind), + !!(result->flags & SEMANTIC_VERIFY_PERSISTABLE), + result->error); + } + } + printf("entries=%"PRIuMAX" clean=%"PRIuMAX + " modified=%"PRIuMAX" sensitive=%"PRIuMAX + " structural=%"PRIuMAX" unstable=%"PRIuMAX + " errors=%"PRIuMAX" hardlinks=%"PRIuMAX + " bytes=%"PRIuMAX" stat_updates=%"PRIuMAX + " root_stable=%d namespace_stable=%d\n", + (uintmax_t)stats.cache_nr, (uintmax_t)stats.raw_clean, + (uintmax_t)stats.raw_modified, (uintmax_t)stats.sensitive, + (uintmax_t)stats.structural, (uintmax_t)stats.unstable, + (uintmax_t)stats.errors, (uintmax_t)stats.hardlinks, + (uintmax_t)stats.bytes_hashed, + (uintmax_t)stats.stat_updates_nr, + semantic_verify_root_is_stable(proof), + !stats.namespace_unstable); + semantic_verify_proof_clear(proof); + return !!ret; +} diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c index b71a22b43bbc9e..5ccf3864beb235 100644 --- a/t/helper/test-tool.c +++ b/t/helper/test-tool.c @@ -70,6 +70,7 @@ static struct test_cmd cmds[] = { { "revision-walking", cmd__revision_walking }, { "run-command", cmd__run_command }, { "scrap-cache-tree", cmd__scrap_cache_tree }, + { "semantic-verify", cmd__semantic_verify }, { "serve-v2", cmd__serve_v2 }, { "sha1", cmd__sha1 }, { "sha1-is-sha1dc", cmd__sha1_is_sha1dc }, diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h index f2885b33d58aa8..d1044198247ae4 100644 --- a/t/helper/test-tool.h +++ b/t/helper/test-tool.h @@ -63,6 +63,7 @@ int cmd__repository(int argc, const char **argv); int cmd__revision_walking(int argc, const char **argv); int cmd__run_command(int argc, const char **argv); int cmd__scrap_cache_tree(int argc, const char **argv); +int cmd__semantic_verify(int argc, const char **argv); int cmd__serve_v2(int argc, const char **argv); int cmd__sha1(int argc, const char **argv); int cmd__sha1_is_sha1dc(int argc, const char **argv); diff --git a/t/lib-semantic-verify.sh b/t/lib-semantic-verify.sh new file mode 100644 index 00000000000000..b46fd064711daf --- /dev/null +++ b/t/lib-semantic-verify.sh @@ -0,0 +1,9 @@ +test_lazy_prereq SEMANTIC_VERIFY_ANCHORED_OPEN ' + test_create_repo semantic-anchored-open-probe && + test_commit -C semantic-anchored-open-probe base tracked && + ( + cd semantic-anchored-open-probe && + test-tool semantic-verify --show-results >actual && + test_grep "^tracked raw-clean " actual + ) +' diff --git a/t/meson.build b/t/meson.build index 6d4c7c4bed8fdf..4ecf8343a2f935 100644 --- a/t/meson.build +++ b/t/meson.build @@ -945,6 +945,7 @@ integration_tests = [ 't7526-commit-pathspec-file.sh', 't7527-builtin-fsmonitor.sh', 't7528-signed-commit-ssh.sh', + 't7531-semantic-verify.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh new file mode 100755 index 00000000000000..f391b86f5005aa --- /dev/null +++ b/t/t7531-semantic-verify.sh @@ -0,0 +1,104 @@ +#!/bin/sh + +test_description='descriptor-anchored semantic verification' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh + +verify_repo () { + repo=$1 && + shift && + ( + cd "$repo" && + test-tool semantic-verify "$@" + ) +} + +test_expect_success !SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'unsupported platforms decline semantic verification' ' + test_create_repo anchored-open-unsupported && + test_commit -C anchored-open-unsupported base tracked && + ( + cd anchored-open-unsupported && + test_must_fail test-tool semantic-verify --show-results + ) >actual && + test_grep "^tracked error persist=0 error=[1-9][0-9]*$" actual && + test_grep "^entries=1 clean=0 modified=0 sensitive=0 structural=0 " \ + actual && + test_grep " unstable=0 errors=1 hardlinks=0 bytes=0 " actual && + test_grep " stat_updates=0 root_stable=0 namespace_stable=1" \ + actual +' + +test_lazy_prereq HARDLINKS ' + rm -f hardlink-source hardlink-alias && + : >hardlink-source && + ln hardlink-source hardlink-alias +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'classifies raw, converted, nested, and modified files' ' + test_create_repo classify && + mkdir -p classify/a/b && + test_write_lines "converted text" >classify/.gitattributes && + for path in raw converted modified deleted a/b/nested + do + test_write_lines original >"classify/$path" || return 1 + done && + test-tool chmtime -120 classify/raw classify/converted \ + classify/modified classify/deleted classify/a/b/nested && + git -C classify add . && + git -C classify commit -m base && + cp -p classify/modified classify/mtime-reference && + test_write_lines replaced >classify/modified && + touch -r classify/mtime-reference classify/modified && + rm classify/deleted classify/mtime-reference && + + verify_repo classify --show-results >actual && + test_grep "^.gitattributes raw-clean persist=1" actual && + test_grep "^raw raw-clean persist=1" actual && + test_grep "^a/b/nested raw-clean persist=1" actual && + test_grep "^converted sensitive" actual && + test_grep "^modified raw-modified" actual && + test_grep "^deleted raw-modified" actual && + test_grep "entries=6 clean=3 modified=2 sensitive=1" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN,HARDLINKS \ + 'clean hardlinks are not persistable' ' + test_create_repo hardlink && + test_write_lines content >hardlink/tracked && + git -C hardlink add tracked && + git -C hardlink commit -m base && + ln hardlink/tracked hardlink/alias && + + verify_repo hardlink --show-results >actual && + test_grep "^tracked raw-clean persist=0" actual && + test_grep "hardlinks=1" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'classifies structural index state' ' + test_create_repo structural && + test_write_lines tracked >structural/tracked && + git -C structural add tracked && + git -C structural commit -m base && + test_write_lines intent >structural/intent && + git -C structural add -N intent && + + verify_repo structural --show-results >actual && + test_grep "^intent structural" actual && + test_grep "structural=1" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'raw hashing uses the repository object format' ' + git init --object-format=sha256 sha256 && + test_write_lines sha256 >sha256/tracked && + git -C sha256 add tracked && + git -C sha256 commit -m base && + verify_repo sha256 --show-results >actual && + test_grep "^tracked raw-clean persist=1" actual +' + +test_done From c97feb2d3552ee6b71eced194b1c47d0cb3d0001 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:40 -0700 Subject: [PATCH 010/403] status: apply complete semantic proofs atomically An index entry, worktree root, or recorded verification result can become invalid between proof preparation and index mutation. Applying early results before checking later entries would leave part of the index incorrectly trusted. Snapshot each cache entry's identity and retain an eight-byte result with an explicitly indexed optional stat update. Before modifying any entry, recheck the root, reject namespaces marked unstable during verification, validate every entry and result, and require a complete one-to-one mapping for staged stat updates. Parent directories are reopened during verification, not again during proof application. Apply only verified, persistable clean entries. Invalidate matching hardlinks and detected content changes so the ordinary refresh tail cannot accidentally accept their existing stat data. Leave converted and skipped entries to their established handling. Extend the test helper and semantic-verification suite to cover successful application, nonpersistable hardlinks, structural rejection, and replacement of an index entry after proof preparation. These tests exercise the explicit proof API; they neither replace a parent after preparation nor introduce a production status caller. Signed-off-by: Taylor Blau --- semantic-verify-internal.h | 13 +++ semantic-verify.c | 137 ++++++++++++++++++++++++++++++++ semantic-verify.h | 7 ++ t/helper/test-semantic-verify.c | 47 ++++++++++- t/t7531-semantic-verify.sh | 45 ++++++++--- 5 files changed, 234 insertions(+), 15 deletions(-) diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index bf1753bf17a6cc..f090787dd1d079 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -1,6 +1,7 @@ #ifndef SEMANTIC_VERIFY_INTERNAL_H #define SEMANTIC_VERIFY_INTERNAL_H +#include "hash.h" #include "statinfo.h" #ifdef __linux__ @@ -89,6 +90,15 @@ struct semantic_verify_stat_update { struct stat_data stat_data; }; +struct semantic_verify_entry_identity { + const struct cache_entry *entry; + struct object_id oid; + struct stat_data stat_data; + char *name; + unsigned int mode; + unsigned int flags; +}; + struct semantic_verify_worker { struct index_state *istate; struct semantic_verify_root *root; @@ -107,6 +117,7 @@ struct semantic_verify_worker { size_t unstable; size_t errors; size_t hardlinks; + size_t active_filters; unsigned int namespace_unstable; }; @@ -116,6 +127,7 @@ struct semantic_verify_proof { struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; + struct semantic_verify_entry_identity *entry_identities; struct semantic_verify_stat_update *stat_updates; size_t cache_nr; size_t stat_updates_nr; @@ -128,6 +140,7 @@ struct semantic_verify_proof { size_t unstable; size_t errors; size_t hardlinks; + size_t active_filters; unsigned int namespace_unstable; }; diff --git a/semantic-verify.c b/semantic-verify.c index d58666ae058d8a..e932addc6c1b54 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -2,6 +2,7 @@ #include "git-compat-util.h" #include "convert.h" +#include "fsmonitor.h" #include "object.h" #include "read-cache-ll.h" #include "repository.h" @@ -9,6 +10,9 @@ #include "semantic-verify-internal.h" #include "trace2.h" +#define SEMANTIC_VERIFY_ENTRY_FLAGS \ + (CE_VALID | CE_STAGEMASK | CE_INTENT_TO_ADD | CE_SKIP_WORKTREE | \ + CE_UPTODATE | CE_FSMONITOR_VALID | CE_CONTENT_CHECK_REQUIRED) static void combine_worker(struct semantic_verify_proof *proof, struct semantic_verify_worker *worker) { @@ -17,6 +21,11 @@ static void combine_worker(struct semantic_verify_proof *proof, if (worker->updates_nr) COPY_ARRAY(proof->stat_updates + base, worker->updates, worker->updates_nr); + for (size_t i = 0; i < worker->updates_nr; i++) { + uint32_t cache_pos = worker->updates[i].cache_pos; + + proof->results[cache_pos].stat_update_index = base + i; + } proof->stat_updates_nr += worker->updates_nr; proof->bytes_hashed += worker->bytes_hashed; proof->raw_clean += worker->raw_clean; @@ -39,11 +48,28 @@ int semantic_verify_prepare(struct index_state *istate, if (!istate || !proof_out) BUG("semantic_verify_prepare requires an index and output"); + if (sizeof(struct semantic_verify_result) != 8) + BUG("semantic verify result unexpectedly grew to %"PRIuMAX" bytes", + (uintmax_t)sizeof(struct semantic_verify_result)); CALLOC_ARRAY(proof, 1); proof->istate = istate; proof->cache_nr = istate->cache_nr; CALLOC_ARRAY(proof->results, proof->cache_nr); + CALLOC_ARRAY(proof->entry_identities, proof->cache_nr); + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + struct semantic_verify_entry_identity *identity = + &proof->entry_identities[i]; + + proof->results[i].stat_update_index = UINT32_MAX; + identity->entry = ce; + oidcpy(&identity->oid, &ce->oid); + identity->stat_data = ce->ce_stat_data; + identity->name = xstrdup(ce->name); + identity->mode = ce->ce_mode; + identity->flags = ce->ce_flags & SEMANTIC_VERIFY_ENTRY_FLAGS; + } *proof_out = proof; if (!proof->cache_nr) return 0; @@ -131,11 +157,122 @@ const struct semantic_verify_result *semantic_verify_result_at( return &proof->results[cache_pos]; } +int semantic_verify_apply_after_closure( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + int applied = 0; + int poisoned = 0; + size_t validated_updates = 0; + + if (!istate || !proof || proof->istate != istate || + proof->cache_nr != istate->cache_nr || + proof->namespace_unstable || + !semantic_verify_root_is_stable(proof)) + return -1; + if (proof->active_filters) { + trace2_data_intmax("semantic_verify", istate->repo, + "filter-scope-rejected", 1); + return -1; + } + + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct semantic_verify_entry_identity *identity = + &proof->entry_identities[i]; + const struct cache_entry *ce = istate->cache[i]; + + if (ce != identity->entry || + !oideq(&ce->oid, &identity->oid) || + memcmp(&ce->ce_stat_data, &identity->stat_data, + sizeof(ce->ce_stat_data)) || + strcmp(ce->name, identity->name) || + ce->ce_mode != identity->mode || + (ce->ce_flags & SEMANTIC_VERIFY_ENTRY_FLAGS) != + identity->flags) + return -1; + } + + /* Validate the complete proof before changing any cache entry. */ + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct semantic_verify_result *result = &proof->results[i]; + + if (result->kind > SEMANTIC_VERIFY_ERROR || + (result->flags & ~(SEMANTIC_VERIFY_PERSISTABLE | + SEMANTIC_VERIFY_ACTIVE_FILTER))) + return -1; + if (result->kind == SEMANTIC_VERIFY_UNCHECKED || + result->kind == SEMANTIC_VERIFY_STRUCTURAL || + result->kind == SEMANTIC_VERIFY_UNSTABLE || + result->kind == SEMANTIC_VERIFY_ERROR) + return -1; + if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) { + if (result->flags || + result->stat_update_index != UINT32_MAX) + return -1; + continue; + } + if (result->stat_update_index != UINT32_MAX) { + const struct semantic_verify_stat_update *update; + + if (result->stat_update_index >= proof->stat_updates_nr) + return -1; + update = &proof->stat_updates[result->stat_update_index]; + if (update->cache_pos != i) + return -1; + validated_updates++; + } + } + if (validated_updates != proof->stat_updates_nr) + return -1; + + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct semantic_verify_result *result = &proof->results[i]; + struct cache_entry *ce = istate->cache[i]; + + /* Force the ordinary refresh tail to preserve mismatches. */ + if (result->kind == SEMANTIC_VERIFY_RAW_MODIFIED || + (result->kind == SEMANTIC_VERIFY_RAW_CLEAN && + !(result->flags & SEMANTIC_VERIFY_PERSISTABLE))) { + fsmonitor_invalidate_cache_entry(ce); + mark_fsmonitor_invalid(istate, ce); + ce->ce_flags |= CE_UPDATE_IN_BASE; + istate->cache_changed |= CE_ENTRY_CHANGED; + poisoned++; + if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) + applied++; + continue; + } + if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) + continue; + if (result->stat_update_index != UINT32_MAX) { + const struct semantic_verify_stat_update *update = + &proof->stat_updates[result->stat_update_index]; + + memcpy(&ce->ce_stat_data, &update->stat_data, + sizeof(ce->ce_stat_data)); + ce->ce_flags |= CE_UPDATE_IN_BASE; + istate->cache_changed |= CE_ENTRY_CHANGED; + } + ce_mark_uptodate(ce); + if (result->flags & SEMANTIC_VERIFY_PERSISTABLE) + mark_fsmonitor_valid(istate, ce); + applied++; + } + trace2_data_intmax("semantic_verify", istate->repo, + "applied", applied); + trace2_data_intmax("semantic_verify", istate->repo, + "poisoned-for-tail", poisoned); + return applied; +} + void semantic_verify_proof_clear(struct semantic_verify_proof *proof) { if (!proof) return; semantic_verify_root_clear(proof->root); + for (size_t i = 0; i < proof->cache_nr; i++) + free(proof->entry_identities[i].name); + free(proof->entry_identities); free(proof->stat_updates); free(proof->results); free(proof); diff --git a/semantic-verify.h b/semantic-verify.h index 798dad30ae5ae4..dc2f02fffdacf6 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -18,9 +18,13 @@ enum semantic_verify_kind { enum semantic_verify_result_flags { /* The clean result may receive persistent fsmonitor validity. */ SEMANTIC_VERIFY_PERSISTABLE = (1u << 0), + /* The selected driver can affect conversion into the index. */ + SEMANTIC_VERIFY_ACTIVE_FILTER = (1u << 1), }; +/* Exactly eight bytes per cache entry. */ struct semantic_verify_result { + uint32_t stat_update_index; uint16_t error; uint8_t kind; uint8_t flags; @@ -44,6 +48,9 @@ struct semantic_verify_stats { /* Build a proof candidate without changing the index. */ int semantic_verify_prepare(struct index_state *istate, struct semantic_verify_proof **proof_out); +int semantic_verify_apply_after_closure( + struct index_state *istate, + const struct semantic_verify_proof *proof); int semantic_verify_root_is_stable( const struct semantic_verify_proof *proof); void semantic_verify_proof_clear(struct semantic_verify_proof *proof); diff --git a/t/helper/test-semantic-verify.c b/t/helper/test-semantic-verify.c index bb1cffe99201a1..b0048dea791490 100644 --- a/t/helper/test-semantic-verify.c +++ b/t/helper/test-semantic-verify.c @@ -36,7 +36,12 @@ int cmd__semantic_verify(int argc, const char **argv) struct semantic_verify_proof *proof = NULL; struct semantic_verify_stats stats; int show_results = 0; + int apply = 0; + int applied = -2; + int before_uptodate = 0, after_uptodate = 0; + int before_valid = 0, after_valid = 0; int ret; + const char *replace_after_prepare = NULL; const char * const usage[] = { "test-tool semantic-verify []", NULL @@ -44,6 +49,9 @@ int cmd__semantic_verify(int argc, const char **argv) struct option opts[] = { OPT_BOOL(0, "show-results", &show_results, "show one result per cache entry"), + OPT_BOOL(0, "apply", &apply, "apply the completed proof"), + OPT_STRING(0, "replace-after-prepare", &replace_after_prepare, + "path", "replace an entry after preparing the proof"), OPT_END() }; @@ -59,6 +67,29 @@ int cmd__semantic_verify(int argc, const char **argv) die("unable to read index"); ret = semantic_verify_prepare(the_repository->index, &proof); semantic_verify_get_stats(proof, &stats); + if (replace_after_prepare) { + struct index_state *istate = the_repository->index; + struct cache_entry *replacement; + int pos = index_name_pos(istate, replace_after_prepare, + strlen(replace_after_prepare)); + + if (pos < 0) + die("%s not in index", replace_after_prepare); + replacement = dup_cache_entry(istate->cache[pos], istate); + replacement->oid.hash[0] ^= 1; + replacement->ce_flags &= + ~(CE_UPTODATE | CE_FSMONITOR_VALID); + if (add_index_entry(istate, replacement, + ADD_CACHE_OK_TO_REPLACE | + ADD_CACHE_KEEP_CACHE_TREE)) + die("unable to replace %s", replace_after_prepare); + } + for (size_t i = 0; i < stats.cache_nr; i++) { + struct cache_entry *ce = the_repository->index->cache[i]; + + before_uptodate += !!ce_uptodate(ce); + before_valid += !!(ce->ce_flags & CE_FSMONITOR_VALID); + } if (show_results) { for (size_t i = 0; i < stats.cache_nr; i++) { const struct semantic_verify_result *result = @@ -71,12 +102,23 @@ int cmd__semantic_verify(int argc, const char **argv) result->error); } } + if (apply) + applied = semantic_verify_apply_after_closure( + the_repository->index, proof); + for (size_t i = 0; i < stats.cache_nr; i++) { + struct cache_entry *ce = the_repository->index->cache[i]; + + after_uptodate += !!ce_uptodate(ce); + after_valid += !!(ce->ce_flags & CE_FSMONITOR_VALID); + } printf("entries=%"PRIuMAX" clean=%"PRIuMAX " modified=%"PRIuMAX" sensitive=%"PRIuMAX " structural=%"PRIuMAX" unstable=%"PRIuMAX " errors=%"PRIuMAX" hardlinks=%"PRIuMAX " bytes=%"PRIuMAX" stat_updates=%"PRIuMAX - " root_stable=%d namespace_stable=%d\n", + " root_stable=%d namespace_stable=%d applied=%d" + " before_uptodate=%d before_valid=%d" + " after_uptodate=%d after_valid=%d\n", (uintmax_t)stats.cache_nr, (uintmax_t)stats.raw_clean, (uintmax_t)stats.raw_modified, (uintmax_t)stats.sensitive, (uintmax_t)stats.structural, (uintmax_t)stats.unstable, @@ -84,7 +126,8 @@ int cmd__semantic_verify(int argc, const char **argv) (uintmax_t)stats.bytes_hashed, (uintmax_t)stats.stat_updates_nr, semantic_verify_root_is_stable(proof), - !stats.namespace_unstable); + !stats.namespace_unstable, applied, + before_uptodate, before_valid, after_uptodate, after_valid); semantic_verify_proof_clear(proof); return !!ret; } diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh index f391b86f5005aa..828a1aaa9595be 100755 --- a/t/t7531-semantic-verify.sh +++ b/t/t7531-semantic-verify.sh @@ -54,41 +54,60 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ touch -r classify/mtime-reference classify/modified && rm classify/deleted classify/mtime-reference && - verify_repo classify --show-results >actual && + verify_repo classify --show-results --apply >actual && test_grep "^.gitattributes raw-clean persist=1" actual && test_grep "^raw raw-clean persist=1" actual && test_grep "^a/b/nested raw-clean persist=1" actual && test_grep "^converted sensitive" actual && test_grep "^modified raw-modified" actual && test_grep "^deleted raw-modified" actual && - test_grep "entries=6 clean=3 modified=2 sensitive=1" actual + test_grep "entries=6 clean=3 modified=2 sensitive=1" actual && + test_grep "applied=3 .* after_uptodate=3" actual ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN,HARDLINKS \ - 'clean hardlinks are not persistable' ' + 'clean hardlinks require the ordinary tail' ' test_create_repo hardlink && test_write_lines content >hardlink/tracked && git -C hardlink add tracked && git -C hardlink commit -m base && ln hardlink/tracked hardlink/alias && - verify_repo hardlink --show-results >actual && + verify_repo hardlink --show-results --apply >actual && test_grep "^tracked raw-clean persist=0" actual && - test_grep "hardlinks=1" actual + test_grep "hardlinks=1" actual && + test_grep "applied=1 .* after_uptodate=0 after_valid=0" actual ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'classifies structural index state' ' + 'structural index state rejects the whole proof' ' test_create_repo structural && - test_write_lines tracked >structural/tracked && - git -C structural add tracked && + test_write_lines tracked >structural/a-tracked && + git -C structural add a-tracked && git -C structural commit -m base && - test_write_lines intent >structural/intent && - git -C structural add -N intent && + test_write_lines intent >structural/z-intent && + git -C structural add -N z-intent && - verify_repo structural --show-results >actual && - test_grep "^intent structural" actual && - test_grep "structural=1" actual + verify_repo structural --show-results --apply >actual && + test_grep "^a-tracked raw-clean persist=1" actual && + test_grep "^z-intent structural" actual && + test_grep "applied=-1 .* after_uptodate=0" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'replaced index entries reject the whole proof' ' + test_create_repo replaced-entry && + test_write_lines tracked >replaced-entry/a-tracked && + test_write_lines replaced >replaced-entry/z-replaced && + git -C replaced-entry add . && + git -C replaced-entry commit -m base && + + verify_repo replaced-entry --show-results --apply \ + --replace-after-prepare=z-replaced >actual && + test_grep "^a-tracked raw-clean persist=1" actual && + test_grep "^z-replaced raw-clean persist=1" actual && + test_grep "applied=-1 before_uptodate=0 before_valid=0 " actual && + test_grep "after_uptodate=0 after_valid=0" actual ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ From 00ee690b1b4a721e44d46e52a471d473a7512438 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:24:52 -0700 Subject: [PATCH 011/403] status: verify semantic proof ranges in parallel Serial proof preparation hashes every eligible indexed file on one worker. Independent index ranges can instead use separate pinned directory state, conversion checks, buffers, and result counters without allowing workers to mutate shared cache entries. Partition the index into at most 32 bounded ranges after preparing conversion state on the calling thread. Start one worker per range, join started workers, and merge their stat updates and counters in index order. Without thread support, use one worker; if thread creation fails, finish unstarted ranges synchronously. Extend test-tool semantic-verify with an explicit thread count. Add a regression that compares the complete results for one and four workers over nested directories with distinct attribute files. Each worker requires its own 256 KiB hash buffer and attribute check. The regression establishes deterministic classifications, not a timed speedup, and no production status command enables this verifier. Signed-off-by: Taylor Blau --- semantic-verify-internal.h | 5 ++ semantic-verify-worker.c | 13 ++++- semantic-verify.c | 100 ++++++++++++++++++++++++++++---- semantic-verify.h | 10 ++++ t/helper/test-semantic-verify.c | 19 +++++- t/t7531-semantic-verify.sh | 47 +++++++++++++++ 6 files changed, 180 insertions(+), 14 deletions(-) diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index f090787dd1d079..70f253ba2885ed 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -3,6 +3,7 @@ #include "hash.h" #include "statinfo.h" +#include "thread-utils.h" #ifdef __linux__ #include @@ -100,6 +101,8 @@ struct semantic_verify_entry_identity { }; struct semantic_verify_worker { + pthread_t pthread; + unsigned int started; struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; @@ -119,6 +122,7 @@ struct semantic_verify_worker { size_t hardlinks; size_t active_filters; unsigned int namespace_unstable; + unsigned int validate_filter_scope; }; void semantic_verify_worker_run(struct semantic_verify_worker *worker); @@ -142,6 +146,7 @@ struct semantic_verify_proof { size_t hardlinks; size_t active_filters; unsigned int namespace_unstable; + unsigned int filter_scope_checked; }; #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index 47e46e8e19b0ee..b0f00099577b0d 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -64,19 +64,30 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) struct cache_entry *ce = worker->istate->cache[i]; struct semantic_verify_result *result = &worker->results[i]; struct semantic_verify_file_result file; + int active_filter; - if (!semantic_verify_classify_entry(worker->istate, ce, check, 0, + if (!semantic_verify_classify_entry(worker->istate, ce, check, + worker->validate_filter_scope, &file)) { result->kind = file.kind; + if (file.active_filter) { + result->flags |= SEMANTIC_VERIFY_ACTIVE_FILTER; + worker->active_filters++; + } count_result(worker, result->kind); continue; } + active_filter = file.active_filter; semantic_verify_file(worker->root, path, ce, i, worker->istate->repo, buffer, &file); result->kind = file.kind; result->error = file.error > UINT16_MAX ? EIO : file.error; + if (active_filter) { + result->flags |= SEMANTIC_VERIFY_ACTIVE_FILTER; + worker->active_filters++; + } worker->bytes_hashed += file.bytes_hashed; if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) { if (file.persistable) diff --git a/semantic-verify.c b/semantic-verify.c index e932addc6c1b54..52582793760ce2 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -13,6 +13,37 @@ #define SEMANTIC_VERIFY_ENTRY_FLAGS \ (CE_VALID | CE_STAGEMASK | CE_INTENT_TO_ADD | CE_SKIP_WORKTREE | \ CE_UPTODATE | CE_FSMONITOR_VALID | CE_CONTENT_CHECK_REQUIRED) +#define SEMANTIC_VERIFY_MAX_THREADS 32 + +static void *run_worker(void *data) +{ + semantic_verify_worker_run(data); + return NULL; +} + +static unsigned int select_thread_count( + size_t cache_nr, + const struct semantic_verify_options *options) +{ + unsigned int nr; + + if (!HAVE_THREADS) + return 1; + if (options && options->nr_threads) { + nr = options->nr_threads; + } else { + unsigned int cpus = online_cpus(); + + nr = cpus > SEMANTIC_VERIFY_MAX_THREADS / 2 ? + SEMANTIC_VERIFY_MAX_THREADS : cpus * 2; + } + if (nr > SEMANTIC_VERIFY_MAX_THREADS) + nr = SEMANTIC_VERIFY_MAX_THREADS; + if (nr > cache_nr && cache_nr) + nr = cache_nr; + return nr; +} + static void combine_worker(struct semantic_verify_proof *proof, struct semantic_verify_worker *worker) { @@ -36,24 +67,30 @@ static void combine_worker(struct semantic_verify_proof *proof, proof->unstable += worker->unstable; proof->errors += worker->errors; proof->hardlinks += worker->hardlinks; + proof->active_filters += worker->active_filters; proof->namespace_unstable |= worker->namespace_unstable; free(worker->updates); } int semantic_verify_prepare(struct index_state *istate, + const struct semantic_verify_options *options, struct semantic_verify_proof **proof_out) { struct semantic_verify_proof *proof; - struct semantic_verify_worker worker = { 0 }; + struct semantic_verify_worker *workers; + unsigned int nr_threads; + size_t updates_nr = 0; + int create_threads = 1; if (!istate || !proof_out) BUG("semantic_verify_prepare requires an index and output"); if (sizeof(struct semantic_verify_result) != 8) BUG("semantic verify result unexpectedly grew to %"PRIuMAX" bytes", (uintmax_t)sizeof(struct semantic_verify_result)); - CALLOC_ARRAY(proof, 1); proof->istate = istate; + proof->filter_scope_checked = options && + options->validate_filter_scope; proof->cache_nr = istate->cache_nr; CALLOC_ARRAY(proof->results, proof->cache_nr); CALLOC_ARRAY(proof->entry_identities, proof->cache_nr); @@ -94,18 +131,55 @@ int semantic_verify_prepare(struct index_state *istate, /* Initialize conversion config and default attribute state serially. */ convert_attrs_prepare(istate); + nr_threads = select_thread_count(proof->cache_nr, options); + CALLOC_ARRAY(workers, nr_threads); trace2_region_enter("semantic_verify", "prepare", istate->repo); - trace2_data_intmax("semantic_verify", istate->repo, "threads", 1); + trace2_data_intmax("semantic_verify", istate->repo, + "threads", nr_threads); trace2_data_intmax("semantic_verify", istate->repo, "result-bytes", sizeof(struct semantic_verify_result)); - worker.istate = istate; - worker.root = proof->root; - worker.results = proof->results; - worker.end = proof->cache_nr; - semantic_verify_worker_run(&worker); - ALLOC_ARRAY(proof->stat_updates, worker.updates_nr); - combine_worker(proof, &worker); + for (unsigned int i = 0; i < nr_threads; i++) { + struct semantic_verify_worker *worker = &workers[i]; + int err; + + worker->istate = istate; + worker->root = proof->root; + worker->results = proof->results; + worker->start = st_mult(proof->cache_nr, i) / nr_threads; + worker->end = st_mult(proof->cache_nr, i + 1) / nr_threads; + worker->validate_filter_scope = proof->filter_scope_checked; + if (nr_threads == 1 || !create_threads) { + semantic_verify_worker_run(worker); + continue; + } + err = pthread_create(&worker->pthread, NULL, run_worker, worker); + if (!err) { + worker->started = 1; + continue; + } + create_threads = 0; + trace2_data_intmax("semantic_verify", istate->repo, + "thread-failure", err); + semantic_verify_worker_run(worker); + } + for (unsigned int i = 0; i < nr_threads; i++) { + int err; + + if (!workers[i].started) + continue; + err = pthread_join(workers[i].pthread, NULL); + if (err) + die("could not join semantic verifier thread: %s", + strerror(err)); + } + + for (unsigned int i = 0; i < nr_threads; i++) + updates_nr += workers[i].updates_nr; + ALLOC_ARRAY(proof->stat_updates, updates_nr); + for (unsigned int i = 0; i < nr_threads; i++) + combine_worker(proof, &workers[i]); + free(workers); trace2_data_intmax("semantic_verify", istate->repo, "raw-clean", proof->raw_clean); @@ -121,6 +195,10 @@ int semantic_verify_prepare(struct index_state *istate, "errors", proof->errors); trace2_data_intmax("semantic_verify", istate->repo, "bytes-hashed", proof->bytes_hashed); + trace2_data_intmax("semantic_verify", istate->repo, + "active-filters", proof->active_filters); + trace2_data_intmax("semantic_verify", istate->repo, + "filter-scope-checked", proof->filter_scope_checked); trace2_region_leave("semantic_verify", "prepare", istate->repo); return 0; } @@ -146,7 +224,9 @@ void semantic_verify_get_stats(const struct semantic_verify_proof *proof, stats->unstable = proof->unstable; stats->errors = proof->errors; stats->hardlinks = proof->hardlinks; + stats->active_filters = proof->active_filters; stats->namespace_unstable = proof->namespace_unstable; + stats->filter_scope_checked = proof->filter_scope_checked; } const struct semantic_verify_result *semantic_verify_result_at( diff --git a/semantic-verify.h b/semantic-verify.h index dc2f02fffdacf6..87692a3e88a424 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -4,6 +4,13 @@ struct index_state; struct semantic_verify_proof; +struct semantic_verify_options { + unsigned int nr_threads; + unsigned int validate_filter_scope : 1; +}; + +#define SEMANTIC_VERIFY_OPTIONS_INIT { 0 } + enum semantic_verify_kind { SEMANTIC_VERIFY_UNCHECKED = 0, SEMANTIC_VERIFY_SKIPPED, @@ -42,11 +49,14 @@ struct semantic_verify_stats { size_t unstable; size_t errors; size_t hardlinks; + size_t active_filters; unsigned int namespace_unstable; + unsigned int filter_scope_checked; }; /* Build a proof candidate without changing the index. */ int semantic_verify_prepare(struct index_state *istate, + const struct semantic_verify_options *options, struct semantic_verify_proof **proof_out); int semantic_verify_apply_after_closure( struct index_state *istate, diff --git a/t/helper/test-semantic-verify.c b/t/helper/test-semantic-verify.c index b0048dea791490..a868eb80fc4be2 100644 --- a/t/helper/test-semantic-verify.c +++ b/t/helper/test-semantic-verify.c @@ -33,10 +33,13 @@ static const char *kind_name(enum semantic_verify_kind kind) int cmd__semantic_verify(int argc, const char **argv) { + struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; struct semantic_verify_proof *proof = NULL; struct semantic_verify_stats stats; + int thread_count = 0; int show_results = 0; int apply = 0; + int validate_filter_scope = 0; int applied = -2; int before_uptodate = 0, after_uptodate = 0; int before_valid = 0, after_valid = 0; @@ -47,9 +50,13 @@ int cmd__semantic_verify(int argc, const char **argv) NULL }; struct option opts[] = { + OPT_INTEGER(0, "threads", &thread_count, + "number of verifier threads"), OPT_BOOL(0, "show-results", &show_results, "show one result per cache entry"), OPT_BOOL(0, "apply", &apply, "apply the completed proof"), + OPT_BOOL(0, "validate-filter-scope", &validate_filter_scope, + "classify filter use for every index entry"), OPT_STRING(0, "replace-after-prepare", &replace_after_prepare, "path", "replace an entry after preparing the proof"), OPT_END() @@ -58,6 +65,10 @@ int cmd__semantic_verify(int argc, const char **argv) argc = parse_options(argc, argv, NULL, opts, usage, 0); if (argc) usage_with_options(usage, opts); + if (thread_count < 0) + die("negative semantic verifier thread count"); + options.nr_threads = thread_count; + options.validate_filter_scope = validate_filter_scope; setup_git_directory(the_repository); repo_config(the_repository, git_default_config, NULL); @@ -65,7 +76,7 @@ int cmd__semantic_verify(int argc, const char **argv) the_repository->settings.command_requires_full_index = 0; if (repo_read_index(the_repository) < 0) die("unable to read index"); - ret = semantic_verify_prepare(the_repository->index, &proof); + ret = semantic_verify_prepare(the_repository->index, &options, &proof); semantic_verify_get_stats(proof, &stats); if (replace_after_prepare) { struct index_state *istate = the_repository->index; @@ -118,7 +129,8 @@ int cmd__semantic_verify(int argc, const char **argv) " bytes=%"PRIuMAX" stat_updates=%"PRIuMAX " root_stable=%d namespace_stable=%d applied=%d" " before_uptodate=%d before_valid=%d" - " after_uptodate=%d after_valid=%d\n", + " after_uptodate=%d after_valid=%d" + " active_filters=%"PRIuMAX" filter_scope_checked=%d\n", (uintmax_t)stats.cache_nr, (uintmax_t)stats.raw_clean, (uintmax_t)stats.raw_modified, (uintmax_t)stats.sensitive, (uintmax_t)stats.structural, (uintmax_t)stats.unstable, @@ -127,7 +139,8 @@ int cmd__semantic_verify(int argc, const char **argv) (uintmax_t)stats.stat_updates_nr, semantic_verify_root_is_stable(proof), !stats.namespace_unstable, applied, - before_uptodate, before_valid, after_uptodate, after_valid); + before_uptodate, before_valid, after_uptodate, after_valid, + (uintmax_t)stats.active_filters, stats.filter_scope_checked); semantic_verify_proof_clear(proof); return !!ret; } diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh index 828a1aaa9595be..a6e7edab9db034 100755 --- a/t/t7531-semantic-verify.sh +++ b/t/t7531-semantic-verify.sh @@ -110,6 +110,26 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "after_uptodate=0 after_valid=0" actual ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN,PTHREADS \ + 'parallel verification preserves index-order results' ' + test_create_repo parallel && + i=0 && + while test $i -lt 16 + do + mkdir "parallel/d$i" && + printf "*.txt -text attr_%s=value\n" "$i" \ + >"parallel/d$i/.gitattributes" && + printf "content %s\n" "$i" >"parallel/d$i/file.txt" && + i=$((i + 1)) || return 1 + done && + git -C parallel add . && + git -C parallel commit -m base && + + verify_repo parallel --threads=1 --show-results >expect && + verify_repo parallel --threads=4 --show-results >actual && + test_cmp expect actual +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'raw hashing uses the repository object format' ' git init --object-format=sha256 sha256 && @@ -120,4 +140,31 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "^tracked raw-clean persist=1" actual ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'filter scope includes early semantic exits' ' + test_create_repo filter-scope && + printf "unmatched filter=demo\n" >filter-scope/.gitattributes && + test_write_lines content >filter-scope/ordinary && + test_write_lines content >filter-scope/assumed && + git -C filter-scope add . && + git -C filter-scope commit -m base && + git -C filter-scope config filter.demo.clean cat && + git -C filter-scope update-index --assume-unchanged assumed && + verify_repo filter-scope --threads=4 --validate-filter-scope \ + --apply >actual.unused && + test_grep "applied=2 .*active_filters=0 " actual.unused && + test_grep "filter_scope_checked=1" actual.unused && + + test_write_lines "ordinary filter=demo" "assumed filter=demo" \ + >filter-scope/.gitattributes && + git -C filter-scope add .gitattributes && + git -C filter-scope commit -m attributes && + + verify_repo filter-scope --threads=4 --validate-filter-scope \ + --show-results --apply >actual && + test_grep "^assumed skipped" actual && + test_grep "applied=-1 .*active_filters=2 " actual && + test_grep "filter_scope_checked=1" actual +' + test_done From fb00c9c56d9ab6a985717bd1910c0febb7b7fb14 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:29:12 -0700 Subject: [PATCH 012/403] status: fingerprint configuration for semantic clean proofs A clean result cannot be reused after configuration changes that alter status or the conversion of tracked worktree bytes. Treating every configuration-origin change as a conversion change would also discard semantic proofs whose effective conversion rules remain identical. Add independently length-framed full and semantic configuration digests using a caller-selected Git object hash algorithm. Bind each full entry to its key, optional value, scope, origin type, and source file. Include effective line-ending and round-trip encoding settings, plus clean, process, and required filters, in the narrower semantic stream. Mark configured clean-side filters unsafe for direct raw verification. Register the configuration implementation and its unit suite in both Make and Meson. The tests distinguish ordinary status changes from conversion changes, confirm that an origin-only change affects only the full digest, and distinguish clean filters from smudge-only rules. This patch exposes and tests digest construction. Neither semantic proof preparation nor proof application consumes these digests, and no production status or index-read path uses them at this boundary. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-config.c | 94 ++++++++++++++++++++++ clean-status-config.h | 26 ++++++ hash-framing.h | 30 +++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-config.c | 113 +++++++++++++++++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 clean-status-config.c create mode 100644 clean-status-config.h create mode 100644 hash-framing.h create mode 100644 t/unit-tests/u-clean-status-config.c diff --git a/Makefile b/Makefile index 0c8545ab270abe..a4720dc87aaaea 100644 --- a/Makefile +++ b/Makefile @@ -1123,6 +1123,7 @@ LIB_OBJS += cbtree.o LIB_OBJS += chdir-notify.o LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o +LIB_OBJS += clean-status-config.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o @@ -1542,6 +1543,7 @@ THIRD_PARTY_SOURCES += sha1dc/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% +CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-config.c b/clean-status-config.c new file mode 100644 index 00000000000000..951893ac833117 --- /dev/null +++ b/clean-status-config.c @@ -0,0 +1,94 @@ +#include "git-compat-util.h" +#include "clean-status-config.h" +#include "config.h" +#include "hash-framing.h" +#include "strbuf.h" + +#define CLEAN_STATUS_FILTER_PROOF_DOMAIN \ + "clean-status-configured-filter-scope-v1" + +void clean_status_config_init(struct clean_status_config_digest *digest, + const struct git_hash_algo *algo) +{ + if (!algo) + BUG("clean-status config digest requires a hash algorithm"); + memset(digest, 0, sizeof(*digest)); + git_hash_init(&digest->ctx, algo); + git_hash_init(&digest->semantic_ctx, algo); + /* Invalidate proofs written before multiply-linked files stayed dirty. */ + hash_optional_cstring(&digest->ctx, + "clean-status-config-hardlink-v1"); + digest->initialized = 1; +} + +static void hash_config_entry(struct git_hash_ctx *ctx, + const char *key, const char *value, + const struct config_context *config_ctx) +{ + uint32_t metadata[2] = { 0 }; + + hash_optional_cstring(ctx, key); + hash_optional_cstring(ctx, value); + if (config_ctx && config_ctx->kvi) { + put_be32(&metadata[0], config_ctx->kvi->scope); + put_be32(&metadata[1], config_ctx->kvi->origin_type); + hash_length_delimited(ctx, metadata, sizeof(metadata)); + hash_optional_cstring(ctx, config_ctx->kvi->filename); + } else { + hash_length_delimited(ctx, metadata, sizeof(metadata)); + hash_optional_cstring(ctx, NULL); + } +} + +static void hash_effective_config_entry(struct git_hash_ctx *ctx, + const char *key, + const char *value) +{ + hash_optional_cstring(ctx, key); + hash_optional_cstring(ctx, value); +} + +void clean_status_config_add(struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx) +{ + const char *suffix; + int semantic; + + if (!digest->initialized || digest->finalized) + BUG("invalid clean-status config digest state"); + hash_config_entry(&digest->ctx, key, value, ctx); + semantic = !strcmp(key, "core.autocrlf") || + !strcmp(key, "core.eol") || + !strcmp(key, "core.checkroundtripencoding"); + if (skip_prefix(key, "filter.", &suffix) && + (ends_with(suffix, ".clean") || ends_with(suffix, ".process") || + ends_with(suffix, ".required"))) { + digest->filter_configured = 1; + semantic = 1; + } + if (semantic) { + hash_effective_config_entry(&digest->semantic_ctx, key, value); + digest->semantic_config_explicit = 1; + } +} + +void clean_status_config_final(struct clean_status_config_digest *digest) +{ + if (!digest->initialized || digest->finalized) + BUG("invalid clean-status config digest state"); + if (digest->filter_configured) { + /* + * Leave repositories without configured clean filters in their + * existing proof domain. Configured filters require a proof which + * has classified every tracked path before it may be reused. + */ + hash_optional_cstring(&digest->ctx, + CLEAN_STATUS_FILTER_PROOF_DOMAIN); + hash_optional_cstring(&digest->semantic_ctx, + CLEAN_STATUS_FILTER_PROOF_DOMAIN); + } + git_hash_final(digest->hash, &digest->ctx); + git_hash_final(digest->semantic_hash, &digest->semantic_ctx); + digest->finalized = 1; +} diff --git a/clean-status-config.h b/clean-status-config.h new file mode 100644 index 00000000000000..47420ed282d4d9 --- /dev/null +++ b/clean-status-config.h @@ -0,0 +1,26 @@ +#ifndef CLEAN_STATUS_CONFIG_H +#define CLEAN_STATUS_CONFIG_H + +#include "hash.h" + +struct config_context; + +struct clean_status_config_digest { + struct git_hash_ctx ctx; + struct git_hash_ctx semantic_ctx; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned initialized : 1; + unsigned finalized : 1; + unsigned filter_configured : 1; + unsigned semantic_config_explicit : 1; +}; + +void clean_status_config_init(struct clean_status_config_digest *digest, + const struct git_hash_algo *algo); +void clean_status_config_add(struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx); +void clean_status_config_final(struct clean_status_config_digest *digest); + +#endif /* CLEAN_STATUS_CONFIG_H */ diff --git a/hash-framing.h b/hash-framing.h new file mode 100644 index 00000000000000..b15294b684a90d --- /dev/null +++ b/hash-framing.h @@ -0,0 +1,30 @@ +#ifndef HASH_FRAMING_H +#define HASH_FRAMING_H + +#include "hash.h" + +static inline void hash_length_delimited(struct git_hash_ctx *ctx, + const void *data, size_t len) +{ + uint32_t size; + + if (len > UINT32_MAX) + BUG("length-delimited hash input too long"); + put_be32(&size, len); + git_hash_update(ctx, &size, sizeof(size)); + if (len) + git_hash_update(ctx, data, len); +} + +static inline void hash_optional_cstring(struct git_hash_ctx *ctx, + const char *value) +{ + static const unsigned char missing = 0; + + if (value) + hash_length_delimited(ctx, value, strlen(value)); + else + hash_length_delimited(ctx, &missing, sizeof(missing)); +} + +#endif /* HASH_FRAMING_H */ diff --git a/meson.build b/meson.build index ff471a3a092f5a..962f4e1dd1283c 100644 --- a/meson.build +++ b/meson.build @@ -331,6 +331,7 @@ libgit_sources = [ 'chdir-notify.c', 'checkout.c', 'chunk-format.c', + 'clean-status-config.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/t/meson.build b/t/meson.build index 4ecf8343a2f935..639db8354a46cb 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,4 +1,5 @@ clar_test_suites = [ + 'unit-tests/u-clean-status-config.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c new file mode 100644 index 00000000000000..cc88bb0680518c --- /dev/null +++ b/t/unit-tests/u-clean-status-config.c @@ -0,0 +1,113 @@ +#include "unit-test.h" +#include "clean-status-config.h" +#include "config.h" + +static void digest_one(struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx) +{ + clean_status_config_init(digest, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_add(digest, key, value, ctx); + clean_status_config_final(digest); +} + +static int hashes_equal(const unsigned char *a, const unsigned char *b) +{ + return hasheq(a, b, &hash_algos[GIT_HASH_SHA1]); +} + +void test_clean_status_config__non_semantic_values_only_change_full_hash(void) +{ + struct clean_status_config_digest a, b; + + digest_one(&a, "status.showuntrackedfiles", "normal", NULL); + digest_one(&b, "status.showuntrackedfiles", "all", NULL); + cl_assert(!hashes_equal(a.hash, b.hash)); + cl_assert(hashes_equal(a.semantic_hash, b.semantic_hash)); + cl_assert(!a.semantic_config_explicit); + cl_assert(!b.semantic_config_explicit); +} + +void test_clean_status_config__semantic_values_change_semantic_hash(void) +{ + struct clean_status_config_digest a, b; + + digest_one(&a, "core.autocrlf", "true", NULL); + digest_one(&b, "core.autocrlf", "false", NULL); + cl_assert(!hashes_equal(a.semantic_hash, b.semantic_hash)); + cl_assert(a.semantic_config_explicit); + cl_assert(b.semantic_config_explicit); +} + +void test_clean_status_config__origin_only_affects_full_hash(void) +{ + struct key_value_info global_kvi = KVI_INIT; + struct key_value_info local_kvi = KVI_INIT; + struct config_context global_ctx = { .kvi = &global_kvi }; + struct config_context local_ctx = { .kvi = &local_kvi }; + struct clean_status_config_digest global, local; + + global_kvi.scope = CONFIG_SCOPE_GLOBAL; + global_kvi.origin_type = CONFIG_ORIGIN_FILE; + global_kvi.filename = "/global"; + local_kvi.scope = CONFIG_SCOPE_LOCAL; + local_kvi.origin_type = CONFIG_ORIGIN_FILE; + local_kvi.filename = "/local"; + digest_one(&global, "core.eol", "lf", &global_ctx); + digest_one(&local, "core.eol", "lf", &local_ctx); + cl_assert(!hashes_equal(global.hash, local.hash)); + cl_assert(hashes_equal(global.semantic_hash, local.semantic_hash)); +} + +static void digest_without_final_domain( + const struct clean_status_config_digest *digest, + unsigned char *full_hash, unsigned char *semantic_hash) +{ + struct git_hash_ctx full, semantic; + + git_hash_init(&full, &hash_algos[GIT_HASH_SHA1]); + git_hash_init(&semantic, &hash_algos[GIT_HASH_SHA1]); + git_hash_clone(&full, &digest->ctx); + git_hash_clone(&semantic, &digest->semantic_ctx); + git_hash_final(full_hash, &full); + git_hash_final(semantic_hash, &semantic); +} + +void test_clean_status_config__configured_filters_bump_proof_domains(void) +{ + static const char *const configured_suffixes[] = { + "clean", "process", "required", + }; + struct clean_status_config_digest smudge; + unsigned char smudge_full[GIT_MAX_RAWSZ]; + unsigned char smudge_semantic[GIT_MAX_RAWSZ]; + + for (size_t i = 0; i < ARRAY_SIZE(configured_suffixes); i++) { + struct clean_status_config_digest configured; + unsigned char full[GIT_MAX_RAWSZ]; + unsigned char semantic[GIT_MAX_RAWSZ]; + char *key = xstrfmt("filter.demo.%s", configured_suffixes[i]); + + clean_status_config_init(&configured, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_add(&configured, key, "command", NULL); + digest_without_final_domain(&configured, full, semantic); + clean_status_config_final(&configured); + cl_assert(configured.filter_configured); + cl_assert(configured.semantic_config_explicit); + cl_assert(!hashes_equal(configured.hash, full)); + cl_assert(!hashes_equal(configured.semantic_hash, semantic)); + free(key); + } + + clean_status_config_init(&smudge, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_add( + &smudge, "filter.demo.smudge", "command", NULL); + digest_without_final_domain( + &smudge, smudge_full, smudge_semantic); + clean_status_config_final(&smudge); + + cl_assert(!smudge.filter_configured); + cl_assert(!smudge.semantic_config_explicit); + cl_assert(hashes_equal(smudge.hash, smudge_full)); + cl_assert(hashes_equal(smudge.semantic_hash, smudge_semantic)); +} From 3e2273a2e459a50213de89b3cb255e2037ae18d7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:30:22 -0700 Subject: [PATCH 013/403] status: write bounded attribute manifests A reusable clean-status proof must identify the .gitattributes source that governs conversion in every tracked directory. An unordered list of paths and hashes cannot distinguish duplicate records, ambiguous paths, or a worktree source from its indexed counterpart. Add a length-delimited manifest writer with an entry count, repository-relative .gitattributes paths, explicit source kinds, and object-format-sized hashes. Reject invalid paths, unknown source kinds, overflows, duplicate records, and nonincreasing path order before appending an entry. Register the new library and Clar suite in both Make and Meson. Focused tests cover ordered records and reject malformed, duplicate, and out-of-order paths. This defines a tested encoding; it does not enable a status fast path. Signed-off-by: Taylor Blau --- Makefile | 2 + attr-manifest.c | 94 ++++++++++++++++++++++++++++++++++ attr-manifest.h | 35 +++++++++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-attr-manifest.c | 53 +++++++++++++++++++ 6 files changed, 186 insertions(+) create mode 100644 attr-manifest.c create mode 100644 attr-manifest.h create mode 100644 t/unit-tests/u-attr-manifest.c diff --git a/Makefile b/Makefile index a4720dc87aaaea..67916eeab63f72 100644 --- a/Makefile +++ b/Makefile @@ -1110,6 +1110,7 @@ LIB_OBJS += archive-tar.o LIB_OBJS += archive-zip.o LIB_OBJS += archive.o LIB_OBJS += attr.o +LIB_OBJS += attr-manifest.o LIB_OBJS += base85.o LIB_OBJS += bisect.o LIB_OBJS += blame.o @@ -1543,6 +1544,7 @@ THIRD_PARTY_SOURCES += sha1dc/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% +CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir diff --git a/attr-manifest.c b/attr-manifest.c new file mode 100644 index 00000000000000..41220073ff59c7 --- /dev/null +++ b/attr-manifest.c @@ -0,0 +1,94 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "environment.h" +#include "read-cache-ll.h" +#include "strbuf.h" + +/* + * A manifest begins with a 32-bit entry count. Each entry contains a 32-bit + * path length, four bytes of source metadata, an object-format hash, and the + * unterminated path. Paths are strictly increasing. + */ +static int attr_manifest_path_valid(const unsigned char *path, size_t len) +{ + const char *base; + char *copy; + int valid; + + if (!len || path[0] == '/' || memchr(path, '\0', len)) + return 0; + copy = xmemdupz(path, len); + base = strrchr(copy, '/'); + base = base ? base + 1 : copy; + valid = !strcmp(base, GITATTRIBUTES_FILE) && + verify_path(copy, S_IFREG | 0644); + free(copy); + return valid; +} + +static int attr_manifest_entry_cmp(const struct attr_manifest_entry *a, + const struct attr_manifest_entry *b) +{ + size_t common = a->path_len < b->path_len ? a->path_len : b->path_len; + int cmp = memcmp(a->path, b->path, common); + + if (cmp) + return cmp; + return a->path_len < b->path_len ? -1 : a->path_len > b->path_len; +} + +void attr_manifest_writer_init(struct attr_manifest_writer *writer, + struct strbuf *buf, + const struct git_hash_algo *algo) +{ + uint32_t count; + + if (!algo) + BUG("attribute manifest requires a hash algorithm"); + memset(writer, 0, sizeof(*writer)); + writer->buf = buf; + writer->algo = algo; + strbuf_reset(buf); + put_be32(&count, 0); + strbuf_add(buf, &count, sizeof(count)); +} + +int attr_manifest_writer_add(struct attr_manifest_writer *writer, + const char *path, + enum attr_manifest_source source, + const unsigned char *hash) +{ + struct attr_manifest_entry previous, current; + unsigned char metadata[4] = { source, 0, 0, 0 }; + uint32_t path_len_be; + size_t entry_offset, path_len = strlen(path); + + if (!writer->buf || !writer->algo || !hash || !path_len || + path_len > UINT32_MAX || writer->nr == UINT32_MAX || + (source != ATTR_MANIFEST_WORKTREE && + source != ATTR_MANIFEST_INDEX) || + !attr_manifest_path_valid((const unsigned char *)path, path_len)) + return -1; + + current.path = (const unsigned char *)path; + current.path_len = path_len; + if (writer->nr) { + previous.path = (const unsigned char *)writer->buf->buf + + writer->last_path_offset; + previous.path_len = writer->last_path_len; + if (attr_manifest_entry_cmp(&previous, ¤t) >= 0) + return -1; + } + + entry_offset = writer->buf->len; + put_be32(&path_len_be, path_len); + strbuf_add(writer->buf, &path_len_be, sizeof(path_len_be)); + strbuf_add(writer->buf, metadata, sizeof(metadata)); + strbuf_add(writer->buf, hash, writer->algo->rawsz); + strbuf_add(writer->buf, path, path_len); + writer->last_path_offset = entry_offset + sizeof(path_len_be) + + sizeof(metadata) + writer->algo->rawsz; + writer->last_path_len = path_len; + put_be32(writer->buf->buf, ++writer->nr); + return 0; +} diff --git a/attr-manifest.h b/attr-manifest.h new file mode 100644 index 00000000000000..75296bae8f1f0e --- /dev/null +++ b/attr-manifest.h @@ -0,0 +1,35 @@ +#ifndef ATTR_MANIFEST_H +#define ATTR_MANIFEST_H + +#include "hash.h" + +struct strbuf; + +enum attr_manifest_source { + ATTR_MANIFEST_WORKTREE = 1, + ATTR_MANIFEST_INDEX = 2, +}; + +struct attr_manifest_entry { + const unsigned char *path; + uint32_t path_len; + enum attr_manifest_source source; + const unsigned char *hash; +}; + +struct attr_manifest_writer { + struct strbuf *buf; + const struct git_hash_algo *algo; + size_t last_path_offset; + uint32_t last_path_len; + uint32_t nr; +}; + +void attr_manifest_writer_init(struct attr_manifest_writer *writer, + struct strbuf *buf, + const struct git_hash_algo *algo); +int attr_manifest_writer_add(struct attr_manifest_writer *writer, + const char *path, + enum attr_manifest_source source, + const unsigned char *hash); +#endif /* ATTR_MANIFEST_H */ diff --git a/meson.build b/meson.build index 962f4e1dd1283c..56b903ab289d75 100644 --- a/meson.build +++ b/meson.build @@ -317,6 +317,7 @@ libgit_sources = [ 'archive-tar.c', 'archive-zip.c', 'archive.c', + 'attr-manifest.c', 'attr.c', 'base85.c', 'bisect.c', diff --git a/t/meson.build b/t/meson.build index 639db8354a46cb..d38d0f5be3cb08 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,4 +1,5 @@ clar_test_suites = [ + 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c new file mode 100644 index 00000000000000..277e3101d042e7 --- /dev/null +++ b/t/unit-tests/u-attr-manifest.c @@ -0,0 +1,53 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "strbuf.h" + +static void fill_hash(unsigned char *hash, unsigned char value, + const struct git_hash_algo *algo) +{ + memset(hash, value, algo->rawsz); +} + +static void add_entry(struct attr_manifest_writer *writer, const char *path, + enum attr_manifest_source source, unsigned char value) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + fill_hash(hash, value, writer->algo); + cl_assert_equal_i(attr_manifest_writer_add(writer, path, source, hash), 0); +} + +void test_attr_manifest__writer_serializes_sorted_entries(void) +{ + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + + attr_manifest_writer_init(&writer, &manifest, &hash_algos[GIT_HASH_SHA256]); + add_entry(&writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&writer, "a/.gitattributes", ATTR_MANIFEST_WORKTREE, 2); + cl_assert_equal_i(get_be32(manifest.buf), 2); + cl_assert_equal_i(writer.nr, 2); + strbuf_release(&manifest); +} + +void test_attr_manifest__writer_rejects_invalid_or_unsorted_paths(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + + fill_hash(hash, 1, algo); + attr_manifest_writer_init(&writer, &manifest, algo); + add_entry(&writer, "b/.gitattributes", ATTR_MANIFEST_INDEX, 1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "a/.gitattributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "b/.gitattributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "/.gitattributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "b/not-attributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(writer.nr, 1); + strbuf_release(&manifest); +} From b908fdb219cb0907b5966e648ebc62323be84022 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:01 -0700 Subject: [PATCH 014/403] status: validate attribute manifest records A persisted attribute manifest is untrusted even when its writer was careful. A truncated record, forged entry count, invalid source, or trailing byte could otherwise make a later reader accept incomplete or ambiguous conversion history. Add a bounded cursor for the format from S07/P01. Check the declared count against the minimum record size, enforce valid paths, known source kinds, zero reserved bytes, object-format-sized hashes, strict path ordering, and exact consumption of the input. Test round trips, an empty manifest, truncation, trailing data, nonzero reserved bytes, and an overstated entry count. Decoding only exposes records; it does not publish or consume status history. Signed-off-by: Taylor Blau --- attr-manifest.c | 79 ++++++++++++++++++++++++++++++++++ attr-manifest.h | 17 ++++++++ t/unit-tests/u-attr-manifest.c | 59 +++++++++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/attr-manifest.c b/attr-manifest.c index 41220073ff59c7..694c787d45fd82 100644 --- a/attr-manifest.c +++ b/attr-manifest.c @@ -92,3 +92,82 @@ int attr_manifest_writer_add(struct attr_manifest_writer *writer, put_be32(writer->buf->buf, ++writer->nr); return 0; } + +int attr_manifest_cursor_init(struct attr_manifest_cursor *cursor, + const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *bytes = data; + size_t minimum_entry_size; + + if (!algo) + BUG("attribute manifest requires a hash algorithm"); + if (len < sizeof(uint32_t)) + return -1; + minimum_entry_size = sizeof(uint32_t) + 4 + algo->rawsz + 1; + cursor->p = bytes + sizeof(uint32_t); + cursor->end = bytes + len; + cursor->last_path = NULL; + cursor->algo = algo; + cursor->last_path_len = 0; + cursor->remaining = get_be32(bytes); + if (cursor->remaining > + (len - sizeof(uint32_t)) / minimum_entry_size) + return -1; + return 0; +} + +int attr_manifest_cursor_next(struct attr_manifest_cursor *cursor, + struct attr_manifest_entry *entry) +{ + struct attr_manifest_entry previous; + uint32_t path_len; + size_t available; + + if (!cursor->remaining) + return cursor->p == cursor->end ? 0 : -1; + available = cursor->end - cursor->p; + if (available < sizeof(uint32_t) + 4 + cursor->algo->rawsz) + return -1; + path_len = get_be32(cursor->p); + cursor->p += sizeof(uint32_t); + entry->source = cursor->p[0]; + if ((entry->source != ATTR_MANIFEST_WORKTREE && + entry->source != ATTR_MANIFEST_INDEX) || + cursor->p[1] || cursor->p[2] || cursor->p[3]) + return -1; + cursor->p += 4; + entry->hash = cursor->p; + cursor->p += cursor->algo->rawsz; + available = cursor->end - cursor->p; + if (!path_len || available < path_len || + !attr_manifest_path_valid(cursor->p, path_len)) + return -1; + entry->path = cursor->p; + entry->path_len = path_len; + if (cursor->last_path) { + previous.path = cursor->last_path; + previous.path_len = cursor->last_path_len; + if (attr_manifest_entry_cmp(&previous, entry) >= 0) + return -1; + } + cursor->last_path = entry->path; + cursor->last_path_len = entry->path_len; + cursor->p += path_len; + cursor->remaining--; + return 1; +} + +int attr_manifest_valid(const void *data, size_t len, + const struct git_hash_algo *algo) +{ + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + int ret; + + if (attr_manifest_cursor_init(&cursor, data, len, algo)) + return 0; + while ((ret = attr_manifest_cursor_next(&cursor, &entry)) > 0) + ; + return !ret; +} diff --git a/attr-manifest.h b/attr-manifest.h index 75296bae8f1f0e..a3e6de4b556c33 100644 --- a/attr-manifest.h +++ b/attr-manifest.h @@ -17,6 +17,15 @@ struct attr_manifest_entry { const unsigned char *hash; }; +struct attr_manifest_cursor { + const unsigned char *p; + const unsigned char *end; + const unsigned char *last_path; + const struct git_hash_algo *algo; + uint32_t last_path_len; + uint32_t remaining; +}; + struct attr_manifest_writer { struct strbuf *buf; const struct git_hash_algo *algo; @@ -32,4 +41,12 @@ int attr_manifest_writer_add(struct attr_manifest_writer *writer, const char *path, enum attr_manifest_source source, const unsigned char *hash); +int attr_manifest_cursor_init(struct attr_manifest_cursor *cursor, + const void *data, size_t len, + const struct git_hash_algo *algo); +int attr_manifest_cursor_next(struct attr_manifest_cursor *cursor, + struct attr_manifest_entry *entry); +int attr_manifest_valid(const void *data, size_t len, + const struct git_hash_algo *algo); + #endif /* ATTR_MANIFEST_H */ diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 277e3101d042e7..d03cc41c273da5 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -51,3 +51,62 @@ void test_attr_manifest__writer_rejects_invalid_or_unsorted_paths(void) cl_assert_equal_i(writer.nr, 1); strbuf_release(&manifest); } + +void test_attr_manifest__reader_round_trips_entries(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + struct attr_manifest_writer writer; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct strbuf manifest = STRBUF_INIT; + + attr_manifest_writer_init(&writer, &manifest, algo); + add_entry(&writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&writer, "a/.gitattributes", ATTR_MANIFEST_WORKTREE, 2); + cl_assert(attr_manifest_valid(manifest.buf, manifest.len, algo)); + cl_assert_equal_i(attr_manifest_cursor_init(&cursor, manifest.buf, + manifest.len, algo), 0); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_INDEX); + cl_assert_equal_i(entry.hash[0], 1); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_WORKTREE); + cl_assert_equal_i(entry.hash[0], 2); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + strbuf_release(&manifest); +} + +void test_attr_manifest__reader_rejects_corrupt_encoding(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + size_t metadata_offset = 2 * sizeof(uint32_t); + unsigned char saved; + + attr_manifest_writer_init(&writer, &manifest, algo); + add_entry(&writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len - 1, algo)); + strbuf_addch(&manifest, 0); + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len, algo)); + strbuf_setlen(&manifest, manifest.len - 1); + + saved = manifest.buf[metadata_offset + 1]; + manifest.buf[metadata_offset + 1] = 1; + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len, algo)); + manifest.buf[metadata_offset + 1] = saved; + put_be32(manifest.buf, 2); + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len, algo)); + strbuf_release(&manifest); +} + +void test_attr_manifest__reader_accepts_empty_manifest(void) +{ + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + + attr_manifest_writer_init(&writer, &manifest, &hash_algos[GIT_HASH_SHA1]); + cl_assert(attr_manifest_valid(manifest.buf, manifest.len, + &hash_algos[GIT_HASH_SHA1])); + strbuf_release(&manifest); +} From 72c623d0c9acfae1f84f874385da89c437f566eb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:54 -0700 Subject: [PATCH 015/403] status: iterate changed attribute manifest entries Refreshing conversion history must invalidate added, removed, and changed .gitattributes sources without reporting unchanged paths. Acting on a partially decoded stream would be worse: a corrupt trailing record could leave some paths invalidated before the failure is known. Validate both complete manifests with S07/P02 before invoking a callback. Merge their ordered cursors without copying entries, report each added or removed path once, and treat a source-kind or hash change as a modification. Do not invoke the callback for records whose path, source, and hash all match. Up-front validation deliberately reads each entire manifest before merging. The additional pass prevents partial callback effects without materializing another collection of paths. Unit tests cover additions, removals, source changes, identical manifests, and a malformed tail that must produce no callbacks. Signed-off-by: Taylor Blau --- attr-manifest.c | 59 ++++++++++++++++++++++++++ attr-manifest.h | 7 ++++ t/unit-tests/u-attr-manifest.c | 77 ++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/attr-manifest.c b/attr-manifest.c index 694c787d45fd82..46aed49a430050 100644 --- a/attr-manifest.c +++ b/attr-manifest.c @@ -37,6 +37,14 @@ static int attr_manifest_entry_cmp(const struct attr_manifest_entry *a, return a->path_len < b->path_len ? -1 : a->path_len > b->path_len; } +static int attr_manifest_entry_equal(const struct attr_manifest_entry *a, + const struct attr_manifest_entry *b, + const struct git_hash_algo *algo) +{ + return !attr_manifest_entry_cmp(a, b) && a->source == b->source && + !memcmp(a->hash, b->hash, algo->rawsz); +} + void attr_manifest_writer_init(struct attr_manifest_writer *writer, struct strbuf *buf, const struct git_hash_algo *algo) @@ -171,3 +179,54 @@ int attr_manifest_valid(const void *data, size_t len, ; return !ret; } + +int attr_manifest_for_each_changed(const void *old_data, size_t old_len, + const void *new_data, size_t new_len, + const struct git_hash_algo *algo, + attr_manifest_change_fn fn, void *data) +{ + struct attr_manifest_cursor old_cursor, new_cursor; + struct attr_manifest_entry old_entry, new_entry; + int old_ret, new_ret; + + /* + * Callers use this as a transactional change set. Validate both + * streams before allowing the callback to observe any entry. + */ + if (!attr_manifest_valid(old_data, old_len, algo) || + !attr_manifest_valid(new_data, new_len, algo)) + return -1; + if (attr_manifest_cursor_init(&old_cursor, old_data, old_len, algo) || + attr_manifest_cursor_init(&new_cursor, new_data, new_len, algo)) + return -1; + old_ret = attr_manifest_cursor_next(&old_cursor, &old_entry); + new_ret = attr_manifest_cursor_next(&new_cursor, &new_entry); + while (old_ret > 0 || new_ret > 0) { + struct attr_manifest_entry changed; + int has_changed = 1; + int cmp; + + if (old_ret <= 0) + cmp = 1; + else if (new_ret <= 0) + cmp = -1; + else + cmp = attr_manifest_entry_cmp(&old_entry, &new_entry); + if (cmp < 0) { + changed = old_entry; + old_ret = attr_manifest_cursor_next(&old_cursor, &old_entry); + } else if (cmp > 0) { + changed = new_entry; + new_ret = attr_manifest_cursor_next(&new_cursor, &new_entry); + } else { + changed = new_entry; + has_changed = !attr_manifest_entry_equal( + &old_entry, &new_entry, algo); + old_ret = attr_manifest_cursor_next(&old_cursor, &old_entry); + new_ret = attr_manifest_cursor_next(&new_cursor, &new_entry); + } + if (has_changed && fn(&changed, data)) + return -1; + } + return old_ret < 0 || new_ret < 0 ? -1 : 0; +} diff --git a/attr-manifest.h b/attr-manifest.h index a3e6de4b556c33..a38acccc224832 100644 --- a/attr-manifest.h +++ b/attr-manifest.h @@ -34,6 +34,9 @@ struct attr_manifest_writer { uint32_t nr; }; +typedef int (*attr_manifest_change_fn)(const struct attr_manifest_entry *entry, + void *data); + void attr_manifest_writer_init(struct attr_manifest_writer *writer, struct strbuf *buf, const struct git_hash_algo *algo); @@ -48,5 +51,9 @@ int attr_manifest_cursor_next(struct attr_manifest_cursor *cursor, struct attr_manifest_entry *entry); int attr_manifest_valid(const void *data, size_t len, const struct git_hash_algo *algo); +int attr_manifest_for_each_changed(const void *old_data, size_t old_len, + const void *new_data, size_t new_len, + const struct git_hash_algo *algo, + attr_manifest_change_fn fn, void *data); #endif /* ATTR_MANIFEST_H */ diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index d03cc41c273da5..41f1d606889606 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -110,3 +110,80 @@ void test_attr_manifest__reader_accepts_empty_manifest(void) &hash_algos[GIT_HASH_SHA1])); strbuf_release(&manifest); } + +static int record_changed_path(const struct attr_manifest_entry *entry, + void *data) +{ + struct strbuf *paths = data; + + if (paths->len) + strbuf_addch(paths, ' '); + strbuf_add(paths, entry->path, entry->path_len); + return 0; +} + +void test_attr_manifest__iterates_added_removed_and_modified_entries(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer old_writer, new_writer; + struct strbuf old = STRBUF_INIT, new = STRBUF_INIT, changed = STRBUF_INIT; + + attr_manifest_writer_init(&old_writer, &old, algo); + add_entry(&old_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&old_writer, "a/.gitattributes", ATTR_MANIFEST_INDEX, 2); + add_entry(&old_writer, "c/.gitattributes", ATTR_MANIFEST_INDEX, 3); + attr_manifest_writer_init(&new_writer, &new, algo); + add_entry(&new_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&new_writer, "a/.gitattributes", ATTR_MANIFEST_WORKTREE, 2); + add_entry(&new_writer, "b/.gitattributes", ATTR_MANIFEST_INDEX, 4); + + cl_assert_equal_i(attr_manifest_for_each_changed( + old.buf, old.len, new.buf, new.len, algo, + record_changed_path, &changed), 0); + cl_assert_equal_s(changed.buf, + "a/.gitattributes b/.gitattributes c/.gitattributes"); + strbuf_release(&changed); + strbuf_release(&new); + strbuf_release(&old); +} + +void test_attr_manifest__does_not_report_identical_entries(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer old_writer, new_writer; + struct strbuf old = STRBUF_INIT, new = STRBUF_INIT, changed = STRBUF_INIT; + + attr_manifest_writer_init(&old_writer, &old, algo); + add_entry(&old_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + attr_manifest_writer_init(&new_writer, &new, algo); + add_entry(&new_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + cl_assert_equal_i(attr_manifest_for_each_changed( + old.buf, old.len, new.buf, new.len, algo, + record_changed_path, &changed), 0); + cl_assert_equal_i(changed.len, 0); + strbuf_release(&changed); + strbuf_release(&new); + strbuf_release(&old); +} + +void test_attr_manifest__rejects_malformed_tail_before_callbacks(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer old_writer, new_writer; + struct strbuf old = STRBUF_INIT, new = STRBUF_INIT, changed = STRBUF_INIT; + + attr_manifest_writer_init(&old_writer, &old, algo); + add_entry(&old_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + attr_manifest_writer_init(&new_writer, &new, algo); + add_entry(&new_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 2); + strbuf_addch(&new, 0); + + cl_assert_equal_i(attr_manifest_for_each_changed( + old.buf, old.len, new.buf, new.len, algo, + record_changed_path, &changed), -1); + cl_assert_equal_i(changed.len, 0); + + strbuf_release(&changed); + strbuf_release(&new); + strbuf_release(&old); +} From bf79489ea518b9f46a986f7f3f0f252c713d41fe Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:33:17 -0700 Subject: [PATCH 016/403] status: read attribute sources through pinned paths Hashing .gitattributes through an absolute pathname does not establish that the file stayed inside the original worktree. Replacing an ancestor can redirect the read, while replacing or changing the source during the read can make a pathname recheck certify different bytes. Resolve each source beneath the root and parent descriptors introduced by S06/P01 and S06/P02. Read a regular, singly linked file in bounded chunks; compare descriptor and pathname identities before and after reading, reject truncation and appended data, and use the reopen check from S06/P03 for the final component. A missing, nonregular, or oversized worktree source remains eligible for indexed fallback. A hard link, unstable parent, read error, or unsupported anchored-open platform instead rejects the observation. Register the source library and Clar suite in both Make and Meson. Tests cover a file larger than one read buffer, missing and nonregular sources, hard links, and a replaced cached parent. The tested reader does not change ordinary status. Signed-off-by: Taylor Blau --- Makefile | 2 + meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-worktree-attr-source.c | 193 ++++++++++++++++++++++++++ worktree-attr-source.c | 93 +++++++++++++ worktree-attr-source.h | 12 ++ 6 files changed, 302 insertions(+) create mode 100644 t/unit-tests/u-worktree-attr-source.c create mode 100644 worktree-attr-source.c create mode 100644 worktree-attr-source.h diff --git a/Makefile b/Makefile index 67916eeab63f72..9faa8a6587171b 100644 --- a/Makefile +++ b/Makefile @@ -1383,6 +1383,7 @@ LIB_OBJS += versioncmp.o LIB_OBJS += walker.o LIB_OBJS += wildmatch.o LIB_OBJS += worktree.o +LIB_OBJS += worktree-attr-source.o LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o @@ -1575,6 +1576,7 @@ CLAR_TEST_SUITES += u-strvec CLAR_TEST_SUITES += u-trailer CLAR_TEST_SUITES += u-urlmatch-normalization CLAR_TEST_SUITES += u-utf8-width +CLAR_TEST_SUITES += u-worktree-attr-source CLAR_TEST_PROG = $(UNIT_TEST_BIN)/unit-tests$(X) CLAR_TEST_OBJS = $(patsubst %,$(UNIT_TEST_DIR)/%.o,$(CLAR_TEST_SUITES)) CLAR_TEST_OBJS += $(UNIT_TEST_DIR)/clar/clar.o diff --git a/meson.build b/meson.build index 56b903ab289d75..912e7522aa1ace 100644 --- a/meson.build +++ b/meson.build @@ -584,6 +584,7 @@ libgit_sources = [ 'walker.c', 'wildmatch.c', 'worktree.c', + 'worktree-attr-source.c', 'wrapper.c', 'write-or-die.c', 'ws.c', diff --git a/t/meson.build b/t/meson.build index d38d0f5be3cb08..73372cd547f84c 100644 --- a/t/meson.build +++ b/t/meson.build @@ -31,6 +31,7 @@ clar_test_suites = [ 'unit-tests/u-trailer.c', 'unit-tests/u-urlmatch-normalization.c', 'unit-tests/u-utf8-width.c', + 'unit-tests/u-worktree-attr-source.c', ] clar_sources = [ diff --git a/t/unit-tests/u-worktree-attr-source.c b/t/unit-tests/u-worktree-attr-source.c new file mode 100644 index 00000000000000..59bddc563639a1 --- /dev/null +++ b/t/unit-tests/u-worktree-attr-source.c @@ -0,0 +1,193 @@ +#include "unit-test.h" + +#include "dir.h" +#include "hash.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "strbuf.h" +#include "worktree-attr-source.h" +#include "wrapper.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +struct worktree_attr_source_fixture { + char *worktree; + struct repository repo; + struct semantic_verify_root *root; + struct semantic_verify_path *path; +}; + +static void source_fixture_init(struct worktree_attr_source_fixture *fixture) +{ + const char *tmp = getenv("TMPDIR"); + + memset(fixture, 0, sizeof(*fixture)); + fixture->worktree = xstrfmt( + "%s/worktree-attr-source.XXXXXX", tmp ? tmp : "/tmp"); + cl_assert(mkdtemp(fixture->worktree) != NULL); + fixture->repo.worktree = fixture->worktree; + fixture->repo.hash_algo = &hash_algos[GIT_HASH_SHA1]; + cl_must_pass(semantic_verify_root_init( + &fixture->repo, &fixture->root)); + fixture->path = semantic_verify_path_new(fixture->root); + cl_assert(fixture->path != NULL); +} + +static void source_fixture_release( + struct worktree_attr_source_fixture *fixture, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from) +{ + struct strbuf worktree = STRBUF_INIT; + + semantic_verify_path_free( + fixture->path, namespace_unstable, namespace_unstable_from); + semantic_verify_root_clear(fixture->root); + strbuf_addstr(&worktree, fixture->worktree); + cl_must_pass(remove_dir_recursively(&worktree, 0)); + strbuf_release(&worktree); + free(fixture->worktree); +} + +static void make_directory(struct worktree_attr_source_fixture *fixture, + const char *name) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addf(&path, "%s/%s", fixture->worktree, name); + cl_must_pass(mkdir(path.buf, 0777)); + strbuf_release(&path); +} +#endif + +void test_worktree_attr_source__hashes_large_regular_file(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct git_hash_ctx ctx; + struct strbuf contents = STRBUF_INIT; + struct strbuf source = STRBUF_INIT; + unsigned char actual[GIT_MAX_RAWSZ], expected[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + make_directory(&fixture, "a"); + strbuf_addchars(&contents, 'x', 64 * 1024 + 17); + strbuf_addf(&source, "%s/a/.gitattributes", fixture.worktree); + write_file_buf(source.buf, contents.buf, contents.len); + git_hash_init(&ctx, algo); + git_hash_update(&ctx, contents.buf, contents.len); + git_hash_final(expected, &ctx); + git_hash_discard(&ctx); + + cl_must_pass(worktree_attr_source_read( + fixture.path, "a/.gitattributes", 7, algo, actual, &found)); + cl_assert_equal_i(found, 1); + cl_assert(!memcmp(actual, expected, algo->rawsz)); + + source_fixture_release(&fixture, &namespace_unstable, NULL); + cl_assert_equal_i(namespace_unstable, 0); + strbuf_release(&source); + strbuf_release(&contents); +#endif +} + +void test_worktree_attr_source__reports_missing_and_non_regular_files(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + cl_must_pass(worktree_attr_source_read( + fixture.path, ".gitattributes", 0, algo, hash, &found)); + cl_assert_equal_i(found, 0); + cl_must_pass(worktree_attr_source_read( + fixture.path, "missing/.gitattributes", 1, + algo, hash, &found)); + cl_assert_equal_i(found, 0); + + make_directory(&fixture, "attributes-directory"); + cl_must_pass(worktree_attr_source_read( + fixture.path, "attributes-directory", 2, algo, hash, &found)); + cl_assert_equal_i(found, 0); + + source_fixture_release(&fixture, &namespace_unstable, NULL); + cl_assert_equal_i(namespace_unstable, 0); +#endif +} + +void test_worktree_attr_source__rejects_hardlinked_file(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct strbuf alias = STRBUF_INIT, source = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + make_directory(&fixture, "a"); + strbuf_addf(&source, "%s/a/.gitattributes", fixture.worktree); + strbuf_addf(&alias, "%s/attributes-alias", fixture.worktree); + write_file(source.buf, "*.dat text\n"); + cl_must_pass(link(source.buf, alias.buf)); + + cl_assert_equal_i(worktree_attr_source_read( + fixture.path, "a/.gitattributes", 3, algo, hash, &found), -1); + cl_assert_equal_i(found, 0); + + source_fixture_release(&fixture, &namespace_unstable, NULL); + cl_assert_equal_i(namespace_unstable, 0); + strbuf_release(&source); + strbuf_release(&alias); +#endif +} + +void test_worktree_attr_source__detects_replaced_cached_parent(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct strbuf old_parent = STRBUF_INIT, parent = STRBUF_INIT; + struct strbuf source = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + size_t namespace_unstable_from; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + make_directory(&fixture, "a"); + strbuf_addf(&parent, "%s/a", fixture.worktree); + strbuf_addf(&old_parent, "%s/a-old", fixture.worktree); + strbuf_addf(&source, "%s/.gitattributes", parent.buf); + write_file(source.buf, "*.dat text\n"); + cl_must_pass(worktree_attr_source_read( + fixture.path, "a/.gitattributes", 17, algo, hash, &found)); + cl_assert_equal_i(found, 1); + + cl_must_pass(rename(parent.buf, old_parent.buf)); + cl_must_pass(mkdir(parent.buf, 0777)); + source_fixture_release( + &fixture, &namespace_unstable, &namespace_unstable_from); + cl_assert_equal_i(namespace_unstable, 1); + cl_assert_equal_i(namespace_unstable_from, 17); + + strbuf_release(&source); + strbuf_release(&old_parent); + strbuf_release(&parent); +#endif +} diff --git a/worktree-attr-source.c b/worktree-attr-source.c new file mode 100644 index 00000000000000..d56043eba7a576 --- /dev/null +++ b/worktree-attr-source.c @@ -0,0 +1,93 @@ +#include "git-compat-util.h" +#include "attr.h" +#include "hash.h" +#include "path-namespace.h" +#include "semantic-verify-internal.h" +#include "worktree-attr-source.h" + +#define WORKTREE_ATTR_HASH_BUFFER_SIZE (64 * 1024) + +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + +int worktree_attr_source_read( + struct semantic_verify_path *path UNUSED, + const char *name UNUSED, size_t position UNUSED, + const struct git_hash_algo *algo UNUSED, + unsigned char *hash UNUSED, int *found) +{ + *found = 0; + return -1; +} + +#else + +int worktree_attr_source_read(struct semantic_verify_path *path, + const char *name, size_t position, + const struct git_hash_algo *algo, + unsigned char *hash, int *found) +{ + struct git_hash_ctx ctx = { 0 }; + struct stat before, after, named; + unsigned char buffer[WORKTREE_ATTR_HASH_BUFFER_SIZE]; + const char *basename; + ssize_t got; + size_t remaining, size; + int parent_fd, fd = -1, ret = -1; + char extra; + + *found = 0; + if (semantic_verify_resolve_parent(path, name, position, + &parent_fd, &basename)) { + if (errno == ENOENT || errno == ENOTDIR || errno == ELOOP || + errno == EXDEV) + return 0; + return -1; + } + if (fstatat(parent_fd, basename, &before, AT_SYMLINK_NOFOLLOW)) + return errno == ENOENT || errno == ENOTDIR ? 0 : -1; + if (!S_ISREG(before.st_mode) || + before.st_size < 0 || before.st_size >= ATTR_MAX_FILE_SIZE) + return 0; + if (before.st_nlink != 1) + return -1; + + fd = semantic_verify_openat(parent_fd, basename, + O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0) + return -1; + if (fstat(fd, &after) || + !path_namespace_stat_equal(&before, &after)) + goto done; + size = xsize_t(before.st_size); + remaining = size; + git_hash_init(&ctx, algo); + while (remaining) { + size_t want = remaining < sizeof(buffer) ? + remaining : sizeof(buffer); + + got = xread(fd, buffer, want); + if (got <= 0) + goto done; + git_hash_update(&ctx, buffer, got); + remaining -= got; + } + got = xread(fd, &extra, 1); + if (got != 0 || + fstat(fd, &after) || + fstatat(parent_fd, basename, &named, AT_SYMLINK_NOFOLLOW) || + !path_namespace_stat_equal(&before, &after) || + !path_namespace_stat_equal(&after, &named) || + path_namespace_reopen_component( + parent_fd, basename, O_RDONLY | O_NONBLOCK | O_NOFOLLOW, + semantic_verify_openat, &after)) + goto done; + git_hash_final(hash, &ctx); + *found = 1; + ret = 0; +done: + git_hash_discard(&ctx); + close(fd); + return ret; +} + +#endif /* SEMANTIC_VERIFY_HAS_ANCHORED_OPEN */ diff --git a/worktree-attr-source.h b/worktree-attr-source.h new file mode 100644 index 00000000000000..2db26c68a71119 --- /dev/null +++ b/worktree-attr-source.h @@ -0,0 +1,12 @@ +#ifndef WORKTREE_ATTR_SOURCE_H +#define WORKTREE_ATTR_SOURCE_H + +struct git_hash_algo; +struct semantic_verify_path; + +int worktree_attr_source_read(struct semantic_verify_path *path, + const char *name, size_t position, + const struct git_hash_algo *algo, + unsigned char *hash, int *found); + +#endif /* WORKTREE_ATTR_SOURCE_H */ From 5244300008adb114a465dabf37a5a5e341351d55 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:34:40 -0700 Subject: [PATCH 017/403] status: build worktree attribute manifests Checking only tracked .gitattributes entries misses worktree attribute files in ancestor directories. Conversely, silently substituting an indexed source for a hard-linked or unstable worktree file would certify conversion rules that Git did not safely observe. Collect the root and every directory scope containing a tracked entry. Read each candidate with S07/P04, prefer a stable worktree source, and use an available indexed object only when the worktree source is absent, nonregular, or oversized. Serialize existing sources in strict path order and hash the complete validated manifest with the repository's object algorithm. Reject unmerged or sparse indexes, missing indexed objects, source-read errors, hard links, replaced ancestors, and an unstable worktree root. Reset the output on failure so callers cannot retain a partial proof. Register the new library in both build systems. Unit tests cover tracked scopes, indexed fallback, a hard-linked worktree source despite an available indexed object, missing objects, and structural indexes. The builder is independently testable but does not run from status. Signed-off-by: Taylor Blau --- Makefile | 1 + hash-framing.h | 11 ++ meson.build | 1 + t/unit-tests/u-attr-manifest.c | 284 +++++++++++++++++++++++++++++++++ worktree-attr-manifest.c | 210 ++++++++++++++++++++++++ worktree-attr-manifest.h | 19 +++ 6 files changed, 526 insertions(+) create mode 100644 worktree-attr-manifest.c create mode 100644 worktree-attr-manifest.h diff --git a/Makefile b/Makefile index 9faa8a6587171b..2eb2b731f6a1d3 100644 --- a/Makefile +++ b/Makefile @@ -1383,6 +1383,7 @@ LIB_OBJS += versioncmp.o LIB_OBJS += walker.o LIB_OBJS += wildmatch.o LIB_OBJS += worktree.o +LIB_OBJS += worktree-attr-manifest.o LIB_OBJS += worktree-attr-source.o LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o diff --git a/hash-framing.h b/hash-framing.h index b15294b684a90d..f20b455e590f87 100644 --- a/hash-framing.h +++ b/hash-framing.h @@ -27,4 +27,15 @@ static inline void hash_optional_cstring(struct git_hash_ctx *ctx, hash_length_delimited(ctx, &missing, sizeof(missing)); } +static inline void hash_buffer_digest(const struct git_hash_algo *algo, + const void *data, size_t len, + unsigned char *hash) +{ + struct git_hash_ctx ctx; + + git_hash_init(&ctx, algo); + git_hash_update(&ctx, data, len); + git_hash_final(hash, &ctx); +} + #endif /* HASH_FRAMING_H */ diff --git a/meson.build b/meson.build index 912e7522aa1ace..98a692d05c1bb3 100644 --- a/meson.build +++ b/meson.build @@ -584,6 +584,7 @@ libgit_sources = [ 'walker.c', 'wildmatch.c', 'worktree.c', + 'worktree-attr-manifest.c', 'worktree-attr-source.c', 'wrapper.c', 'write-or-die.c', diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 41f1d606889606..9f00a7d6f22b04 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -1,6 +1,15 @@ #include "unit-test.h" #include "attr-manifest.h" +#include "dir.h" +#include "hash.h" +#include "odb.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "setup.h" #include "strbuf.h" +#include "worktree-attr-manifest.h" +#include "wrapper.h" static void fill_hash(unsigned char *hash, unsigned char value, const struct git_hash_algo *algo) @@ -187,3 +196,278 @@ void test_attr_manifest__rejects_malformed_tail_before_callbacks(void) strbuf_release(&new); strbuf_release(&old); } + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static char *create_worktree(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/attr-manifest.XXXXXX", tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void remove_worktree(char *worktree) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addstr(&path, worktree); + cl_assert_equal_i(remove_dir_recursively(&path, 0), 0); + strbuf_release(&path); + free(worktree); +} + +static struct cache_entry *add_index_path(struct index_state *istate, + size_t pos, const char *path, + unsigned int stage) +{ + size_t len = strlen(path); + struct cache_entry *ce = make_empty_cache_entry(istate, len); + + ce->ce_mode = S_IFREG | 0644; + ce->ce_flags = create_ce_flags(stage); + ce->ce_namelen = len; + memcpy(ce->name, path, len + 1); + istate->cache[pos] = ce; + return ce; +} + +static void init_object_store(struct repository *repo, const char *worktree) +{ + struct strbuf object_dir = STRBUF_INIT; + + strbuf_addf(&object_dir, "%s/objects", worktree); + repo->objects = odb_new(repo, object_dir.buf, ""); + strbuf_release(&object_dir); +} +#endif + +void test_attr_manifest__builds_sources_for_tracked_scopes(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct git_hash_ctx ctx; + struct strbuf path = STRBUF_INIT, manifest = STRBUF_INIT; + char root_source[] = "*.root text\n"; + unsigned char expected[GIT_MAX_RAWSZ], hash[GIT_MAX_RAWSZ]; + + strbuf_addf(&path, "%s/a", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/b", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/.gitattributes", worktree); + write_file_buf(path.buf, root_source, strlen(root_source)); + git_hash_init(&ctx, algo); + git_hash_update(&ctx, root_source, strlen(root_source)); + git_hash_final(expected, &ctx); + strbuf_reset(&path); + strbuf_addf(&path, "%s/a/.gitattributes", worktree); + write_file(path.buf, "*.dat -text\n"); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_index_path(&istate, 0, "a/file", 0); + add_index_path(&istate, 1, "b/file", 0); + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), 0); + cl_assert_equal_i(stats.candidates, 3); + cl_assert_equal_i(stats.worktree_sources, 2); + cl_assert_equal_i(stats.index_sources, 0); + cl_assert(attr_manifest_valid(manifest.buf, manifest.len, algo)); + + cl_assert_equal_i(attr_manifest_cursor_init( + &cursor, manifest.buf, manifest.len, algo), 0); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_WORKTREE); + cl_assert_equal_i(entry.path_len, strlen(GITATTRIBUTES_FILE)); + cl_assert(!memcmp(entry.path, GITATTRIBUTES_FILE, entry.path_len)); + cl_assert(!memcmp(entry.hash, expected, algo->rawsz)); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_WORKTREE); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + + strbuf_release(&manifest); + strbuf_release(&path); + release_index(&istate); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__falls_back_to_index_source(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + char source[] = "*.dat text\n"; + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct cache_entry *attributes; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + int have_repository, ret; + + init_object_store(&repo, worktree); + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + attributes = add_index_path(&istate, 0, GITATTRIBUTES_FILE, 0); + cl_must_pass(odb_pretend_object( + repo.objects, source, strlen(source), OBJ_BLOB, + &attributes->oid)); + + have_repository = startup_info->have_repository; + startup_info->have_repository = 1; + ret = worktree_attr_manifest_build( + &istate, &manifest, hash, &stats); + startup_info->have_repository = have_repository; + cl_assert_equal_i(ret, 0); + cl_assert_equal_i(stats.candidates, 1); + cl_assert_equal_i(stats.worktree_sources, 0); + cl_assert_equal_i(stats.index_sources, 1); + cl_assert_equal_i(attr_manifest_cursor_init( + &cursor, manifest.buf, manifest.len, algo), 0); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_INDEX); + cl_assert_equal_i(entry.path_len, strlen(GITATTRIBUTES_FILE)); + cl_assert(!memcmp(entry.path, GITATTRIBUTES_FILE, entry.path_len)); + cl_assert(!memcmp(entry.hash, attributes->oid.hash, algo->rawsz)); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + + strbuf_release(&manifest); + release_index(&istate); + odb_free(repo.objects); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__rejects_hardlinked_source_over_index(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + char indexed_source[] = "*.dat text\n"; + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct cache_entry *attributes; + struct strbuf source = STRBUF_INIT, alias = STRBUF_INIT; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + int have_repository, ret; + + init_object_store(&repo, worktree); + strbuf_addf(&source, "%s/a", worktree); + cl_assert_equal_i(mkdir(source.buf, 0777), 0); + strbuf_addstr(&source, "/" GITATTRIBUTES_FILE); + write_file(source.buf, "*.dat -text\n"); + strbuf_addf(&alias, "%s/attributes-alias", worktree); + cl_assert_equal_i(link(source.buf, alias.buf), 0); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + attributes = add_index_path( + &istate, 0, "a/" GITATTRIBUTES_FILE, 0); + add_index_path(&istate, 1, "a/file", 0); + cl_must_pass(odb_pretend_object( + repo.objects, indexed_source, strlen(indexed_source), OBJ_BLOB, + &attributes->oid)); + + have_repository = startup_info->have_repository; + startup_info->have_repository = 1; + ret = worktree_attr_manifest_build( + &istate, &manifest, hash, &stats); + startup_info->have_repository = have_repository; + cl_assert_equal_i(ret, -1); + cl_assert_equal_i(manifest.len, 0); + cl_assert_equal_i(stats.worktree_sources, 0); + cl_assert_equal_i(stats.index_sources, 0); + + strbuf_release(&manifest); + strbuf_release(&alias); + strbuf_release(&source); + release_index(&istate); + odb_free(repo.objects); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__rejects_missing_index_source(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct cache_entry *attributes; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned char missing[GIT_MAX_RAWSZ]; + + init_object_store(&repo, worktree); + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + attributes = add_index_path(&istate, 0, GITATTRIBUTES_FILE, 0); + fill_hash(missing, 0x42, algo); + oidread(&attributes->oid, missing, algo); + strbuf_addstr(&manifest, "discard me"); + + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), -1); + cl_assert_equal_i(manifest.len, 0); + + strbuf_release(&manifest); + release_index(&istate); + odb_free(repo.objects); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__builder_rejects_structural_indexes(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + add_index_path(&istate, 0, "file", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), -1); + cl_assert_equal_i(manifest.len, 0); + istate.cache[0]->ce_flags = create_ce_flags(0); + istate.sparse_index = INDEX_COLLAPSED; + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), -1); + cl_assert_equal_i(manifest.len, 0); + + strbuf_release(&manifest); + release_index(&istate); + remove_worktree(worktree); +#endif +} diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c new file mode 100644 index 00000000000000..f30757677068cb --- /dev/null +++ b/worktree-attr-manifest.c @@ -0,0 +1,210 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "dir.h" +#include "environment.h" +#include "hash-framing.h" +#include "object.h" +#include "odb.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "string-list.h" +#include "strbuf.h" +#include "worktree-attr-manifest.h" +#include "worktree-attr-source.h" + +struct attr_manifest_candidate { + unsigned char worktree_hash[GIT_MAX_RAWSZ]; + unsigned char index_hash[GIT_MAX_RAWSZ]; + unsigned int index_present : 1; + unsigned int worktree_present : 1; + unsigned int error : 1; +}; + +struct attr_manifest_probe_data { + struct string_list *candidates; + struct semantic_verify_root *root; + const struct git_hash_algo *algo; + size_t start; + size_t end; + unsigned int namespace_unstable; +}; + +static int collect_candidates(struct index_state *istate, + struct string_list *candidates) +{ + struct strbuf candidate = STRBUF_INIT; + const char *previous = NULL; + size_t previous_len = 0; + unsigned int i; + int ret = -1; + + string_list_append(candidates, GITATTRIBUTES_FILE); + for (i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + const char *slash = ce->name; + + if (ce_stage(ce) || S_ISSPARSEDIR(ce->ce_mode)) + goto done; + while ((slash = strchr(slash, '/')) != NULL) { + size_t len = slash - ce->name; + + if (!previous || previous_len <= len || + !is_dir_sep(previous[len]) || + fspathncmp(previous, ce->name, len)) { + strbuf_reset(&candidate); + strbuf_add(&candidate, ce->name, len + 1); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + string_list_append(candidates, candidate.buf); + } + slash++; + } + previous = ce->name; + previous_len = ce->ce_namelen; + } + string_list_sort(candidates); + string_list_remove_duplicates(candidates, 0); + ret = candidates->nr <= UINT32_MAX ? 0 : -1; +done: + strbuf_release(&candidate); + return ret; +} + +static int collect_index_sources(struct index_state *istate, + struct string_list *candidates) +{ + struct strbuf candidate = STRBUF_INIT; + unsigned int i; + int ret = 0; + + for (i = 0; i < candidates->nr; i++) { + struct attr_manifest_candidate *state; + + CALLOC_ARRAY(state, 1); + candidates->items[i].util = state; + } + for (i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + const char *base = strrchr(ce->name, '/'); + struct string_list_item *item; + struct attr_manifest_candidate *state; + + base = base ? base + 1 : ce->name; + if (fspathcmp(base, GITATTRIBUTES_FILE)) + continue; + strbuf_reset(&candidate); + if (base != ce->name) + strbuf_add(&candidate, ce->name, base - ce->name); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + item = string_list_lookup(candidates, candidate.buf); + if (!item) + BUG("tracked attribute source lacks manifest candidate"); + state = item->util; + if ((S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) && + odb_has_object(istate->repo->objects, &ce->oid, 0)) { + state->index_present = 1; + memcpy(state->index_hash, ce->oid.hash, + istate->repo->hash_algo->rawsz); + } else if (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) { + ret = -1; + break; + } + } + strbuf_release(&candidate); + return ret; +} + +static void probe_attr_manifest_candidates( + struct attr_manifest_probe_data *data) +{ + struct semantic_verify_path *path = + semantic_verify_path_new(data->root); + size_t i; + + for (i = data->start; i < data->end; i++) { + struct string_list_item *item = &data->candidates->items[i]; + struct attr_manifest_candidate *candidate = item->util; + int found; + + if (worktree_attr_source_read(path, item->string, i, data->algo, + candidate->worktree_hash, &found)) + candidate->error = 1; + else + candidate->worktree_present = found; + } + semantic_verify_path_free(path, &data->namespace_unstable, NULL); +} + +static int probe_candidates(struct string_list *candidates, + struct semantic_verify_root *root, + const struct git_hash_algo *algo) +{ + struct attr_manifest_probe_data data = { + .candidates = candidates, + .root = root, + .algo = algo, + .end = candidates->nr, + }; + + probe_attr_manifest_candidates(&data); + return data.namespace_unstable ? -1 : 0; +} + +int worktree_attr_manifest_build( + struct index_state *istate, + struct strbuf *manifest, + unsigned char *manifest_hash, + struct worktree_attr_manifest_stats *stats) +{ + struct string_list candidates = STRING_LIST_INIT_DUP; + struct semantic_verify_root *root = NULL; + struct attr_manifest_writer writer; + const struct git_hash_algo *algo = istate->repo->hash_algo; + unsigned int i; + int ret = -1; + + memset(stats, 0, sizeof(*stats)); + if (istate->sparse_index != INDEX_EXPANDED || + semantic_verify_root_init(istate->repo, &root) || + collect_candidates(istate, &candidates) || + collect_index_sources(istate, &candidates)) + goto done; + stats->candidates = candidates.nr; + if (probe_candidates(&candidates, root, algo)) + goto done; + attr_manifest_writer_init(&writer, manifest, algo); + for (i = 0; i < candidates.nr; i++) { + const char *name = candidates.items[i].string; + struct attr_manifest_candidate *state = candidates.items[i].util; + enum attr_manifest_source source; + const unsigned char *hash; + + if (state->error) + goto done; + if (state->worktree_present) { + source = ATTR_MANIFEST_WORKTREE; + hash = state->worktree_hash; + stats->worktree_sources++; + } else if (state->index_present) { + source = ATTR_MANIFEST_INDEX; + hash = state->index_hash; + stats->index_sources++; + } else { + continue; + } + if (attr_manifest_writer_add(&writer, name, source, hash)) + goto done; + } + if (!semantic_verify_root_stable(root)) + goto done; + if (!attr_manifest_valid(manifest->buf, manifest->len, algo)) + BUG("newly built attribute manifest is invalid"); + hash_buffer_digest(algo, manifest->buf, manifest->len, manifest_hash); + ret = 0; +done: + semantic_verify_root_clear(root); + string_list_clear(&candidates, 1); + if (ret) + strbuf_reset(manifest); + return ret; +} diff --git a/worktree-attr-manifest.h b/worktree-attr-manifest.h new file mode 100644 index 00000000000000..4c3e8dc4ba762c --- /dev/null +++ b/worktree-attr-manifest.h @@ -0,0 +1,19 @@ +#ifndef WORKTREE_ATTR_MANIFEST_H +#define WORKTREE_ATTR_MANIFEST_H + +struct index_state; +struct strbuf; + +struct worktree_attr_manifest_stats { + size_t candidates; + size_t worktree_sources; + size_t index_sources; +}; + +int worktree_attr_manifest_build( + struct index_state *istate, + struct strbuf *manifest, + unsigned char *manifest_hash, + struct worktree_attr_manifest_stats *stats); + +#endif /* WORKTREE_ATTR_MANIFEST_H */ From db68dc725d908387806ec9b753755655267616cd Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:40:39 -0700 Subject: [PATCH 018/403] status: probe attribute sources in parallel The manifest builder from S07/P05 probes every tracked directory scope, including scopes without a worktree .gitattributes file. Those independent filesystem observations can run concurrently; parallel probing does not eliminate or reduce the source probes. Divide the sorted candidate list into contiguous ranges and give each worker its own anchored path state. Limit the worker count, preserve one-worker execution on builds without threads, and keep serialization on the calling thread so scheduling cannot change manifest order. If creating a worker fails, finish that range and all unstarted ranges on the calling thread. Join started workers and reject observed namespace instability before emitting the manifest. Unit tests compare serial and parallel manifest bytes and hashes and inject a worker-creation failure to verify complete, identical fallback output. Worker and descriptor state is bounded; no timing or memory result is claimed. Signed-off-by: Taylor Blau --- t/unit-tests/u-attr-manifest.c | 141 +++++++++++++++++++++++++++++++++ worktree-attr-manifest.c | 99 ++++++++++++++++++++--- worktree-attr-manifest.h | 2 + 3 files changed, 229 insertions(+), 13 deletions(-) diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 9f00a7d6f22b04..a41eb2a7975fac 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -8,9 +8,15 @@ #include "semantic-verify-internal.h" #include "setup.h" #include "strbuf.h" +#include "thread-utils.h" #include "worktree-attr-manifest.h" #include "wrapper.h" +#define ATTR_MANIFEST_TEST_THREADS "GIT_TEST_ATTR_MANIFEST_THREADS" +#define ATTR_MANIFEST_TEST_THREAD_FAIL_AT \ + "GIT_TEST_ATTR_MANIFEST_THREAD_FAIL_AT" +#define ATTR_MANIFEST_TEST_SOURCE_NR 257 + static void fill_hash(unsigned char *hash, unsigned char value, const struct git_hash_algo *algo) { @@ -441,6 +447,141 @@ void test_attr_manifest__rejects_missing_index_source(void) #endif } +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +struct many_sources_fixture { + const struct git_hash_algo *algo; + char *worktree; + struct repository repo; + struct index_state istate; +}; + +static void many_sources_fixture_init(struct many_sources_fixture *fixture) +{ + struct strbuf path = STRBUF_INIT; + size_t i; + + memset(fixture, 0, sizeof(*fixture)); + fixture->algo = &hash_algos[GIT_HASH_SHA1]; + fixture->worktree = create_worktree(); + fixture->repo.worktree = fixture->worktree; + fixture->repo.hash_algo = fixture->algo; + index_state_init(&fixture->istate, &fixture->repo); + CALLOC_ARRAY(fixture->istate.cache, ATTR_MANIFEST_TEST_SOURCE_NR); + fixture->istate.cache_alloc = fixture->istate.cache_nr = + ATTR_MANIFEST_TEST_SOURCE_NR; + + for (i = 0; i < ATTR_MANIFEST_TEST_SOURCE_NR; i++) { + strbuf_reset(&path); + strbuf_addf(&path, "%s/d%03" PRIuMAX, + fixture->worktree, (uintmax_t)i); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_addstr(&path, "/" GITATTRIBUTES_FILE); + write_file(path.buf, "source %" PRIuMAX "\n", (uintmax_t)i); + strbuf_reset(&path); + strbuf_addf(&path, "d%03" PRIuMAX "/file", (uintmax_t)i); + add_index_path(&fixture->istate, i, path.buf, 0); + } + strbuf_release(&path); +} + +static void many_sources_fixture_release(struct many_sources_fixture *fixture) +{ + release_index(&fixture->istate); + remove_worktree(fixture->worktree); +} + +static void clear_attr_manifest_thread_env(void *unused UNUSED) +{ + unsetenv(ATTR_MANIFEST_TEST_THREADS); + unsetenv(ATTR_MANIFEST_TEST_THREAD_FAIL_AT); +} +#endif + +void test_attr_manifest__parallel_probes_match_serial_output(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct many_sources_fixture fixture; + struct worktree_attr_manifest_stats serial_stats, parallel_stats; + struct strbuf serial = STRBUF_INIT, parallel = STRBUF_INIT; + unsigned char serial_hash[GIT_MAX_RAWSZ]; + unsigned char parallel_hash[GIT_MAX_RAWSZ]; + + if (!HAVE_THREADS) + return; + cl_set_cleanup(clear_attr_manifest_thread_env, NULL); + many_sources_fixture_init(&fixture); + + xsetenv(ATTR_MANIFEST_TEST_THREADS, "1", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, &serial, serial_hash, &serial_stats), 0); + cl_assert_equal_i(serial_stats.candidates, + ATTR_MANIFEST_TEST_SOURCE_NR + 1); + cl_assert_equal_i(serial_stats.threads, 1); + cl_assert_equal_i(serial_stats.worktree_sources, + ATTR_MANIFEST_TEST_SOURCE_NR); + + xsetenv(ATTR_MANIFEST_TEST_THREADS, "2", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, ¶llel, parallel_hash, ¶llel_stats), 0); + cl_assert_equal_i(parallel_stats.candidates, + ATTR_MANIFEST_TEST_SOURCE_NR + 1); + cl_assert_equal_i(parallel_stats.threads, 2); + cl_assert_equal_i(parallel_stats.thread_failures, 0); + cl_assert_equal_i(parallel_stats.worktree_sources, + ATTR_MANIFEST_TEST_SOURCE_NR); + cl_assert_equal_i(serial.len, parallel.len); + cl_assert(!memcmp(serial.buf, parallel.buf, serial.len)); + cl_assert(!memcmp(serial_hash, parallel_hash, fixture.algo->rawsz)); + + strbuf_release(¶llel); + strbuf_release(&serial); + many_sources_fixture_release(&fixture); + clear_attr_manifest_thread_env(NULL); +#endif +} + +void test_attr_manifest__thread_failure_completes_remaining_ranges(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct many_sources_fixture fixture; + struct worktree_attr_manifest_stats serial_stats, fallback_stats; + struct strbuf serial = STRBUF_INIT, fallback = STRBUF_INIT; + unsigned char serial_hash[GIT_MAX_RAWSZ]; + unsigned char fallback_hash[GIT_MAX_RAWSZ]; + + if (!HAVE_THREADS) + return; + cl_set_cleanup(clear_attr_manifest_thread_env, NULL); + many_sources_fixture_init(&fixture); + + xsetenv(ATTR_MANIFEST_TEST_THREADS, "1", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, &serial, serial_hash, &serial_stats), 0); + xsetenv(ATTR_MANIFEST_TEST_THREADS, "2", 1); + xsetenv(ATTR_MANIFEST_TEST_THREAD_FAIL_AT, "1", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, &fallback, fallback_hash, &fallback_stats), 0); + cl_assert_equal_i(fallback_stats.candidates, + ATTR_MANIFEST_TEST_SOURCE_NR + 1); + cl_assert_equal_i(fallback_stats.threads, 2); + cl_assert_equal_i(fallback_stats.thread_failures, 1); + cl_assert_equal_i(fallback_stats.worktree_sources, + ATTR_MANIFEST_TEST_SOURCE_NR); + cl_assert_equal_i(serial.len, fallback.len); + cl_assert(!memcmp(serial.buf, fallback.buf, serial.len)); + cl_assert(!memcmp(serial_hash, fallback_hash, fixture.algo->rawsz)); + + strbuf_release(&fallback); + strbuf_release(&serial); + many_sources_fixture_release(&fixture); + clear_attr_manifest_thread_env(NULL); +#endif +} + void test_attr_manifest__builder_rejects_structural_indexes(void) { #if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c index f30757677068cb..1a0234360c67cb 100644 --- a/worktree-attr-manifest.c +++ b/worktree-attr-manifest.c @@ -5,14 +5,19 @@ #include "hash-framing.h" #include "object.h" #include "odb.h" +#include "parse.h" #include "read-cache-ll.h" #include "repository.h" #include "semantic-verify-internal.h" #include "string-list.h" #include "strbuf.h" +#include "thread-utils.h" #include "worktree-attr-manifest.h" #include "worktree-attr-source.h" +#define ATTR_MANIFEST_FILES_PER_THREAD 256 +#define ATTR_MANIFEST_MAX_THREADS 32 + struct attr_manifest_candidate { unsigned char worktree_hash[GIT_MAX_RAWSZ]; unsigned char index_hash[GIT_MAX_RAWSZ]; @@ -30,6 +35,12 @@ struct attr_manifest_probe_data { unsigned int namespace_unstable; }; +struct attr_manifest_thread { + struct attr_manifest_probe_data probe; + pthread_t pthread; + unsigned int started : 1; +}; + static int collect_candidates(struct index_state *istate, struct string_list *candidates) { @@ -114,9 +125,9 @@ static int collect_index_sources(struct index_state *istate, return ret; } -static void probe_attr_manifest_candidates( - struct attr_manifest_probe_data *data) +static void *probe_attr_manifest_candidates(void *cb_data) { + struct attr_manifest_probe_data *data = cb_data; struct semantic_verify_path *path = semantic_verify_path_new(data->root); size_t i; @@ -133,21 +144,83 @@ static void probe_attr_manifest_candidates( candidate->worktree_present = found; } semantic_verify_path_free(path, &data->namespace_unstable, NULL); + return NULL; +} + +static size_t select_thread_count(size_t candidates) +{ + size_t cpus, test_threads, threads; + + if (!HAVE_THREADS) + return 1; + threads = DIV_ROUND_UP(candidates, ATTR_MANIFEST_FILES_PER_THREAD); + cpus = online_cpus(); + if (threads > cpus * 2) + threads = cpus * 2; + test_threads = git_env_ulong("GIT_TEST_ATTR_MANIFEST_THREADS", 0); + if (test_threads) + threads = test_threads; + if (threads > ATTR_MANIFEST_MAX_THREADS) + threads = ATTR_MANIFEST_MAX_THREADS; + if (threads > candidates) + threads = candidates; + return threads ? threads : 1; +} + +static int create_probe_thread(struct attr_manifest_thread *worker, + size_t thread_id) +{ + if (git_env_ulong("GIT_TEST_ATTR_MANIFEST_THREAD_FAIL_AT", + ULONG_MAX) == thread_id) + return EAGAIN; + return pthread_create(&worker->pthread, NULL, + probe_attr_manifest_candidates, &worker->probe); } static int probe_candidates(struct string_list *candidates, struct semantic_verify_root *root, - const struct git_hash_algo *algo) + const struct git_hash_algo *algo, + struct worktree_attr_manifest_stats *stats) { - struct attr_manifest_probe_data data = { - .candidates = candidates, - .root = root, - .algo = algo, - .end = candidates->nr, - }; - - probe_attr_manifest_candidates(&data); - return data.namespace_unstable ? -1 : 0; + struct attr_manifest_thread *workers; + size_t thread_id, threads = select_thread_count(candidates->nr); + int create_threads = HAVE_THREADS; + int ret = 0; + + CALLOC_ARRAY(workers, threads); + for (thread_id = 0; thread_id < threads; thread_id++) { + struct attr_manifest_thread *worker = &workers[thread_id]; + struct attr_manifest_probe_data *data = &worker->probe; + int err; + + data->candidates = candidates; + data->root = root; + data->algo = algo; + data->start = st_mult(candidates->nr, thread_id) / threads; + data->end = st_mult(candidates->nr, thread_id + 1) / threads; + if (threads == 1 || !create_threads) { + probe_attr_manifest_candidates(data); + continue; + } + err = create_probe_thread(worker, thread_id); + if (!err) { + worker->started = 1; + continue; + } + stats->thread_failures++; + create_threads = 0; + probe_attr_manifest_candidates(data); + } + for (thread_id = 0; thread_id < threads; thread_id++) { + struct attr_manifest_thread *worker = &workers[thread_id]; + + if (worker->started && pthread_join(worker->pthread, NULL)) + die("unable to join attribute manifest thread"); + ret |= worker->probe.namespace_unstable; + } + stats->threads = threads; + free(workers); + return ret ? -1 : 0; } int worktree_attr_manifest_build( @@ -170,7 +243,7 @@ int worktree_attr_manifest_build( collect_index_sources(istate, &candidates)) goto done; stats->candidates = candidates.nr; - if (probe_candidates(&candidates, root, algo)) + if (probe_candidates(&candidates, root, algo, stats)) goto done; attr_manifest_writer_init(&writer, manifest, algo); for (i = 0; i < candidates.nr; i++) { diff --git a/worktree-attr-manifest.h b/worktree-attr-manifest.h index 4c3e8dc4ba762c..4b3e17c26abd35 100644 --- a/worktree-attr-manifest.h +++ b/worktree-attr-manifest.h @@ -6,8 +6,10 @@ struct strbuf; struct worktree_attr_manifest_stats { size_t candidates; + size_t threads; size_t worktree_sources; size_t index_sources; + size_t thread_failures; }; int worktree_attr_manifest_build( From 750b209706f9b4e60fa818e25c5ccfe06060d93c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:30:48 -0700 Subject: [PATCH 019/403] fsmonitor: add a bounded clean-proof record format A provider token cannot establish clean status unless its configuration, conversion semantics, attribute sources, and manifest are recorded as one verifiable observation. Parsing a malformed record directly into index state could also publish part of an invalid proof. Define a versioned, length-delimited clean-proof codec with distinct magic, bounded flags, a bounded token, object-format-sized configuration and attribute hashes, the validated attribute manifest, and a trailing checksum. Parse into temporary state and publish the decoded view only after all lengths, flags, token bytes, manifest records, and the checksum agree. Allow a generic writer to retain validated history while clearing its token and stat bindings instead of asserting a fresh provider epoch. Register the library and Clar suite in both Make and Meson. Tests cover both object formats, corrupt and truncated records, invalid flags, embedded token NULs, checksum changes, and selective clearing of epoch bindings. The codec does not attach an extension to an index or enable an early status answer. Signed-off-by: Taylor Blau --- Makefile | 2 + fsmonitor-clean-proof.c | 116 ++++++++++++++++++++ fsmonitor-clean-proof.h | 42 ++++++++ hash-framing.h | 10 ++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-fsmonitor-clean-proof.c | 141 +++++++++++++++++++++++++ 7 files changed, 313 insertions(+) create mode 100644 fsmonitor-clean-proof.c create mode 100644 fsmonitor-clean-proof.h create mode 100644 t/unit-tests/u-fsmonitor-clean-proof.c diff --git a/Makefile b/Makefile index 2eb2b731f6a1d3..f2fe60decb2d5b 100644 --- a/Makefile +++ b/Makefile @@ -1176,6 +1176,7 @@ LIB_OBJS += fetch-object-info.o LIB_OBJS += fetch-pack.o LIB_OBJS += fmt-merge-msg.o LIB_OBJS += fsck.o +LIB_OBJS += fsmonitor-clean-proof.o LIB_OBJS += fsmonitor.o LIB_OBJS += fsmonitor-ipc.o LIB_OBJS += fsmonitor-settings.o @@ -1552,6 +1553,7 @@ CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate CLAR_TEST_SUITES += u-fsmonitor-attributes +CLAR_TEST_SUITES += u-fsmonitor-clean-proof CLAR_TEST_SUITES += u-hash CLAR_TEST_SUITES += u-hashmap CLAR_TEST_SUITES += u-list-objects-filter-options diff --git a/fsmonitor-clean-proof.c b/fsmonitor-clean-proof.c new file mode 100644 index 00000000000000..3c45c014469737 --- /dev/null +++ b/fsmonitor-clean-proof.c @@ -0,0 +1,116 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" +#include "strbuf.h" + +#define FSMONITOR_CLEAN_PROOF_MAGIC 0x46534331 /* "FSC1" */ +#define FSMONITOR_CLEAN_PROOF_HEADER_WORDS 5 + +int fsmonitor_clean_proof_parse(struct fsmonitor_clean_proof *proof, + const void *data, size_t len, + const struct git_hash_algo *algo) +{ + struct fsmonitor_clean_proof parsed = { 0 }; + const unsigned char *p = data; + const unsigned char *end = p + len; + unsigned char checksum[GIT_MAX_RAWSZ]; + size_t hashes_len = 4 * algo->rawsz; + uint32_t token_len, manifest_len; + + memset(proof, 0, sizeof(*proof)); + if (len < FSMONITOR_CLEAN_PROOF_HEADER_WORDS * sizeof(uint32_t) + + hashes_len + 1) + return -1; + if (get_be32(p) != FSMONITOR_CLEAN_PROOF_VERSION) + return -1; + p += sizeof(uint32_t); + if (get_be32(p) != FSMONITOR_CLEAN_PROOF_MAGIC) + return -1; + p += sizeof(uint32_t); + parsed.flags = get_be32(p); + p += sizeof(uint32_t); + token_len = get_be32(p); + p += sizeof(uint32_t); + manifest_len = get_be32(p); + p += sizeof(uint32_t); + if (parsed.flags & ~FSMONITOR_CLEAN_PROOF_ALL || !token_len || + token_len > FSMONITOR_CLEAN_PROOF_TOKEN_MAX || + manifest_len < sizeof(uint32_t) || + (size_t)(end - p) < token_len || memchr(p, '\0', token_len)) + return -1; + parsed.token = p; + parsed.token_len = token_len; + p += token_len; + if ((size_t)(end - p) < hashes_len || + (size_t)(end - p) - hashes_len != manifest_len) + return -1; + parsed.config_hash = p; + p += algo->rawsz; + parsed.semantic_hash = p; + p += algo->rawsz; + parsed.attr_hash = p; + p += algo->rawsz; + parsed.attr_manifest = p; + parsed.attr_manifest_len = manifest_len; + p += manifest_len; + if (!attr_manifest_valid(parsed.attr_manifest, + parsed.attr_manifest_len, algo)) + return -1; + hash_buffer_digest(algo, data, len - algo->rawsz, checksum); + if (memcmp(checksum, p, algo->rawsz)) + return -1; + *proof = parsed; + return 0; +} + +int fsmonitor_clean_proof_write(struct strbuf *out, + const struct fsmonitor_clean_proof *proof, + const struct git_hash_algo *algo) +{ + uint32_t value; + + strbuf_reset(out); + if (!proof->token || !proof->token_len || + proof->token_len > FSMONITOR_CLEAN_PROOF_TOKEN_MAX || + proof->token_len > UINT32_MAX || + memchr(proof->token, '\0', proof->token_len) || + proof->flags & ~FSMONITOR_CLEAN_PROOF_ALL || + !proof->config_hash || !proof->semantic_hash || !proof->attr_hash || + !proof->attr_manifest || proof->attr_manifest_len > UINT32_MAX || + !attr_manifest_valid(proof->attr_manifest, + proof->attr_manifest_len, algo)) + return -1; + + put_be32(&value, FSMONITOR_CLEAN_PROOF_VERSION); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, FSMONITOR_CLEAN_PROOF_MAGIC); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, proof->flags); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, proof->token_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, proof->attr_manifest_len); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, proof->token, proof->token_len); + strbuf_add(out, proof->config_hash, algo->rawsz); + strbuf_add(out, proof->semantic_hash, algo->rawsz); + strbuf_add(out, proof->attr_hash, algo->rawsz); + strbuf_add(out, proof->attr_manifest, proof->attr_manifest_len); + hash_append_checksum(out, algo); + return 0; +} + +int fsmonitor_clean_proof_copy_without_bindings( + struct strbuf *out, const void *data, size_t len, + const struct git_hash_algo *algo) +{ + struct fsmonitor_clean_proof proof; + + strbuf_reset(out); + if (fsmonitor_clean_proof_parse(&proof, data, len, algo)) + return -1; + proof.flags &= ~(FSMONITOR_CLEAN_PROOF_TOKEN_BOUND | + FSMONITOR_CLEAN_PROOF_STAT_BOUND); + return fsmonitor_clean_proof_write(out, &proof, algo); +} diff --git a/fsmonitor-clean-proof.h b/fsmonitor-clean-proof.h new file mode 100644 index 00000000000000..0d4da4cd725803 --- /dev/null +++ b/fsmonitor-clean-proof.h @@ -0,0 +1,42 @@ +#ifndef FSMONITOR_CLEAN_PROOF_H +#define FSMONITOR_CLEAN_PROOF_H + +#include "hash.h" + +struct strbuf; + +#define FSMONITOR_CLEAN_PROOF_VERSION 1 +#define FSMONITOR_CLEAN_PROOF_TOKEN_MAX 4096 + +#define FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE (1u << 0) +#define FSMONITOR_CLEAN_PROOF_TOKEN_BOUND (1u << 1) +#define FSMONITOR_CLEAN_PROOF_STAT_BOUND (1u << 2) +#define FSMONITOR_CLEAN_PROOF_FULL_INDEX (1u << 3) +#define FSMONITOR_CLEAN_PROOF_ALL \ + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | \ + FSMONITOR_CLEAN_PROOF_TOKEN_BOUND | \ + FSMONITOR_CLEAN_PROOF_STAT_BOUND | \ + FSMONITOR_CLEAN_PROOF_FULL_INDEX) + +struct fsmonitor_clean_proof { + uint32_t flags; + const unsigned char *token; + size_t token_len; + const unsigned char *config_hash; + const unsigned char *semantic_hash; + const unsigned char *attr_hash; + const unsigned char *attr_manifest; + size_t attr_manifest_len; +}; + +int fsmonitor_clean_proof_parse(struct fsmonitor_clean_proof *proof, + const void *data, size_t len, + const struct git_hash_algo *algo); +int fsmonitor_clean_proof_write(struct strbuf *out, + const struct fsmonitor_clean_proof *proof, + const struct git_hash_algo *algo); +int fsmonitor_clean_proof_copy_without_bindings( + struct strbuf *out, const void *data, size_t len, + const struct git_hash_algo *algo); + +#endif /* FSMONITOR_CLEAN_PROOF_H */ diff --git a/hash-framing.h b/hash-framing.h index f20b455e590f87..6808cc288faa04 100644 --- a/hash-framing.h +++ b/hash-framing.h @@ -2,6 +2,7 @@ #define HASH_FRAMING_H #include "hash.h" +#include "strbuf.h" static inline void hash_length_delimited(struct git_hash_ctx *ctx, const void *data, size_t len) @@ -38,4 +39,13 @@ static inline void hash_buffer_digest(const struct git_hash_algo *algo, git_hash_final(hash, &ctx); } +static inline void hash_append_checksum(struct strbuf *out, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + hash_buffer_digest(algo, out->buf, out->len, hash); + strbuf_add(out, hash, algo->rawsz); +} + #endif /* HASH_FRAMING_H */ diff --git a/meson.build b/meson.build index 98a692d05c1bb3..c81db56fb078b4 100644 --- a/meson.build +++ b/meson.build @@ -380,6 +380,7 @@ libgit_sources = [ 'fetch-pack.c', 'fmt-merge-msg.c', 'fsck.c', + 'fsmonitor-clean-proof.c', 'fsmonitor.c', 'fsmonitor-ipc.c', 'fsmonitor-settings.c', diff --git a/t/meson.build b/t/meson.build index 73372cd547f84c..812a11c51d18d7 100644 --- a/t/meson.build +++ b/t/meson.build @@ -5,6 +5,7 @@ clar_test_suites = [ 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', 'unit-tests/u-fsmonitor-attributes.c', + 'unit-tests/u-fsmonitor-clean-proof.c', 'unit-tests/u-hash.c', 'unit-tests/u-hashmap.c', 'unit-tests/u-list-objects-filter-options.c', diff --git a/t/unit-tests/u-fsmonitor-clean-proof.c b/t/unit-tests/u-fsmonitor-clean-proof.c new file mode 100644 index 00000000000000..b4691221c75f49 --- /dev/null +++ b/t/unit-tests/u-fsmonitor-clean-proof.c @@ -0,0 +1,141 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "fsmonitor-clean-proof.h" +#include "strbuf.h" + +struct proof_fixture { + struct strbuf manifest; + struct strbuf encoded; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char attr_hash[GIT_MAX_RAWSZ]; + struct fsmonitor_clean_proof proof; +}; + +static void fixture_init(struct proof_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct attr_manifest_writer writer; + unsigned char hash[GIT_MAX_RAWSZ]; + static const unsigned char token[] = "builtin:1:2"; + + memset(fixture, 0, sizeof(*fixture)); + fixture->manifest = (struct strbuf)STRBUF_INIT; + fixture->encoded = (struct strbuf)STRBUF_INIT; + memset(hash, 1, algo->rawsz); + memset(fixture->config_hash, 2, algo->rawsz); + memset(fixture->semantic_hash, 3, algo->rawsz); + memset(fixture->attr_hash, 4, algo->rawsz); + attr_manifest_writer_init(&writer, &fixture->manifest, algo); + cl_assert_equal_i(attr_manifest_writer_add( + &writer, ".gitattributes", ATTR_MANIFEST_INDEX, hash), 0); + fixture->proof.flags = FSMONITOR_CLEAN_PROOF_ALL; + fixture->proof.token = token; + fixture->proof.token_len = sizeof(token) - 1; + fixture->proof.config_hash = fixture->config_hash; + fixture->proof.semantic_hash = fixture->semantic_hash; + fixture->proof.attr_hash = fixture->attr_hash; + fixture->proof.attr_manifest = + (const unsigned char *)fixture->manifest.buf; + fixture->proof.attr_manifest_len = fixture->manifest.len; +} + +static void fixture_release(struct proof_fixture *fixture) +{ + strbuf_release(&fixture->encoded); + strbuf_release(&fixture->manifest); +} + +static void assert_round_trip(const struct git_hash_algo *algo) +{ + struct proof_fixture fixture; + struct fsmonitor_clean_proof parsed; + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture.encoded, &fixture.proof, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.flags, fixture.proof.flags); + cl_assert_equal_i(parsed.token_len, fixture.proof.token_len); + cl_assert(!memcmp(parsed.token, fixture.proof.token, parsed.token_len)); + cl_assert(!memcmp(parsed.config_hash, fixture.config_hash, algo->rawsz)); + cl_assert(!memcmp(parsed.attr_manifest, fixture.manifest.buf, + parsed.attr_manifest_len)); + fixture_release(&fixture); +} + +static void assert_rejected(struct fsmonitor_clean_proof *parsed, + const struct strbuf *encoded, + const struct git_hash_algo *algo) +{ + memset(parsed, 0xff, sizeof(*parsed)); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + parsed, encoded->buf, encoded->len, algo), -1); + cl_assert_equal_i(parsed->flags, 0); + cl_assert_equal_p(parsed->token, NULL); + cl_assert_equal_i(parsed->token_len, 0); + cl_assert_equal_p(parsed->config_hash, NULL); + cl_assert_equal_p(parsed->semantic_hash, NULL); + cl_assert_equal_p(parsed->attr_hash, NULL); + cl_assert_equal_p(parsed->attr_manifest, NULL); + cl_assert_equal_i(parsed->attr_manifest_len, 0); +} + +void test_fsmonitor_clean_proof__round_trips_both_object_formats(void) +{ + assert_round_trip(&hash_algos[GIT_HASH_SHA1]); + assert_round_trip(&hash_algos[GIT_HASH_SHA256]); +} + +void test_fsmonitor_clean_proof__rejects_corrupt_records(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct proof_fixture fixture; + struct fsmonitor_clean_proof parsed; + size_t token_offset = 5 * sizeof(uint32_t); + uint32_t saved; + unsigned char byte; + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture.encoded, &fixture.proof, algo), 0); + fixture.encoded.len--; + assert_rejected(&parsed, &fixture.encoded, algo); + fixture.encoded.len++; + saved = get_be32(fixture.encoded.buf + 2 * sizeof(uint32_t)); + put_be32(fixture.encoded.buf + 2 * sizeof(uint32_t), 1u << 31); + assert_rejected(&parsed, &fixture.encoded, algo); + put_be32(fixture.encoded.buf + 2 * sizeof(uint32_t), saved); + byte = fixture.encoded.buf[token_offset]; + fixture.encoded.buf[token_offset] = '\0'; + assert_rejected(&parsed, &fixture.encoded, algo); + fixture.encoded.buf[token_offset] = byte; + fixture.encoded.buf[fixture.encoded.len - 1] ^= 1; + assert_rejected(&parsed, &fixture.encoded, algo); + fixture_release(&fixture); +} + +void test_fsmonitor_clean_proof__clears_only_epoch_bindings(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + struct proof_fixture fixture; + struct fsmonitor_clean_proof parsed; + struct strbuf unbound = STRBUF_INIT; + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture.encoded, &fixture.proof, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_copy_without_bindings( + &unbound, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, unbound.buf, unbound.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + cl_assert_equal_i(parsed.token_len, fixture.proof.token_len); + cl_assert(!memcmp(parsed.attr_manifest, fixture.manifest.buf, + fixture.manifest.len)); + strbuf_release(&unbound); + fixture_release(&fixture); +} From c23170e4f4312d6f8b39e2a1d2044958316c8934 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:42:56 -0700 Subject: [PATCH 020/403] status: retain validated worktree attribute manifests A persisted attribute manifest must not become current merely because its bytes were present in an index. Invalid records or unknown proof flags could otherwise leave half-loaded history available to a later clean-status decision. Introduce a state object that owns separate persisted and current manifest buffers, their hashes, proof flags, and validity bits. Validate incoming bytes with S07/P02 and accept only the flags defined by S07/P07. Clear the persisted view before loading; copy it into the current view only through explicit adoption. Register the state library and Clar suite in both Make and Meson. Tests cover valid loading and adoption, explicit current-state invalidation, and removal of a previously valid persisted view after malformed input. This state does not read an index extension, scan the worktree, invalidate attribute paths, or activate a status optimization. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-manifest.c | 56 +++++++++++++++++++++ clean-status-manifest.h | 29 +++++++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-manifest.c | 70 ++++++++++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 clean-status-manifest.c create mode 100644 clean-status-manifest.h create mode 100644 t/unit-tests/u-clean-status-manifest.c diff --git a/Makefile b/Makefile index f2fe60decb2d5b..8bdaab2391f7b2 100644 --- a/Makefile +++ b/Makefile @@ -1125,6 +1125,7 @@ LIB_OBJS += chdir-notify.o LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o @@ -1549,6 +1550,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config +CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-manifest.c b/clean-status-manifest.c new file mode 100644 index 00000000000000..713d8bd4d5e104 --- /dev/null +++ b/clean-status-manifest.c @@ -0,0 +1,56 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "clean-status-manifest.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" + +void clean_status_manifest_init(struct clean_status_manifest_state *state) +{ + memset(state, 0, sizeof(*state)); + strbuf_init(&state->disk, 0); + strbuf_init(&state->current, 0); +} + +void clean_status_manifest_release(struct clean_status_manifest_state *state) +{ + strbuf_release(&state->disk); + strbuf_release(&state->current); +} + +int clean_status_manifest_load(struct clean_status_manifest_state *state, + const void *data, size_t len, uint32_t flags, + const struct git_hash_algo *algo) +{ + state->disk_valid = 0; + state->disk_flags = 0; + strbuf_reset(&state->disk); + if (flags & ~FSMONITOR_CLEAN_PROOF_ALL || + !attr_manifest_valid(data, len, algo)) + return -1; + strbuf_add(&state->disk, data, len); + hash_buffer_digest(algo, data, len, state->disk_hash); + state->disk_flags = flags; + state->disk_valid = 1; + return 0; +} + +void clean_status_manifest_adopt_disk( + struct clean_status_manifest_state *state) +{ + if (!state->disk_valid) + BUG("cannot adopt an invalid clean-status manifest"); + strbuf_reset(&state->current); + strbuf_addbuf(&state->current, &state->disk); + memcpy(state->current_hash, state->disk_hash, + sizeof(state->current_hash)); + state->current_flags = state->disk_flags; + state->current_valid = 1; + state->checked = 1; +} + +void clean_status_manifest_invalidate( + struct clean_status_manifest_state *state) +{ + state->current_valid = 0; + state->current_flags = 0; +} diff --git a/clean-status-manifest.h b/clean-status-manifest.h new file mode 100644 index 00000000000000..e924ba9fade2fe --- /dev/null +++ b/clean-status-manifest.h @@ -0,0 +1,29 @@ +#ifndef CLEAN_STATUS_MANIFEST_H +#define CLEAN_STATUS_MANIFEST_H + +#include "hash.h" +#include "strbuf.h" + +struct clean_status_manifest_state { + struct strbuf disk; + struct strbuf current; + unsigned char disk_hash[GIT_MAX_RAWSZ]; + unsigned char current_hash[GIT_MAX_RAWSZ]; + uint32_t disk_flags; + uint32_t current_flags; + unsigned disk_valid : 1; + unsigned current_valid : 1; + unsigned checked : 1; +}; + +void clean_status_manifest_init(struct clean_status_manifest_state *state); +void clean_status_manifest_release(struct clean_status_manifest_state *state); +int clean_status_manifest_load(struct clean_status_manifest_state *state, + const void *data, size_t len, uint32_t flags, + const struct git_hash_algo *algo); +void clean_status_manifest_adopt_disk( + struct clean_status_manifest_state *state); +void clean_status_manifest_invalidate( + struct clean_status_manifest_state *state); + +#endif /* CLEAN_STATUS_MANIFEST_H */ diff --git a/meson.build b/meson.build index c81db56fb078b4..abc61a3c8bd6d4 100644 --- a/meson.build +++ b/meson.build @@ -333,6 +333,7 @@ libgit_sources = [ 'checkout.c', 'chunk-format.c', 'clean-status-config.c', + 'clean-status-manifest.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/t/meson.build b/t/meson.build index 812a11c51d18d7..d0ed8bb9461d07 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,6 +1,7 @@ clar_test_suites = [ 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', + 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c new file mode 100644 index 00000000000000..e6d83c564a8a6b --- /dev/null +++ b/t/unit-tests/u-clean-status-manifest.c @@ -0,0 +1,70 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "clean-status-manifest.h" +#include "fsmonitor-clean-proof.h" + +static void make_manifest(struct strbuf *manifest, + const struct git_hash_algo *algo) +{ + struct attr_manifest_writer writer; + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(hash, 1, algo->rawsz); + attr_manifest_writer_init(&writer, manifest, algo); + cl_assert_equal_i(attr_manifest_writer_add( + &writer, ".gitattributes", ATTR_MANIFEST_INDEX, hash), 0); +} + +void test_clean_status_manifest__loads_and_adopts_valid_history(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_manifest_state state; + struct strbuf manifest = STRBUF_INIT; + + clean_status_manifest_init(&state); + make_manifest(&manifest, algo); + cl_assert_equal_i(clean_status_manifest_load( + &state, manifest.buf, manifest.len, + FSMONITOR_CLEAN_PROOF_ALL, algo), 0); + cl_assert(state.disk_valid); + clean_status_manifest_adopt_disk(&state); + cl_assert(state.current_valid); + cl_assert(state.checked); + cl_assert_equal_i(state.current_flags, FSMONITOR_CLEAN_PROOF_ALL); + cl_assert_equal_i(state.current.len, manifest.len); + cl_assert(!memcmp(state.current.buf, manifest.buf, manifest.len)); + clean_status_manifest_invalidate(&state); + cl_assert(!state.current_valid); + cl_assert_equal_i(state.current_flags, 0); + clean_status_manifest_release(&state); + strbuf_release(&manifest); +} + +void test_clean_status_manifest__rejects_invalid_history(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_manifest_state state; + struct strbuf manifest = STRBUF_INIT; + size_t valid_len; + + clean_status_manifest_init(&state); + make_manifest(&manifest, algo); + valid_len = manifest.len; + cl_assert_equal_i(clean_status_manifest_load( + &state, manifest.buf, manifest.len, + FSMONITOR_CLEAN_PROOF_ALL, algo), 0); + cl_assert(state.disk_valid); + cl_assert_equal_i(state.disk_flags, FSMONITOR_CLEAN_PROOF_ALL); + cl_assert_equal_i(state.disk.len, valid_len); + cl_assert(!memcmp(state.disk.buf, manifest.buf, valid_len)); + + strbuf_addch(&manifest, 0); + cl_assert_equal_i(clean_status_manifest_load( + &state, manifest.buf, manifest.len, + FSMONITOR_CLEAN_PROOF_ALL, algo), -1); + cl_assert(!state.disk_valid); + cl_assert_equal_i(state.disk_flags, 0); + cl_assert_equal_i(state.disk.len, 0); + clean_status_manifest_release(&state); + strbuf_release(&manifest); +} From 6548d5b87847bb6546194843137e70d0698152a1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 10:32:32 -0700 Subject: [PATCH 021/403] path: snapshot external source namespaces An external attributes file can be missing, reached through a symbolic link, or redirected when an ancestor is replaced. Hashing its contents alone cannot distinguish stable absence from a changed containing namespace, or identical bytes reached through a different path. Extend the filesystem-identity primitives from S06/P03 to capture the lstat identity or absence of every component of an absolute path. Compare snapshots component by component and hash their explicit states and canonical identity fields with length-delimited framing. Reject capture with EAGAIN when the platform cannot report reliable object identity instead of hashing fabricated identity fields. Expose whether the final component exists and release all snapshot storage explicitly. Tests verify equal snapshots and hashes, a missing target that subsequently appears, and an ancestor replacement that changes the namespace even when the replacement has identical content. The snapshot is independently testable. It does not read an external attribute source or establish a status speedup. Signed-off-by: Taylor Blau --- path-namespace.c | 155 +++++++++++++++++++++++++++++ path-namespace.h | 11 ++ t/unit-tests/u-path-namespace.c | 171 ++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+) diff --git a/path-namespace.c b/path-namespace.c index 151634b886b663..14cc149c8ef188 100644 --- a/path-namespace.c +++ b/path-namespace.c @@ -1,5 +1,25 @@ #include "git-compat-util.h" +#include "abspath.h" +#include "hash.h" +#include "hash-framing.h" #include "path-namespace.h" +#include "strbuf.h" + +enum namespace_entry_state { + NAMESPACE_ENTRY_MISSING = 0, + NAMESPACE_ENTRY_PRESENT = 1, +}; + +struct stat_fingerprint { + struct path_stat_identity identity; + unsigned int state; +}; + +struct path_namespace_snapshot { + struct stat_fingerprint *entries; + size_t nr; + size_t alloc; +}; void path_stat_identity_init(struct path_stat_identity *identity, const struct stat *st) @@ -37,6 +57,133 @@ int path_stat_identity_equal(const struct path_stat_identity *a, return !memcmp(a, b, sizeof(*a)); } +static void stat_fingerprint_init(struct stat_fingerprint *fingerprint, + const struct stat *st) +{ + memset(fingerprint, 0, sizeof(*fingerprint)); + fingerprint->state = NAMESPACE_ENTRY_PRESENT; + path_stat_identity_init(&fingerprint->identity, st); + if (S_ISDIR(st->st_mode)) { + /* Unrelated children do not change which object a path names. */ + fingerprint->identity.fields[3] = 0; + fingerprint->identity.fields[6] = 0; + for (size_t i = 7; i <= 10; i++) + fingerprint->identity.fields[i] = 0; + } +} + +static int stat_fingerprint_equal(const struct stat_fingerprint *a, + const struct stat_fingerprint *b) +{ + return a->state == b->state && + path_stat_identity_equal(&a->identity, &b->identity); +} + +static int capture_entry(const char *path, + struct path_namespace_snapshot *snapshot) +{ + struct stat st; + struct stat_fingerprint *entry; + + ALLOC_GROW(snapshot->entries, snapshot->nr + 1, snapshot->alloc); + entry = &snapshot->entries[snapshot->nr++]; + memset(entry, 0, sizeof(*entry)); + if (!lstat(path, &st)) { + stat_fingerprint_init(entry, &st); + return 0; + } + if (errno == ENOENT || errno == ENOTDIR) { + entry->state = NAMESPACE_ENTRY_MISSING; + return 0; + } + return -1; +} + +int path_namespace_capture(const char *path, + struct path_namespace_snapshot **snapshot_out) +{ + struct path_namespace_snapshot *snapshot; + struct strbuf prefix = STRBUF_INIT; + size_t root_len, pos; + int ret = -1; + + if (!fstat_is_reliable()) { + errno = EAGAIN; + return -1; + } + + CALLOC_ARRAY(snapshot, 1); + root_len = offset_1st_component(path); + if (!root_len) + goto done; + strbuf_add(&prefix, path, root_len); + if (capture_entry(prefix.buf, snapshot)) + goto done; + pos = root_len; + while (path[pos]) { + size_t start, end; + + while (path[pos] && is_dir_sep(path[pos])) + pos++; + if (!path[pos]) + break; + start = pos; + while (path[pos] && !is_dir_sep(path[pos])) + pos++; + end = pos; + strbuf_complete(&prefix, '/'); + strbuf_add(&prefix, path + start, end - start); + if (capture_entry(prefix.buf, snapshot)) + goto done; + } + *snapshot_out = snapshot; + snapshot = NULL; + ret = 0; +done: + path_namespace_clear(snapshot); + strbuf_release(&prefix); + return ret; +} + +int path_namespace_equal(const struct path_namespace_snapshot *a, + const struct path_namespace_snapshot *b) +{ + if (a->nr != b->nr) + return 0; + for (size_t i = 0; i < a->nr; i++) + if (!stat_fingerprint_equal(&a->entries[i], &b->entries[i])) + return 0; + return 1; +} + +int path_namespace_target_present( + const struct path_namespace_snapshot *snapshot) +{ + return snapshot->nr && + snapshot->entries[snapshot->nr - 1].state == + NAMESPACE_ENTRY_PRESENT; +} + +void path_namespace_hash(struct git_hash_ctx *ctx, + const struct path_namespace_snapshot *snapshot) +{ + uint32_t value; + uint64_t field; + + put_be32(&value, snapshot->nr); + hash_length_delimited(ctx, &value, sizeof(value)); + for (size_t i = 0; i < snapshot->nr; i++) { + put_be32(&value, snapshot->entries[i].state); + hash_length_delimited(ctx, &value, sizeof(value)); + for (size_t j = 0; + j < ARRAY_SIZE(snapshot->entries[i].identity.fields); j++) { + put_be64(&field, + snapshot->entries[i].identity.fields[j]); + hash_length_delimited(ctx, &field, sizeof(field)); + } + } +} + int path_namespace_stat_equal(const struct stat *a, const struct stat *b) { struct path_stat_identity first, second; @@ -87,3 +234,11 @@ int path_namespace_reopen_component( errno = saved_errno; return -1; } + +void path_namespace_clear(struct path_namespace_snapshot *snapshot) +{ + if (!snapshot) + return; + free(snapshot->entries); + free(snapshot); +} diff --git a/path-namespace.h b/path-namespace.h index c26f4f12aebd48..16a607f94118f8 100644 --- a/path-namespace.h +++ b/path-namespace.h @@ -1,6 +1,8 @@ #ifndef PATH_NAMESPACE_H #define PATH_NAMESPACE_H +struct git_hash_ctx; +struct path_namespace_snapshot; struct stat; typedef int (*path_namespace_open_fn)(int dirfd, const char *path, int flags); @@ -15,9 +17,18 @@ void path_stat_identity_init(struct path_stat_identity *identity, const struct stat *st); int path_stat_identity_equal(const struct path_stat_identity *a, const struct path_stat_identity *b); +int path_namespace_capture(const char *path, + struct path_namespace_snapshot **snapshot_out); +int path_namespace_equal(const struct path_namespace_snapshot *a, + const struct path_namespace_snapshot *b); +int path_namespace_target_present( + const struct path_namespace_snapshot *snapshot); +void path_namespace_hash(struct git_hash_ctx *ctx, + const struct path_namespace_snapshot *snapshot); int path_namespace_stat_equal(const struct stat *a, const struct stat *b); int path_namespace_reopen_component( int parent_fd, const char *component, int flags, path_namespace_open_fn open_fn, const struct stat *expected); +void path_namespace_clear(struct path_namespace_snapshot *snapshot); #endif /* PATH_NAMESPACE_H */ diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c index 4e0d9dfab24d5d..3c80140a8bbf72 100644 --- a/t/unit-tests/u-path-namespace.c +++ b/t/unit-tests/u-path-namespace.c @@ -1,7 +1,11 @@ #include "unit-test.h" +#include "dir.h" +#include "hash.h" #include "path-namespace.h" +#include "strbuf.h" #include "tempfile.h" +#include "wrapper.h" #define ASSERT_STAT_FIELD_MATTERS(base, changed, field) do { \ (changed) = (base); \ @@ -115,3 +119,170 @@ void test_path_namespace__reopen_component(void) cl_must_pass(delete_tempfile(&first)); cl_must_pass(delete_tempfile(&second)); } + +static char *create_namespace(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/path-namespace.XXXXXX", tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void remove_namespace(char *path) +{ + struct strbuf root = STRBUF_INIT; + + strbuf_addstr(&root, path); + cl_must_pass(remove_dir_recursively(&root, 0)); + strbuf_release(&root); + free(path); +} + +static void hash_namespace(const struct path_namespace_snapshot *snapshot, + unsigned char *hash) +{ + struct git_hash_ctx ctx; + + git_hash_init(&ctx, &hash_algos[GIT_HASH_SHA1]); + path_namespace_hash(&ctx, snapshot); + git_hash_final(hash, &ctx); + git_hash_discard(&ctx); +} + +void test_path_namespace__equal_snapshots_have_equal_hashes(void) +{ + struct path_namespace_snapshot *first = NULL, *second = NULL; + struct strbuf directory = STRBUF_INIT, target = STRBUF_INIT; + unsigned char first_hash[GIT_MAX_RAWSZ], second_hash[GIT_MAX_RAWSZ]; + char *root; + + if (!fstat_is_reliable()) + cl_skip(); + root = create_namespace(); + + strbuf_addf(&directory, "%s/a", root); + cl_must_pass(mkdir(directory.buf, 0777)); + strbuf_addf(&target, "%s/target", directory.buf); + write_file(target.buf, "contents\n"); + + cl_must_pass(path_namespace_capture(target.buf, &first)); + cl_must_pass(path_namespace_capture(target.buf, &second)); + cl_assert(path_namespace_target_present(first)); + cl_assert(path_namespace_target_present(second)); + cl_assert(path_namespace_equal(first, second)); + hash_namespace(first, first_hash); + hash_namespace(second, second_hash); + cl_assert(!memcmp(first_hash, second_hash, + hash_algos[GIT_HASH_SHA1].rawsz)); + + path_namespace_clear(second); + path_namespace_clear(first); + strbuf_release(&target); + strbuf_release(&directory); + remove_namespace(root); +} + +void test_path_namespace__captures_missing_and_replaced_components(void) +{ + struct path_namespace_snapshot *missing = NULL, *created = NULL; + struct path_namespace_snapshot *replaced = NULL; + struct strbuf directory = STRBUF_INIT, old_directory = STRBUF_INIT; + struct strbuf target = STRBUF_INIT; + unsigned char missing_hash[GIT_MAX_RAWSZ], created_hash[GIT_MAX_RAWSZ]; + unsigned char replaced_hash[GIT_MAX_RAWSZ]; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *root; + + if (!fstat_is_reliable()) { + cl_assert(path_namespace_capture(".", &missing) < 0); + cl_assert_equal_i(errno, EAGAIN); + return; + } + root = create_namespace(); + + strbuf_addf(&directory, "%s/a", root); + strbuf_addf(&old_directory, "%s/a-old", root); + cl_must_pass(mkdir(directory.buf, 0777)); + strbuf_addf(&target, "%s/target", directory.buf); + + cl_must_pass(path_namespace_capture(target.buf, &missing)); + cl_assert(!path_namespace_target_present(missing)); + hash_namespace(missing, missing_hash); + + write_file(target.buf, "contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &created)); + cl_assert(path_namespace_target_present(created)); + cl_assert(!path_namespace_equal(missing, created)); + hash_namespace(created, created_hash); + cl_assert(memcmp(missing_hash, created_hash, algo->rawsz)); + + cl_must_pass(rename(directory.buf, old_directory.buf)); + cl_must_pass(mkdir(directory.buf, 0777)); + write_file(target.buf, "contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &replaced)); + cl_assert(path_namespace_target_present(replaced)); + cl_assert(!path_namespace_equal(created, replaced)); + hash_namespace(replaced, replaced_hash); + cl_assert(memcmp(created_hash, replaced_hash, algo->rawsz)); + + path_namespace_clear(replaced); + path_namespace_clear(created); + path_namespace_clear(missing); + strbuf_release(&target); + strbuf_release(&old_directory); + strbuf_release(&directory); + remove_namespace(root); +} + +void test_path_namespace__unrelated_ancestor_entries_leave_target_unchanged(void) +{ + struct path_namespace_snapshot *first = NULL, *second = NULL; + struct path_namespace_snapshot *modified = NULL, *permissions = NULL; + struct strbuf directory = STRBUF_INIT, target = STRBUF_INIT; + struct strbuf unrelated = STRBUF_INIT; + unsigned char first_hash[GIT_MAX_RAWSZ], second_hash[GIT_MAX_RAWSZ]; + unsigned char modified_hash[GIT_MAX_RAWSZ]; + struct stat st; + char *root; + + if (!fstat_is_reliable()) + cl_skip(); + root = create_namespace(); + + strbuf_addf(&directory, "%s/a", root); + cl_must_pass(mkdir(directory.buf, 0777)); + strbuf_addf(&target, "%s/target", directory.buf); + write_file(target.buf, "contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &first)); + hash_namespace(first, first_hash); + + strbuf_addf(&unrelated, "%s/unrelated", root); + write_file(unrelated.buf, "unrelated\n"); + cl_must_pass(path_namespace_capture(target.buf, &second)); + hash_namespace(second, second_hash); + cl_assert(path_namespace_equal(first, second)); + cl_assert(!memcmp(first_hash, second_hash, + hash_algos[GIT_HASH_SHA1].rawsz)); + + write_file(target.buf, "changed contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &modified)); + hash_namespace(modified, modified_hash); + cl_assert(!path_namespace_equal(second, modified)); + cl_assert(memcmp(second_hash, modified_hash, + hash_algos[GIT_HASH_SHA1].rawsz)); + + cl_must_pass(stat(directory.buf, &st)); + cl_must_pass(chmod(directory.buf, st.st_mode ^ S_IXGRP)); + cl_must_pass(path_namespace_capture(target.buf, &permissions)); + cl_assert(!path_namespace_equal(modified, permissions)); + + path_namespace_clear(permissions); + path_namespace_clear(modified); + path_namespace_clear(second); + path_namespace_clear(first); + strbuf_release(&unrelated); + strbuf_release(&target); + strbuf_release(&directory); + remove_namespace(root); +} From a07c51299800d104be06e5e2dc80a1a43223fc65 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:47:10 -0700 Subject: [PATCH 022/403] attr: fingerprint external sources in stable namespaces An external attributes file can change conversion without changing a worktree attribute manifest. Content alone is also insufficient: an ancestor or linked target can be replaced, and a missing source is safe to reuse only while its containing namespace remains stable. Capture the normalized absolute-path namespace with S07/P09 before and after observing each enabled source. For a present source, require nonblocking-open support, a regular singly linked file below the attribute-file limit, and matching descriptor, pathname, and target identities. Read the entire file into one allocation. Record source configuration and contents in one framed digest, and component and target identities in a separate namespace digest. Recheck the complete namespace for stable missing sources. Enabled sources inherit the namespace capture's fail-closed identity check; disabled sources remain unobserved and safely digestible. Reject instability rather than publishing an incomplete fingerprint. Register the fingerprint library and Clar suite in both Make and Meson. Tests separate content from metadata changes, detect an altered ancestor of a missing source, preserve disabled-source digests, and exercise both object formats. This does not select repository attribute sources or integrate fingerprints into status. Signed-off-by: Taylor Blau --- Makefile | 2 + attr-fingerprint.c | 132 ++++++++++++++++++++++++++++++ attr-fingerprint.h | 21 +++++ meson.build | 1 + path-namespace.c | 12 +++ path-namespace.h | 2 + t/meson.build | 1 + t/unit-tests/u-attr-fingerprint.c | 127 ++++++++++++++++++++++++++++ 8 files changed, 298 insertions(+) create mode 100644 attr-fingerprint.c create mode 100644 attr-fingerprint.h create mode 100644 t/unit-tests/u-attr-fingerprint.c diff --git a/Makefile b/Makefile index 8bdaab2391f7b2..d3a4ee8b399294 100644 --- a/Makefile +++ b/Makefile @@ -1110,6 +1110,7 @@ LIB_OBJS += archive-tar.o LIB_OBJS += archive-zip.o LIB_OBJS += archive.o LIB_OBJS += attr.o +LIB_OBJS += attr-fingerprint.o LIB_OBJS += attr-manifest.o LIB_OBJS += base85.o LIB_OBJS += bisect.o @@ -1548,6 +1549,7 @@ THIRD_PARTY_SOURCES += sha1dc/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% +CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-clean-status-manifest diff --git a/attr-fingerprint.c b/attr-fingerprint.c new file mode 100644 index 00000000000000..ce21f0510032e9 --- /dev/null +++ b/attr-fingerprint.c @@ -0,0 +1,132 @@ +#include "git-compat-util.h" +#include "abspath.h" +#include "attr-fingerprint.h" +#include "attr.h" +#include "hash-framing.h" +#include "path-namespace.h" +#include "strbuf.h" +#include "wrapper.h" + +static int open_attr_source(const char *path) +{ +#ifdef O_NONBLOCK + return git_open_cloexec(path, O_RDONLY | O_NONBLOCK); +#else + (void)path; + errno = ENOSYS; + return -1; +#endif +} + +static int hash_source(struct git_hash_ctx *content_ctx, + struct git_hash_ctx *namespace_ctx, + const struct attr_fingerprint_source *source, + int *present) +{ + struct path_namespace_snapshot *before = NULL, *after = NULL; + struct stat opened_before, opened_after, named; + struct strbuf normalized = STRBUF_INIT; + char *absolute = NULL; + char *buf = NULL; + ssize_t got; + size_t size; + uint32_t state; + int fd = -1, ret = -1; + char extra; + + hash_optional_cstring(content_ctx, source->path); + hash_optional_cstring(namespace_ctx, source->path); + put_be32(&state, source->enabled); + hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(namespace_ctx, &state, sizeof(state)); + *present = 0; + if (!source->enabled || !source->path) + return 0; + + absolute = absolute_pathdup(source->path); + strbuf_addstr(&normalized, absolute); + if (strbuf_normalize_path(&normalized) || + path_namespace_capture(normalized.buf, &before)) + goto done; + *present = path_namespace_target_present(before); + if (!*present) { + if (path_namespace_capture(normalized.buf, &after) || + !path_namespace_equal(before, after)) + goto done; + state = 0; + hash_length_delimited(content_ctx, &state, sizeof(state)); + path_namespace_hash(namespace_ctx, before); + ret = 0; + goto done; + } + + fd = open_attr_source(normalized.buf); + if (fd < 0 || fstat(fd, &opened_before) || + !S_ISREG(opened_before.st_mode) || opened_before.st_nlink != 1 || + opened_before.st_size < 0 || + opened_before.st_size >= ATTR_MAX_FILE_SIZE) + goto done; + size = xsize_t(opened_before.st_size); + buf = xmalloc(size ? size : 1); + got = read_in_full(fd, buf, size); + if (got < 0 || (size_t)got != size || read(fd, &extra, 1) != 0 || + fstat(fd, &opened_after) || stat(normalized.buf, &named) || + !path_namespace_stat_equal(&opened_before, &opened_after) || + !path_namespace_stat_equal(&opened_after, &named) || + path_namespace_capture(normalized.buf, &after) || + !path_namespace_equal(before, after)) + goto done; + state = 1; + hash_length_delimited(content_ctx, &state, sizeof(state)); + path_namespace_hash(namespace_ctx, before); + path_namespace_hash_stat(namespace_ctx, &opened_after); + hash_length_delimited(content_ctx, buf, size); + ret = 0; +done: + if (fd >= 0) + close(fd); + free(buf); + free(absolute); + path_namespace_clear(before); + path_namespace_clear(after); + strbuf_release(&normalized); + return ret; +} + +static int fingerprint_sources( + const struct attr_fingerprint_source *sources, size_t nr, + const struct git_hash_algo *algo, struct attr_fingerprint *result) +{ + struct git_hash_ctx content_ctx, namespace_ctx; + uint32_t count; + + memset(result, 0, sizeof(*result)); + git_hash_init(&content_ctx, algo); + git_hash_init(&namespace_ctx, algo); + hash_optional_cstring(&content_ctx, "attribute-source-content-v1"); + hash_optional_cstring(&namespace_ctx, + "attribute-source-namespace-v1"); + if (nr > UINT32_MAX) + return -1; + put_be32(&count, nr); + hash_length_delimited(&content_ctx, &count, sizeof(count)); + hash_length_delimited(&namespace_ctx, &count, sizeof(count)); + for (size_t i = 0; i < nr; i++) { + int present; + + if (hash_source(&content_ctx, &namespace_ctx, &sources[i], + &present)) + return -1; + result->sources_present |= present; + } + git_hash_final(result->content_hash, &content_ctx); + git_hash_final(result->namespace_hash, &namespace_ctx); + return 0; +} + +int attr_fingerprint_sources( + const struct attr_fingerprint_source *sources, size_t nr, + const struct git_hash_algo *algo, struct attr_fingerprint *result) +{ + return fingerprint_sources(sources, nr, algo, result); +} diff --git a/attr-fingerprint.h b/attr-fingerprint.h new file mode 100644 index 00000000000000..7f3d4f0b7c1688 --- /dev/null +++ b/attr-fingerprint.h @@ -0,0 +1,21 @@ +#ifndef ATTR_FINGERPRINT_H +#define ATTR_FINGERPRINT_H + +#include "hash.h" + +struct attr_fingerprint_source { + const char *path; + unsigned int enabled : 1; +}; + +struct attr_fingerprint { + unsigned char content_hash[GIT_MAX_RAWSZ]; + unsigned char namespace_hash[GIT_MAX_RAWSZ]; + unsigned int sources_present : 1; +}; + +int attr_fingerprint_sources( + const struct attr_fingerprint_source *sources, size_t nr, + const struct git_hash_algo *algo, struct attr_fingerprint *result); + +#endif /* ATTR_FINGERPRINT_H */ diff --git a/meson.build b/meson.build index abc61a3c8bd6d4..ee646f1a998466 100644 --- a/meson.build +++ b/meson.build @@ -317,6 +317,7 @@ libgit_sources = [ 'archive-tar.c', 'archive-zip.c', 'archive.c', + 'attr-fingerprint.c', 'attr-manifest.c', 'attr.c', 'base85.c', diff --git a/path-namespace.c b/path-namespace.c index 14cc149c8ef188..533c8b53262899 100644 --- a/path-namespace.c +++ b/path-namespace.c @@ -184,6 +184,18 @@ void path_namespace_hash(struct git_hash_ctx *ctx, } } +void path_namespace_hash_stat(struct git_hash_ctx *ctx, const struct stat *st) +{ + struct path_stat_identity identity; + uint64_t field; + + path_stat_identity_init(&identity, st); + for (size_t i = 0; i < ARRAY_SIZE(identity.fields); i++) { + put_be64(&field, identity.fields[i]); + hash_length_delimited(ctx, &field, sizeof(field)); + } +} + int path_namespace_stat_equal(const struct stat *a, const struct stat *b) { struct path_stat_identity first, second; diff --git a/path-namespace.h b/path-namespace.h index 16a607f94118f8..d702b2570aae73 100644 --- a/path-namespace.h +++ b/path-namespace.h @@ -25,6 +25,8 @@ int path_namespace_target_present( const struct path_namespace_snapshot *snapshot); void path_namespace_hash(struct git_hash_ctx *ctx, const struct path_namespace_snapshot *snapshot); +void path_namespace_hash_stat(struct git_hash_ctx *ctx, + const struct stat *st); int path_namespace_stat_equal(const struct stat *a, const struct stat *b); int path_namespace_reopen_component( int parent_fd, const char *component, int flags, diff --git a/t/meson.build b/t/meson.build index d0ed8bb9461d07..0e733642e2c8a5 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,4 +1,5 @@ clar_test_suites = [ + 'unit-tests/u-attr-fingerprint.c', 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-clean-status-manifest.c', diff --git a/t/unit-tests/u-attr-fingerprint.c b/t/unit-tests/u-attr-fingerprint.c new file mode 100644 index 00000000000000..e7b61b687c788f --- /dev/null +++ b/t/unit-tests/u-attr-fingerprint.c @@ -0,0 +1,127 @@ +#include "unit-test.h" +#include "attr-fingerprint.h" +#include "dir.h" +#include "strbuf.h" +#include "wrapper.h" + +static char *create_directory(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/attr-fingerprint.XXXXXX", + tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void remove_directory(char *path) +{ + struct strbuf cleanup = STRBUF_INIT; + + strbuf_addstr(&cleanup, path); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + free(path); +} + +static void fingerprint(const char *path, int enabled, + const struct git_hash_algo *algo, + struct attr_fingerprint *result) +{ + struct attr_fingerprint_source source = { + .path = path, + .enabled = enabled, + }; + + cl_assert_equal_i(attr_fingerprint_sources( + &source, 1, algo, result), 0); +} + +void test_attr_fingerprint__separates_contents_from_namespace(void) +{ +#ifndef O_NONBLOCK + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *directory = create_directory(); + struct strbuf path = STRBUF_INIT; + struct attr_fingerprint initial, metadata, changed; + struct stat st; + + strbuf_addf(&path, "%s/attributes", directory); + write_file(path.buf, "*.txt text\n"); + fingerprint(path.buf, 1, algo, &initial); + cl_assert(initial.sources_present); + cl_assert_equal_i(stat(path.buf, &st), 0); + cl_assert_equal_i(chmod(path.buf, st.st_mode ^ S_IXUSR), 0); + fingerprint(path.buf, 1, algo, &metadata); + cl_assert(!memcmp(initial.content_hash, metadata.content_hash, + algo->rawsz)); + cl_assert(memcmp(initial.namespace_hash, metadata.namespace_hash, + algo->rawsz)); + write_file(path.buf, "*.txt -text\n"); + fingerprint(path.buf, 1, algo, &changed); + cl_assert(memcmp(metadata.content_hash, changed.content_hash, + algo->rawsz)); + + strbuf_release(&path); + remove_directory(directory); +#endif +} + +void test_attr_fingerprint__records_missing_parent_namespaces(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + char *directory; + struct strbuf path = STRBUF_INIT; + struct attr_fingerprint before, after; + struct stat st; + + if (!fstat_is_reliable()) { + struct attr_fingerprint_source source = { + .path = "missing/attributes", + .enabled = 1, + }; + + cl_assert(attr_fingerprint_sources( + &source, 1, algo, &before) < 0); + cl_assert_equal_i(errno, EAGAIN); + return; + } + directory = create_directory(); + + strbuf_addf(&path, "%s/missing/attributes", directory); + fingerprint(path.buf, 1, algo, &before); + cl_assert(!before.sources_present); + cl_assert_equal_i(stat(directory, &st), 0); + cl_assert_equal_i(chmod(directory, st.st_mode ^ S_IXGRP), 0); + fingerprint(path.buf, 1, algo, &after); + cl_assert(!after.sources_present); + cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); + cl_assert(memcmp(before.namespace_hash, after.namespace_hash, + algo->rawsz)); + + strbuf_release(&path); + remove_directory(directory); +} + +void test_attr_fingerprint__does_not_observe_disabled_sources(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *directory = create_directory(); + struct strbuf path = STRBUF_INIT; + struct attr_fingerprint before, after; + + strbuf_addf(&path, "%s/attributes", directory); + fingerprint(path.buf, 0, algo, &before); + write_file(path.buf, "*.txt text\n"); + fingerprint(path.buf, 0, algo, &after); + cl_assert(!before.sources_present); + cl_assert(!after.sources_present); + cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); + cl_assert(!memcmp(before.namespace_hash, after.namespace_hash, + algo->rawsz)); + + strbuf_release(&path); + remove_directory(directory); +} From daf3d5c608f2f1b0af2cb873e659594758a5e094 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:32 -0500 Subject: [PATCH 023/403] status: bind semantic configuration to its index A clean-status configuration digest cannot establish which index it describes while it remains detached from the repository and index that will consume it. External attribute content and namespace must also be recorded before an index can reuse conversion-dependent history. Attach a finalized, repository-bound digest at the beginning of do_read_index(), fingerprint the system, global, and info attribute sources, and store the resulting state on the index. Ignore an unfinalized digest, another repository's digest, and a second attachment. Release the state with release_index(). Extend the existing clean-status configuration unit suite to exercise repository binding, one-shot attachment, semantic and attribute hashes, unsafe-filter state, and index-lifetime cleanup. Register the new production object with both Make and Meson. Signed-off-by: Taylor Blau --- Makefile | 1 + attr-fingerprint.c | 35 ++++++++ attr-fingerprint.h | 4 + clean-status-internal.h | 25 ++++++ clean-status.c | 82 ++++++++++++++++++ clean-status.h | 17 ++++ meson.build | 1 + read-cache-ll.h | 2 + read-cache.c | 3 + t/unit-tests/u-clean-status-config.c | 123 +++++++++++++++++++++++++++ 10 files changed, 293 insertions(+) create mode 100644 clean-status-internal.h create mode 100644 clean-status.c create mode 100644 clean-status.h diff --git a/Makefile b/Makefile index 9ddaf95ee566e8..b1d8379fdfaa41 100644 --- a/Makefile +++ b/Makefile @@ -1125,6 +1125,7 @@ LIB_OBJS += cbtree.o LIB_OBJS += chdir-notify.o LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o +LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o diff --git a/attr-fingerprint.c b/attr-fingerprint.c index ce21f0510032e9..6a9cde2821614c 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -2,8 +2,11 @@ #include "abspath.h" #include "attr-fingerprint.h" #include "attr.h" +#include "environment.h" #include "hash-framing.h" +#include "path.h" #include "path-namespace.h" +#include "repository.h" #include "strbuf.h" #include "wrapper.h" @@ -130,3 +133,35 @@ int attr_fingerprint_sources( { return fingerprint_sources(sources, nr, algo, result); } + +static int repository_sources(struct repository *repo, + struct attr_fingerprint_source *sources, + char **info_attributes) +{ + if (getenv(GIT_ATTR_SOURCE_ENVIRONMENT)) + return -1; + sources[0].path = git_attr_system_file(); + sources[0].enabled = git_attr_system_is_enabled(); + sources[1].path = git_attr_global_file(); + sources[1].enabled = 1; + *info_attributes = repo_git_path(repo, INFOATTRIBUTES_FILE); + sources[2].path = *info_attributes; + sources[2].enabled = 1; + return 0; +} + +int attr_fingerprint_repository(struct repository *repo, + struct attr_fingerprint *result) +{ + struct attr_fingerprint_source sources[3]; + char *info_attributes = NULL; + int ret; + + memset(result, 0, sizeof(*result)); + if (repository_sources(repo, sources, &info_attributes)) + return -1; + ret = attr_fingerprint_sources(sources, ARRAY_SIZE(sources), + repo->hash_algo, result); + free(info_attributes); + return ret; +} diff --git a/attr-fingerprint.h b/attr-fingerprint.h index 7f3d4f0b7c1688..a159aa0697468c 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -3,6 +3,8 @@ #include "hash.h" +struct repository; + struct attr_fingerprint_source { const char *path; unsigned int enabled : 1; @@ -17,5 +19,7 @@ struct attr_fingerprint { int attr_fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, const struct git_hash_algo *algo, struct attr_fingerprint *result); +int attr_fingerprint_repository(struct repository *repo, + struct attr_fingerprint *result); #endif /* ATTR_FINGERPRINT_H */ diff --git a/clean-status-internal.h b/clean-status-internal.h new file mode 100644 index 00000000000000..4a04e20a18f612 --- /dev/null +++ b/clean-status-internal.h @@ -0,0 +1,25 @@ +#ifndef CLEAN_STATUS_INTERNAL_H +#define CLEAN_STATUS_INTERNAL_H + +#include "hash.h" + +struct index_state; + +struct clean_status_state { + unsigned char current_config_hash[GIT_MAX_RAWSZ]; + unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; + unsigned char current_attr_hash[GIT_MAX_RAWSZ]; + unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned current_config_valid : 1; + unsigned current_semantic_valid : 1; + unsigned current_attr_valid : 1; + unsigned current_semantic_explicit : 1; + unsigned current_attr_sources_present : 1; + unsigned config_enforced : 1; + unsigned filter_configured : 1; + unsigned filter_scope_valid : 1; +}; + +struct clean_status_state *clean_status_get_state(struct index_state *istate); + +#endif /* CLEAN_STATUS_INTERNAL_H */ diff --git a/clean-status.c b/clean-status.c new file mode 100644 index 00000000000000..c0cfb63c407428 --- /dev/null +++ b/clean-status.c @@ -0,0 +1,82 @@ +#include "git-compat-util.h" +#include "attr-fingerprint.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "read-cache-ll.h" +#include "repository.h" + +static struct repository *configured_repo; +static unsigned char configured_hash[GIT_MAX_RAWSZ]; +static unsigned char configured_semantic_hash[GIT_MAX_RAWSZ]; +static int configured_hash_valid; +static int configured_filter_configured; +static int configured_semantic_explicit; + +struct clean_status_state *clean_status_get_state(struct index_state *istate) +{ + if (!istate->clean_status) + CALLOC_ARRAY(istate->clean_status, 1); + return istate->clean_status; +} + +void clean_status_set_config_digest( + struct repository *repo, + const struct clean_status_config_digest *digest) +{ + configured_repo = repo; + configured_hash_valid = digest && digest->finalized; + configured_filter_configured = configured_hash_valid && + digest->filter_configured; + configured_semantic_explicit = configured_hash_valid && + digest->semantic_config_explicit; + if (!configured_hash_valid) + return; + memcpy(configured_hash, digest->hash, repo->hash_algo->rawsz); + memcpy(configured_semantic_hash, digest->semantic_hash, + repo->hash_algo->rawsz); +} + +void clean_status_attach_config(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + struct attr_fingerprint attrs; + + if (state && state->current_config_valid) + return; + if (!configured_hash_valid || configured_repo != istate->repo) + return; + state = clean_status_get_state(istate); + memcpy(state->current_config_hash, configured_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->current_semantic_hash, configured_semantic_hash, + istate->repo->hash_algo->rawsz); + state->current_config_valid = 1; + state->current_semantic_valid = 1; + state->current_semantic_explicit = configured_semantic_explicit; + state->config_enforced = 1; + state->filter_configured = configured_filter_configured; + if (!attr_fingerprint_repository(istate->repo, &attrs)) { + memcpy(state->current_attr_hash, attrs.content_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_namespace_hash, attrs.namespace_hash, + istate->repo->hash_algo->rawsz); + state->current_attr_valid = 1; + state->current_attr_sources_present = attrs.sources_present; + } +} + +int clean_status_filter_scope_needs_validation( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->current_config_valid && state->config_enforced && + state->filter_configured && !state->filter_scope_valid; +} + +void clean_status_release(struct index_state *istate) +{ + if (!istate->clean_status) + return; + FREE_AND_NULL(istate->clean_status); +} diff --git a/clean-status.h b/clean-status.h new file mode 100644 index 00000000000000..7a45d2f51c395e --- /dev/null +++ b/clean-status.h @@ -0,0 +1,17 @@ +#ifndef CLEAN_STATUS_H +#define CLEAN_STATUS_H + +#include "clean-status-config.h" + +struct index_state; +struct repository; + +void clean_status_set_config_digest( + struct repository *repo, + const struct clean_status_config_digest *digest); +void clean_status_attach_config(struct index_state *istate); +int clean_status_filter_scope_needs_validation( + const struct index_state *istate); +void clean_status_release(struct index_state *istate); + +#endif /* CLEAN_STATUS_H */ diff --git a/meson.build b/meson.build index ee646f1a998466..d3b433856cb165 100644 --- a/meson.build +++ b/meson.build @@ -333,6 +333,7 @@ libgit_sources = [ 'chdir-notify.c', 'checkout.c', 'chunk-format.c', + 'clean-status.c', 'clean-status-config.c', 'clean-status-manifest.c', 'color.c', diff --git a/read-cache-ll.h b/read-cache-ll.h index d44a946ed04d36..943ccc296a71d6 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -142,6 +142,7 @@ static inline unsigned create_ce_flags(unsigned stage) #define FSMONITOR_CHANGED (1 << 8) struct split_index; +struct clean_status_state; struct untracked_cache; struct progress; struct pattern_list; @@ -202,6 +203,7 @@ struct index_state { struct progress *progress; struct repository *repo; struct pattern_list *sparse_checkout_patterns; + struct clean_status_state *clean_status; }; /** diff --git a/read-cache.c b/read-cache.c index cf3600f2a337d6..2b2493b45aee97 100644 --- a/read-cache.c +++ b/read-cache.c @@ -16,6 +16,7 @@ #include "tempfile.h" #include "lockfile.h" #include "cache-tree.h" +#include "clean-status.h" #include "refs.h" #include "dir.h" #include "object-file.h" @@ -2241,6 +2242,7 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) int nr_threads, cpus; struct index_entry_offset_table *ieot = NULL; + clean_status_attach_config(istate); if (istate->initialized) return istate->cache_nr; @@ -2473,6 +2475,7 @@ void release_index(struct index_state *istate) free(istate->fsmonitor_last_update); free(istate->fsmonitor_last_update_pending); free(istate->fsmonitor_untracked_token); + clean_status_release(istate); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index cc88bb0680518c..74e40b205d85c0 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -1,6 +1,14 @@ #include "unit-test.h" +#include "attr-fingerprint.h" +#include "clean-status.h" #include "clean-status-config.h" +#include "clean-status-internal.h" #include "config.h" +#include "dir.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strbuf.h" +#include "wrapper.h" static void digest_one(struct clean_status_config_digest *digest, const char *key, const char *value, @@ -111,3 +119,118 @@ void test_clean_status_config__configured_filters_bump_proof_domains(void) cl_assert(hashes_equal(smudge.hash, smudge_full)); cl_assert(hashes_equal(smudge.semantic_hash, smudge_semantic)); } + +#if defined(O_NONBLOCK) && !defined(GIT_WINDOWS_NATIVE) +static char *create_gitdir(int with_attributes) +{ + const char *tmp = getenv("TMPDIR"); + char *gitdir = xstrfmt("%s/clean-status-config.XXXXXX", + tmp ? tmp : "/tmp"); + struct strbuf path = STRBUF_INIT; + + cl_assert(mkdtemp(gitdir) != NULL); + if (with_attributes) { + strbuf_addf(&path, "%s/info", gitdir); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_addstr(&path, "/attributes"); + write_file(path.buf, "*.txt text\n"); + } + strbuf_release(&path); + return gitdir; +} + +static void remove_gitdir(char *gitdir) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addstr(&path, gitdir); + cl_assert_equal_i(remove_dir_recursively(&path, 0), 0); + strbuf_release(&path); + free(gitdir); +} + +static void clear_staged_config(void *unused UNUSED) +{ + clean_status_set_config_digest(NULL, NULL); +} +#endif + +void test_clean_status_config__attaches_only_to_the_staged_repository(void) +{ +#if !defined(O_NONBLOCK) || defined(GIT_WINDOWS_NATIVE) + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *gitdir_a = create_gitdir(1); + char *gitdir_b = create_gitdir(0); + struct repository repo_a = { + .gitdir = gitdir_a, + .commondir = gitdir_a, + .hash_algo = algo, + }; + struct repository repo_b = { + .gitdir = gitdir_b, + .commondir = gitdir_b, + .hash_algo = algo, + }; + struct index_state istate_a = INDEX_STATE_INIT(&repo_a); + struct index_state istate_b = INDEX_STATE_INIT(&repo_b); + struct clean_status_config_digest digest, replacement; + struct clean_status_state *state; + struct attr_fingerprint attrs; + + cl_set_cleanup(clear_staged_config, NULL); + digest_one(&digest, "filter.demo.clean", "cat", NULL); + digest_one(&replacement, "core.autocrlf", "false", NULL); + cl_assert_equal_i(attr_fingerprint_repository(&repo_a, &attrs), 0); + cl_assert(attrs.sources_present); + + clean_status_set_config_digest(&repo_a, &digest); + clean_status_attach_config(&istate_b); + cl_assert_equal_p(istate_b.clean_status, NULL); + clean_status_attach_config(&istate_a); + state = istate_a.clean_status; + cl_assert(state != NULL); + cl_assert(state->current_config_valid); + cl_assert(state->current_semantic_valid); + cl_assert(state->current_attr_valid); + cl_assert(state->config_enforced); + cl_assert(state->filter_configured); + cl_assert(!state->filter_scope_valid); + cl_assert(state->current_semantic_explicit); + cl_assert_equal_i(state->current_attr_sources_present, + attrs.sources_present); + cl_assert(hashes_equal(state->current_config_hash, digest.hash)); + cl_assert(hashes_equal(state->current_semantic_hash, + digest.semantic_hash)); + cl_assert(!memcmp(state->current_attr_hash, attrs.content_hash, + algo->rawsz)); + cl_assert(!memcmp(state->current_attr_namespace_hash, + attrs.namespace_hash, algo->rawsz)); + + clean_status_set_config_digest(&repo_a, &replacement); + clean_status_attach_config(&istate_a); + cl_assert(hashes_equal(state->current_config_hash, digest.hash)); + cl_assert(hashes_equal(state->current_semantic_hash, + digest.semantic_hash)); + cl_assert(state->filter_configured); + cl_assert(!state->filter_scope_valid); + + release_index(&istate_a); + cl_assert_equal_p(istate_a.clean_status, NULL); + release_index(&istate_b); + clear_staged_config(NULL); + if (repo_a.config) { + git_configset_clear(repo_a.config); + FREE_AND_NULL(repo_a.config); + } + if (repo_b.config) { + git_configset_clear(repo_b.config); + FREE_AND_NULL(repo_b.config); + } + repo_settings_clear(&repo_a); + repo_settings_clear(&repo_b); + remove_gitdir(gitdir_b); + remove_gitdir(gitdir_a); +#endif +} From ae3e36ffd700f94058b1f4f06c1a2b97f6c721ca Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:36 -0500 Subject: [PATCH 024/403] commit: stage semantic configuration before reading the index Index attachment cannot recover the configuration seen by git status or git commit if their callbacks finish without recording it. A separate configuration pass could also bind a different stream from the one that established the commands' existing behavior. Wrap each existing status or commit callback so the original callback and clean-status digest consume the same key, value, and context. Finalize and stage the digest after the existing configuration pass and before either command reads its index. Preserve determine_whence(), advice_enabled(), the original callback, configuration order, and option handling. The index-owned attachment and its existing configuration unit coverage are supplied by S08/P01; this patch adds no command-specific regression. Signed-off-by: Taylor Blau --- builtin/commit.c | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index e2f4d08b347707..29f339f89a2254 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -13,6 +13,7 @@ #include "config.h" #include "lockfile.h" #include "cache-tree.h" +#include "clean-status.h" #include "color.h" #include "dir.h" #include "editor.h" @@ -205,11 +206,34 @@ static void determine_whence(struct wt_status *s) s->whence = whence; } -static void status_init_config(struct wt_status *s, config_fn_t fn) +struct status_config_callback_data { + struct wt_status *status; + config_fn_t fn; + struct clean_status_config_digest *clean_digest; +}; + +static int status_config_callback(const char *key, const char *value, + const struct config_context *ctx, void *cb) +{ + struct status_config_callback_data *data = cb; + + clean_status_config_add(data->clean_digest, key, value, ctx); + return data->fn(key, value, ctx, data->status); +} + +static void status_init_config_with_clean_digest( + struct wt_status *s, config_fn_t fn, + struct clean_status_config_digest *clean_digest) { + struct status_config_callback_data data = { + .status = s, + .fn = fn, + .clean_digest = clean_digest, + }; + wt_status_prepare(the_repository, s); init_diff_ui_defaults(); - repo_config(the_repository, fn, s); + repo_config(the_repository, status_config_callback, &data); determine_whence(s); s->hints = advice_enabled(ADVICE_STATUS_HINTS); /* must come after repo_config() */ } @@ -1542,6 +1566,7 @@ struct repository *repo UNUSED) static int no_renames = -1; static const char *rename_score_arg = (const char *)-1; static struct wt_status s; + struct clean_status_config_digest clean_digest; unsigned int progress_flag = 0; int fd; struct object_id oid; @@ -1605,7 +1630,11 @@ struct repository *repo UNUSED) prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; - status_init_config(&s, git_status_config); + clean_status_config_init(&clean_digest, the_repository->hash_algo); + status_init_config_with_clean_digest( + &s, git_status_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); argc = parse_options(argc, argv, prefix, builtin_status_options, builtin_status_usage, 0); @@ -1703,6 +1732,7 @@ int cmd_commit(int argc, struct repository *repo UNUSED) { static struct wt_status s; + struct clean_status_config_digest clean_digest; static const char *cleanup_arg = NULL; static struct option builtin_commit_options[] = { OPT__QUIET(&quiet, N_("suppress summary after successful commit")), @@ -1807,7 +1837,11 @@ int cmd_commit(int argc, prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; - status_init_config(&s, git_commit_config); + clean_status_config_init(&clean_digest, the_repository->hash_algo); + status_init_config_with_clean_digest( + &s, git_commit_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); s.commit_template = 1; status_format = STATUS_FORMAT_NONE; /* Ignore status.short */ s.colopts = 0; From 0bbb6a9ae25b46f43bd8c7efddd929ca790a3cff Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:46:34 -0500 Subject: [PATCH 025/403] status: classify durable index source identities An index with a null trailing checksum cannot be bound to the file that was actually read unless the platform supplies a durable file identity. Treating a directory, multiply linked file, or unsupported platform as equivalent would turn identity comparison into an unwarranted correctness guarantee. Add clean_status_identity_from_stat() for single-link regular files and make clean_status_identity_is_durable() return true only on Apple platforms. Keep unsupported platforms explicitly ineligible instead of inferring durability from stat fields alone. Register the identity object and its unit suite with Make and Meson. The tests reject directories and multiply linked files, accept a single-link regular file, and check the appropriate platform result. Actual null-checksum index verification remains a separate change. Signed-off-by: Taylor Blau --- Makefile | 2 ++ clean-status-identity.c | 21 +++++++++++++++++++++ clean-status-identity.h | 16 ++++++++++++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-identity.c | 26 ++++++++++++++++++++++++++ 6 files changed, 67 insertions(+) create mode 100644 clean-status-identity.c create mode 100644 clean-status-identity.h create mode 100644 t/unit-tests/u-clean-status-identity.c diff --git a/Makefile b/Makefile index b1d8379fdfaa41..ce5d750fb0dd09 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o LIB_OBJS += column.o @@ -1553,6 +1554,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config +CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir diff --git a/clean-status-identity.c b/clean-status-identity.c new file mode 100644 index 00000000000000..1d4c71116d6600 --- /dev/null +++ b/clean-status-identity.c @@ -0,0 +1,21 @@ +#include "git-compat-util.h" +#include "clean-status-identity.h" + +int clean_status_identity_from_stat(struct clean_status_identity *identity, + const struct stat *st) +{ + memset(identity, 0, sizeof(*identity)); + if (!S_ISREG(st->st_mode) || st->st_nlink != 1) + return -1; + path_stat_identity_init(&identity->stat, st); + return 0; +} + +int clean_status_identity_is_durable(void) +{ +#ifdef __APPLE__ + return 1; +#else + return 0; +#endif +} diff --git a/clean-status-identity.h b/clean-status-identity.h new file mode 100644 index 00000000000000..b0453effcdcb91 --- /dev/null +++ b/clean-status-identity.h @@ -0,0 +1,16 @@ +#ifndef CLEAN_STATUS_IDENTITY_H +#define CLEAN_STATUS_IDENTITY_H + +#include "path-namespace.h" + +struct stat; + +struct clean_status_identity { + struct path_stat_identity stat; +}; + +int clean_status_identity_from_stat(struct clean_status_identity *identity, + const struct stat *st); +int clean_status_identity_is_durable(void); + +#endif /* CLEAN_STATUS_IDENTITY_H */ diff --git a/meson.build b/meson.build index d3b433856cb165..8f34aafa0fe276 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-identity.c', 'clean-status-manifest.c', 'color.c', 'column.c', diff --git a/t/meson.build b/t/meson.build index 7b764958d084d4..832001d876b22a 100644 --- a/t/meson.build +++ b/t/meson.build @@ -2,6 +2,7 @@ clar_test_suites = [ 'unit-tests/u-attr-fingerprint.c', 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', + 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', diff --git a/t/unit-tests/u-clean-status-identity.c b/t/unit-tests/u-clean-status-identity.c new file mode 100644 index 00000000000000..33e7b80fcfc7e7 --- /dev/null +++ b/t/unit-tests/u-clean-status-identity.c @@ -0,0 +1,26 @@ +#include "unit-test.h" +#include "clean-status-identity.h" + +void test_clean_status_identity__requires_a_single_link_regular_file(void) +{ + struct clean_status_identity identity; + struct stat st = { 0 }; + + st.st_mode = S_IFDIR | 0755; + st.st_nlink = 1; + cl_assert_equal_i(clean_status_identity_from_stat(&identity, &st), -1); + st.st_mode = S_IFREG | 0644; + st.st_nlink = 2; + cl_assert_equal_i(clean_status_identity_from_stat(&identity, &st), -1); + st.st_nlink = 1; + cl_assert_equal_i(clean_status_identity_from_stat(&identity, &st), 0); +} + +void test_clean_status_identity__durability_is_platform_specific(void) +{ +#ifdef __APPLE__ + cl_assert(clean_status_identity_is_durable()); +#else + cl_assert(!clean_status_identity_is_durable()); +#endif +} From 407fec9453e11747030384402bddf729ea0f67f2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 23:15:36 -0700 Subject: [PATCH 026/403] read-cache: bind null-checksum indexes to their source file With index.skipHash enabled, a null trailing checksum cannot prove that verify_index_from() reopened the index that do_read_index() parsed. Replacing the pathname between those operations can otherwise make an unread index appear valid. Record the identity from the index reader's existing fstat() result. When verifying a null-checksum index on an Apple platform, compare it with the identity from the verifier's existing file observation. Reject an absent, nonregular, multiply linked, or replaced identity. Leave checksummed indexes and platforms without durable identities on their existing paths. Reuse the identity classification from S08/P03 without adding an index-read system call. Register the new object and unit suite with Make and Meson; the unit test replaces the index pathname and checks the unsupported fallback. Signed-off-by: Taylor Blau --- Makefile | 2 ++ clean-status-identity.c | 6 ++++ clean-status-identity.h | 2 ++ clean-status-index.c | 29 +++++++++++++++++++ clean-status-internal.h | 3 ++ clean-status.h | 5 ++++ meson.build | 1 + read-cache.c | 4 +++ t/meson.build | 1 + t/unit-tests/u-clean-status-index.c | 43 +++++++++++++++++++++++++++++ 10 files changed, 96 insertions(+) create mode 100644 clean-status-index.c create mode 100644 t/unit-tests/u-clean-status-index.c diff --git a/Makefile b/Makefile index ce5d750fb0dd09..400b9168333827 100644 --- a/Makefile +++ b/Makefile @@ -1128,6 +1128,7 @@ LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o LIB_OBJS += clean-status-identity.o +LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o LIB_OBJS += column.o @@ -1555,6 +1556,7 @@ CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-clean-status-identity +CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir diff --git a/clean-status-identity.c b/clean-status-identity.c index 1d4c71116d6600..415ecab19a64f2 100644 --- a/clean-status-identity.c +++ b/clean-status-identity.c @@ -19,3 +19,9 @@ int clean_status_identity_is_durable(void) return 0; #endif } + +int clean_status_identity_equal(const struct clean_status_identity *a, + const struct clean_status_identity *b) +{ + return path_stat_identity_equal(&a->stat, &b->stat); +} diff --git a/clean-status-identity.h b/clean-status-identity.h index b0453effcdcb91..68e459a0349a1f 100644 --- a/clean-status-identity.h +++ b/clean-status-identity.h @@ -12,5 +12,7 @@ struct clean_status_identity { int clean_status_identity_from_stat(struct clean_status_identity *identity, const struct stat *st); int clean_status_identity_is_durable(void); +int clean_status_identity_equal(const struct clean_status_identity *a, + const struct clean_status_identity *b); #endif /* CLEAN_STATUS_IDENTITY_H */ diff --git a/clean-status-index.c b/clean-status-index.c new file mode 100644 index 00000000000000..4733e53e3a9fcc --- /dev/null +++ b/clean-status-index.c @@ -0,0 +1,29 @@ +#include "git-compat-util.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "read-cache-ll.h" + +void clean_status_record_source_identity(struct index_state *istate, + const struct stat *st) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || state->source_identity_valid || + !clean_status_identity_is_durable()) + return; + if (!clean_status_identity_from_stat(&state->source_identity, st)) + state->source_identity_valid = 1; +} + +int clean_status_verify_null_index(const struct index_state *istate, + const struct stat *st) +{ + const struct clean_status_state *state = istate->clean_status; + struct clean_status_identity identity; + + if (!state || !clean_status_identity_is_durable()) + return 1; + return state->source_identity_valid && + !clean_status_identity_from_stat(&identity, st) && + clean_status_identity_equal(&identity, &state->source_identity); +} diff --git a/clean-status-internal.h b/clean-status-internal.h index 4a04e20a18f612..9eed823928f80a 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -1,11 +1,13 @@ #ifndef CLEAN_STATUS_INTERNAL_H #define CLEAN_STATUS_INTERNAL_H +#include "clean-status-identity.h" #include "hash.h" struct index_state; struct clean_status_state { + struct clean_status_identity source_identity; unsigned char current_config_hash[GIT_MAX_RAWSZ]; unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_hash[GIT_MAX_RAWSZ]; @@ -18,6 +20,7 @@ struct clean_status_state { unsigned config_enforced : 1; unsigned filter_configured : 1; unsigned filter_scope_valid : 1; + unsigned source_identity_valid : 1; }; struct clean_status_state *clean_status_get_state(struct index_state *istate); diff --git a/clean-status.h b/clean-status.h index 7a45d2f51c395e..6054c1011da494 100644 --- a/clean-status.h +++ b/clean-status.h @@ -5,6 +5,7 @@ struct index_state; struct repository; +struct stat; void clean_status_set_config_digest( struct repository *repo, @@ -12,6 +13,10 @@ void clean_status_set_config_digest( void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); +void clean_status_record_source_identity(struct index_state *istate, + const struct stat *st); +int clean_status_verify_null_index(const struct index_state *istate, + const struct stat *st); void clean_status_release(struct index_state *istate); #endif /* CLEAN_STATUS_H */ diff --git a/meson.build b/meson.build index 8f34aafa0fe276..ca2c44295b3e5a 100644 --- a/meson.build +++ b/meson.build @@ -336,6 +336,7 @@ libgit_sources = [ 'clean-status.c', 'clean-status-config.c', 'clean-status-identity.c', + 'clean-status-index.c', 'clean-status-manifest.c', 'color.c', 'column.c', diff --git a/read-cache.c b/read-cache.c index 2b2493b45aee97..e58b29bdd142d4 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2260,6 +2260,7 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) if (fstat(fd, &st)) die_errno(_("%s: cannot stat the open index"), path); + clean_status_record_source_identity(istate, &st); mmap_size = xsize_t(st.st_size); if (mmap_size < sizeof(struct cache_header) + the_hash_algo->rawsz) @@ -2749,6 +2750,9 @@ static int verify_index_from(const struct index_state *istate, const char *path) if (st.st_size < sizeof(struct cache_header) + the_hash_algo->rawsz) goto out; + if (is_null_oid(&istate->oid) && + !clean_status_verify_null_index(istate, &st)) + goto out; n = pread_in_full(fd, hash, the_hash_algo->rawsz, st.st_size - the_hash_algo->rawsz); if (n != the_hash_algo->rawsz) diff --git a/t/meson.build b/t/meson.build index 832001d876b22a..45243e9949d383 100644 --- a/t/meson.build +++ b/t/meson.build @@ -3,6 +3,7 @@ clar_test_suites = [ 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-clean-status-identity.c', + 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c new file mode 100644 index 00000000000000..5d769692eea894 --- /dev/null +++ b/t/unit-tests/u-clean-status-index.c @@ -0,0 +1,43 @@ +#include "unit-test.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "dir.h" +#include "read-cache-ll.h" +#include "strbuf.h" +#include "wrapper.h" + +void test_clean_status_index__binds_the_parsed_source(void) +{ + const char *tmp = getenv("TMPDIR"); + char *worktree = xstrfmt("%s/status-source.XXXXXX", + tmp ? tmp : "/tmp"); + struct index_state istate = { 0 }; + struct strbuf path = STRBUF_INIT, replacement = STRBUF_INIT; + struct strbuf cleanup = STRBUF_INIT; + struct stat original, current; + + cl_assert(mkdtemp(worktree) != NULL); + strbuf_addf(&path, "%s/index", worktree); + strbuf_addf(&replacement, "%s/replacement", worktree); + write_file(path.buf, "original"); + write_file(replacement.buf, "replacement"); + cl_assert_equal_i(stat(path.buf, &original), 0); + clean_status_get_state(&istate); + clean_status_record_source_identity(&istate, &original); + cl_assert(clean_status_verify_null_index(&istate, &original)); + + cl_assert_equal_i(rename(replacement.buf, path.buf), 0); + cl_assert_equal_i(stat(path.buf, ¤t), 0); + if (clean_status_identity_is_durable()) + cl_assert(!clean_status_verify_null_index(&istate, ¤t)); + else + cl_assert(clean_status_verify_null_index(&istate, ¤t)); + + clean_status_release(&istate); + strbuf_addstr(&cleanup, worktree); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + strbuf_release(&replacement); + strbuf_release(&path); + free(worktree); +} From fcb19d8f00ebb3483c2378487209a1f8bd3f46f6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:32 -0500 Subject: [PATCH 027/403] read-cache: validate persisted fsmonitor semantic history A filesystem-monitor token does not establish that saved configuration, conversion rules, attribute inputs, or their complete manifest still describe the current index. Accepting duplicate, stale, or partially bound history could let status trust cached worktree state under different semantics. Recognize the FSCF index extension and delegate malformed-record rejection to the bounded clean-proof parser from S07/P07. Publish its token, configuration and semantic hashes, attribute hash, and manifest only after the complete record validates. Reject duplicate records, and adopt a manifest only when the current token, hashes, complete proof flags, and filter policy all agree. Record stronger semantic mismatches and withhold incoherent history. Integrate validation into post_read_index_from(), release all owned record and manifest storage with the index, and document the extension layout. Register the history object and unit suite with Make and Meson. A SHA-1 fixture rejects duplicate records; a SHA-256 fixture accepts coherent history and detects a changed semantic hash. Signed-off-by: Taylor Blau --- Documentation/gitformat-index.adoc | 30 +++++++ Makefile | 2 + clean-status-history.c | 113 ++++++++++++++++++++++++ clean-status-internal.h | 18 +++- clean-status.c | 9 +- clean-status.h | 5 ++ meson.build | 1 + read-cache.c | 5 ++ t/meson.build | 1 + t/unit-tests/u-clean-status-history.c | 120 ++++++++++++++++++++++++++ 10 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 clean-status-history.c create mode 100644 t/unit-tests/u-clean-status-history.c diff --git a/Documentation/gitformat-index.adoc b/Documentation/gitformat-index.adoc index aaa9c29b4653b8..047310ec26d105 100644 --- a/Documentation/gitformat-index.adoc +++ b/Documentation/gitformat-index.adoc @@ -379,6 +379,36 @@ The remaining data of each directory block is grouped by type: - A NUL-terminated string containing the opaque file system monitor token associated with the untracked-cache data. +== File System Monitor semantic proof + + The file system monitor semantic proof records the configuration and + attribute inputs for a completed worktree-content verification. Its + signature is { 'F', 'S', 'C', 'F' }. + + The extension consists of: + + - 32-bit version number (currently 1). + + - 32-bit magic number identifying version 1 records (`FSC1`). + + - 32-bit flags. The low four bits respectively indicate a complete + attribute manifest, a provider-token binding, a stat-data binding, and + coverage of the full index. All other bits must be zero. + + - 32-bit length of the provider token. + + - 32-bit length of the attribute manifest. + + - The provider token, without a terminating NUL. + + - Three hashes, using the index hash algorithm, over the relevant Git + configuration, semantic-conversion configuration, and attribute state. + + - The attribute manifest described by its length above. + + - A hash over all preceding bytes in this extension, using the index hash + algorithm. + == End of Index Entry The End of Index Entry (EOIE) is used to locate the end of the variable diff --git a/Makefile b/Makefile index 400b9168333827..cde58e8bd4728f 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o @@ -1555,6 +1556,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config +CLAR_TEST_SUITES += u-clean-status-history CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest diff --git a/clean-status-history.c b/clean-status-history.c new file mode 100644 index 00000000000000..f77f4294e74af3 --- /dev/null +++ b/clean-status-history.c @@ -0,0 +1,113 @@ +#include "git-compat-util.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strbuf.h" +#include "trace2.h" + +static void invalidate_disk_history(struct clean_status_state *state) +{ + state->disk_config_seen = 1; + state->disk_config_invalid = 1; + state->disk_config_valid = 0; + state->disk_semantic_valid = 0; + state->disk_attr_valid = 0; + FREE_AND_NULL(state->disk_config_token); + strbuf_reset(&state->disk_config_raw); + state->manifest.disk_valid = 0; + state->manifest.disk_flags = 0; + strbuf_reset(&state->manifest.disk); +} + +int clean_status_read_fsmonitor_config(struct index_state *istate, + const void *data, unsigned long size) +{ + struct clean_status_state *state = clean_status_get_state(istate); + struct fsmonitor_clean_proof proof; + + if (state->disk_config_seen || + fsmonitor_clean_proof_parse(&proof, data, size, + istate->repo->hash_algo) || + clean_status_manifest_load(&state->manifest, + proof.attr_manifest, + proof.attr_manifest_len, + proof.flags, + istate->repo->hash_algo)) { + invalidate_disk_history(state); + trace2_data_intmax("fsmonitor", istate->repo, + "config/invalid-extension", 1); + return 0; + } + + state->disk_config_seen = 1; + state->disk_config_token = xmemdupz(proof.token, proof.token_len); + memcpy(state->disk_config_hash, proof.config_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->disk_semantic_hash, proof.semantic_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->disk_attr_hash, proof.attr_hash, + istate->repo->hash_algo->rawsz); + strbuf_add(&state->disk_config_raw, data, size); + state->disk_config_valid = 1; + state->disk_semantic_valid = 1; + state->disk_attr_valid = 1; + return 0; +} + +void clean_status_prepare_fsmonitor_config(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + int token_coherent, config_coherent, semantic_changed, attr_changed; + int coherent; + + if (!state || !state->current_config_valid) + return; + token_coherent = state->disk_config_valid && + !state->disk_config_invalid && istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && state->disk_config_token && + !strcmp(state->disk_config_token, istate->fsmonitor_last_update); + config_coherent = state->disk_config_valid && + !memcmp(state->disk_config_hash, state->current_config_hash, + algo->rawsz); + semantic_changed = state->disk_semantic_valid && + state->current_semantic_valid && + memcmp(state->disk_semantic_hash, state->current_semantic_hash, + algo->rawsz); + attr_changed = (state->disk_attr_valid && !state->current_attr_valid) || + (state->disk_attr_valid && state->current_attr_valid && + memcmp(state->disk_attr_hash, state->current_attr_hash, + algo->rawsz)); + coherent = token_coherent && config_coherent && + state->disk_semantic_valid && state->current_semantic_valid && + !semantic_changed && state->disk_attr_valid && + state->current_attr_valid && !attr_changed && + state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL; + state->filter_scope_valid = coherent && state->filter_configured; + state->config_revalidated = coherent; + state->initial_coherent = coherent; + FREE_AND_NULL(state->config_revalidated_token); + if (coherent) { + state->config_revalidated_token = + xstrdup(istate->fsmonitor_last_update); + clean_status_manifest_adopt_disk(&state->manifest); + } + state->config_mismatch = state->config_enforced && !coherent; + state->strong_mismatch = state->config_enforced && + (state->disk_config_invalid || + semantic_changed || attr_changed || + (state->disk_config_valid && !state->current_attr_valid) || + (!state->disk_semantic_valid && + state->current_semantic_explicit) || + (!state->disk_attr_valid && + state->current_attr_sources_present) || + clean_status_filter_scope_needs_validation(istate)); + trace2_data_intmax("fsmonitor", istate->repo, + "config/coherent", coherent); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/initial-mismatch", state->strong_mismatch); +} diff --git a/clean-status-internal.h b/clean-status-internal.h index 9eed823928f80a..d4868d989b6cb1 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -2,16 +2,23 @@ #define CLEAN_STATUS_INTERNAL_H #include "clean-status-identity.h" -#include "hash.h" +#include "clean-status-manifest.h" struct index_state; struct clean_status_state { struct clean_status_identity source_identity; + struct clean_status_manifest_state manifest; + struct strbuf disk_config_raw; + char *disk_config_token; + char *config_revalidated_token; unsigned char current_config_hash[GIT_MAX_RAWSZ]; + unsigned char disk_config_hash[GIT_MAX_RAWSZ]; unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; + unsigned char disk_semantic_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned char disk_attr_hash[GIT_MAX_RAWSZ]; unsigned current_config_valid : 1; unsigned current_semantic_valid : 1; unsigned current_attr_valid : 1; @@ -20,7 +27,16 @@ struct clean_status_state { unsigned config_enforced : 1; unsigned filter_configured : 1; unsigned filter_scope_valid : 1; + unsigned config_mismatch : 1; + unsigned strong_mismatch : 1; + unsigned config_revalidated : 1; + unsigned initial_coherent : 1; unsigned source_identity_valid : 1; + unsigned disk_config_valid : 1; + unsigned disk_semantic_valid : 1; + unsigned disk_attr_valid : 1; + unsigned disk_config_seen : 1; + unsigned disk_config_invalid : 1; }; struct clean_status_state *clean_status_get_state(struct index_state *istate); diff --git a/clean-status.c b/clean-status.c index c0cfb63c407428..f1c61c4e345f80 100644 --- a/clean-status.c +++ b/clean-status.c @@ -14,8 +14,11 @@ static int configured_semantic_explicit; struct clean_status_state *clean_status_get_state(struct index_state *istate) { - if (!istate->clean_status) + if (!istate->clean_status) { CALLOC_ARRAY(istate->clean_status, 1); + clean_status_manifest_init(&istate->clean_status->manifest); + strbuf_init(&istate->clean_status->disk_config_raw, 0); + } return istate->clean_status; } @@ -78,5 +81,9 @@ void clean_status_release(struct index_state *istate) { if (!istate->clean_status) return; + clean_status_manifest_release(&istate->clean_status->manifest); + strbuf_release(&istate->clean_status->disk_config_raw); + free(istate->clean_status->disk_config_token); + free(istate->clean_status->config_revalidated_token); FREE_AND_NULL(istate->clean_status); } diff --git a/clean-status.h b/clean-status.h index 6054c1011da494..82ee75f8c52304 100644 --- a/clean-status.h +++ b/clean-status.h @@ -17,6 +17,11 @@ void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st); + +int clean_status_read_fsmonitor_config(struct index_state *istate, + const void *data, unsigned long size); +void clean_status_prepare_fsmonitor_config(struct index_state *istate); + void clean_status_release(struct index_state *istate); #endif /* CLEAN_STATUS_H */ diff --git a/meson.build b/meson.build index ca2c44295b3e5a..59b7097fca049d 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-history.c', 'clean-status-identity.c', 'clean-status-index.c', 'clean-status-manifest.c', diff --git a/read-cache.c b/read-cache.c index e58b29bdd142d4..7e769d3e4ddb5f 100644 --- a/read-cache.c +++ b/read-cache.c @@ -72,6 +72,7 @@ #define CACHE_EXT_LINK 0x6c696e6b /* "link" */ #define CACHE_EXT_UNTRACKED 0x554E5452 /* "UNTR" */ #define CACHE_EXT_FSMONITOR 0x46534D4E /* "FSMN" */ +#define CACHE_EXT_FSMONITOR_CONFIG 0x46534346 /* "FSCF" */ #define CACHE_EXT_FSMONITOR_UNTRACKED 0x46535543 /* "FSUC" */ #define CACHE_EXT_ENDOFINDEXENTRIES 0x454F4945 /* "EOIE" */ #define CACHE_EXT_INDEXENTRYOFFSETTABLE 0x49454F54 /* "IEOT" */ @@ -1779,6 +1780,9 @@ static int read_index_extension(struct index_state *istate, case CACHE_EXT_FSMONITOR: read_fsmonitor_extension(istate, data, sz); break; + case CACHE_EXT_FSMONITOR_CONFIG: + clean_status_read_fsmonitor_config(istate, data, sz); + break; case CACHE_EXT_FSMONITOR_UNTRACKED: read_fsmonitor_untracked_extension(istate, data, sz); break; @@ -1984,6 +1988,7 @@ static void post_read_index_from(struct index_state *istate) tweak_untracked_cache(istate); tweak_split_index(istate); prepare_fsmonitor_untracked(istate); + clean_status_prepare_fsmonitor_config(istate); tweak_fsmonitor(istate); } diff --git a/t/meson.build b/t/meson.build index 45243e9949d383..e4131eac00a60e 100644 --- a/t/meson.build +++ b/t/meson.build @@ -2,6 +2,7 @@ clar_test_suites = [ 'unit-tests/u-attr-fingerprint.c', 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', + 'unit-tests/u-clean-status-history.c', 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c new file mode 100644 index 00000000000000..49795b05545ecf --- /dev/null +++ b/t/unit-tests/u-clean-status-history.c @@ -0,0 +1,120 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strbuf.h" + +struct history_fixture { + struct repository repo; + struct index_state istate; + struct strbuf manifest; + struct strbuf encoded; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char attr_hash[GIT_MAX_RAWSZ]; +}; + +static void fixture_init(struct history_fixture *fixture, + const struct git_hash_algo *algo) +{ + static const unsigned char token[] = "builtin:1:2"; + struct attr_manifest_writer writer; + struct fsmonitor_clean_proof proof; + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(fixture, 0, sizeof(*fixture)); + fixture->repo.hash_algo = algo; + index_state_init(&fixture->istate, &fixture->repo); + fixture->manifest = (struct strbuf)STRBUF_INIT; + fixture->encoded = (struct strbuf)STRBUF_INIT; + memset(hash, 1, algo->rawsz); + memset(fixture->config_hash, 2, algo->rawsz); + memset(fixture->semantic_hash, 3, algo->rawsz); + memset(fixture->attr_hash, 4, algo->rawsz); + attr_manifest_writer_init(&writer, &fixture->manifest, algo); + cl_assert_equal_i(attr_manifest_writer_add( + &writer, ".gitattributes", ATTR_MANIFEST_INDEX, hash), 0); + memset(&proof, 0, sizeof(proof)); + proof.flags = FSMONITOR_CLEAN_PROOF_ALL; + proof.token = token; + proof.token_len = sizeof(token) - 1; + proof.config_hash = fixture->config_hash; + proof.semantic_hash = fixture->semantic_hash; + proof.attr_hash = fixture->attr_hash; + proof.attr_manifest = (const unsigned char *)fixture->manifest.buf; + proof.attr_manifest_len = fixture->manifest.len; + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture->encoded, &proof, algo), 0); +} + +static void fixture_release(struct history_fixture *fixture) +{ + clean_status_release(&fixture->istate); + free(fixture->istate.fsmonitor_last_update); + strbuf_release(&fixture->encoded); + strbuf_release(&fixture->manifest); +} + +static struct clean_status_state *install_current( + struct history_fixture *fixture) +{ + struct clean_status_state *state = + clean_status_get_state(&fixture->istate); + const struct git_hash_algo *algo = fixture->repo.hash_algo; + + memcpy(state->current_config_hash, fixture->config_hash, algo->rawsz); + memcpy(state->current_semantic_hash, fixture->semantic_hash, algo->rawsz); + memcpy(state->current_attr_hash, fixture->attr_hash, algo->rawsz); + state->current_config_valid = 1; + state->current_semantic_valid = 1; + state->current_attr_valid = 1; + state->config_enforced = 1; + fixture->istate.fsmonitor_last_update = xstrdup("builtin:1:2"); + fixture->istate.fsmonitor_token_valid = 1; + return state; +} + +void test_clean_status_history__reads_valid_history_once(void) +{ + struct history_fixture fixture; + struct clean_status_state *state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA1]); + cl_assert_equal_i(clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len), 0); + state = fixture.istate.clean_status; + cl_assert(state->disk_config_valid); + cl_assert(state->manifest.disk_valid); + cl_assert_equal_s(state->disk_config_token, "builtin:1:2"); + + cl_assert_equal_i(clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len), 0); + cl_assert(state->disk_config_invalid); + cl_assert(!state->disk_config_valid); + cl_assert(!state->manifest.disk_valid); + fixture_release(&fixture); +} + +void test_clean_status_history__adopts_only_coherent_proofs(void) +{ + struct history_fixture fixture; + struct clean_status_state *state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA256]); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(state->initial_coherent); + cl_assert(state->manifest.current_valid); + cl_assert_equal_i(state->manifest.current.len, fixture.manifest.len); + + state->current_semantic_hash[0] ^= 1; + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(state->strong_mismatch); + cl_assert(!state->initial_coherent); + fixture_release(&fixture); +} From 613d0c5d2e97ed9cc41c8be5d8ac812bf406dc90 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:47 -0500 Subject: [PATCH 028/403] read-cache: write only token-closed fsmonitor history Reading a validated FSCF record is not enough to preserve it during a generic index rewrite. Writing fresh token or stat bindings before the current provider token is revalidated would claim a semantic proof that the index has not established. Write a newly bound FSCF extension only when configuration, attributes, the complete manifest, the valid provider token, and its revalidated token all agree. Otherwise preserve an existing validated record with its token and stat bindings cleared; never serialize malformed or missing history. Add the extension to the existing index writer. Extend the history unit tests to distinguish closed proofs from preserved unbound manifests. Add a test-tool round trip and t7519 coverage that read, write, and reread a coherent FSCF record through a real index. Signed-off-by: Taylor Blau --- builtin/add.c | 40 ++++++++- builtin/checkout-index.c | 19 ++++- builtin/checkout.c | 79 +++++++++++++++--- builtin/describe.c | 22 ++++- builtin/reset.c | 38 ++++++++- builtin/stash.c | 27 +++++- builtin/update-index.c | 42 +++++++++- clean-status-history.c | 66 +++++++++++++++ clean-status-internal.h | 2 + clean-status.c | 20 +++++ clean-status.h | 8 ++ fsmonitor.c | 23 ++++- read-cache-ll.h | 1 + read-cache.c | 53 ++++++++++-- t/helper/test-read-cache.c | 116 ++++++++++++++++++++++++++ t/t7519-status-fsmonitor.sh | 13 +++ t/t7527-builtin-fsmonitor.sh | 1 + t/unit-tests/u-clean-status-history.c | 81 ++++++++++++++++++ 18 files changed, 621 insertions(+), 30 deletions(-) diff --git a/builtin/add.c b/builtin/add.c index 60ffbede2be58a..b13ba9580cf896 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -6,6 +6,8 @@ #include "builtin.h" #include "advice.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "lockfile.h" @@ -285,18 +287,20 @@ static struct option builtin_add_options[] = { }; static int add_config(const char *var, const char *value, - const struct config_context *ctx, void *cb) + const struct config_context *ctx, void *data) { + clean_status_config_add(data, var, value, ctx); + if (!strcmp(var, "add.ignoreerrors") || !strcmp(var, "add.ignore-errors")) { ignore_add_errors = git_config_bool(var, value); return 0; } - if (git_color_config(var, value, cb) < 0) + if (git_color_config(var, value, NULL) < 0) return -1; - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } static const char embedded_advice[] = N_( @@ -384,18 +388,25 @@ int cmd_add(int argc, const char *prefix, struct repository *repo) { + struct clean_status_config_digest clean_digest; int exit_status = 0; struct pathspec pathspec; struct dir_struct dir = DIR_INIT; int flags; int add_new_files; + int preserve_add_history = 0; int require_pathspec; char *seen = NULL; char *ps_matched = NULL; struct lock_file lock_file = LOCK_INIT; struct odb_transaction *transaction; - repo_config(repo, add_config, NULL); + show_usage_with_options_if_asked(argc, argv, + builtin_add_usage, builtin_add_options); + + clean_status_config_init(&clean_digest, repo->hash_algo); + repo_config(repo, add_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, prefix, builtin_add_options, builtin_add_usage, PARSE_OPT_KEEP_ARGV0); @@ -492,8 +503,27 @@ int cmd_add(int argc, (!(addremove || take_worktree_changes) ? ADD_CACHE_IGNORE_REMOVAL : 0)); + /* + * The refresh-only path below updates stat data and fsmonitor + * validity, but does not change the logical contents of the index. + * Ordinary add can do the same after an mtime-only change. Ask + * ADD_CACHE_TRACK_CLEAN_HISTORY to invalidate on any persistent + * add/remove decision below. + */ + if (refresh_only) { + clean_status_set_config_digest(repo, &clean_digest); + } else if (!show_only && !intent_to_add && !add_renormalize && + !chmod_arg && !include_sparse && !ignore_add_errors) { + preserve_add_history = 1; + flags |= ADD_CACHE_TRACK_CLEAN_HISTORY; + clean_status_set_config_digest(repo, &clean_digest); + } + if (repo_read_index_preload(repo, &pathspec, 0) < 0) die(_("index file corrupt")); + if (preserve_add_history && + (repo->index->split_index || repo->index->sparse_index)) + clean_status_invalidate_current_proof(repo->index); die_in_unpopulated_submodule(repo->index, prefix); die_path_inside_submodule(repo->index, &pathspec); @@ -603,6 +633,8 @@ int cmd_add(int argc, odb_transaction_commit(transaction); finish: + if (preserve_add_history && exit_status) + clean_status_invalidate_current_proof(repo->index); if (write_locked_index(repo->index, &lock_file, COMMIT_LOCK | SKIP_IF_UNCHANGED)) die(_("unable to write new index file")); diff --git a/builtin/checkout-index.c b/builtin/checkout-index.c index 311b94ff3174a6..1807696b1c92c8 100644 --- a/builtin/checkout-index.c +++ b/builtin/checkout-index.c @@ -8,6 +8,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -30,6 +32,13 @@ static char topath[4][TEMPORARY_FILENAME_LENGTH + 1]; static struct checkout state = CHECKOUT_INIT; +static int checkout_index_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + static void write_tempfile_record(const char *name, const char *prefix) { int i; @@ -215,6 +224,7 @@ int cmd_checkout_index(int argc, const char *prefix, struct repository *repo) { + struct clean_status_config_digest clean_digest; int i; struct lock_file lock_file = LOCK_INIT; int all = 0; @@ -253,7 +263,14 @@ int cmd_checkout_index(int argc, show_usage_with_options_if_asked(argc, argv, builtin_checkout_index_usage, builtin_checkout_index_options); - repo_config(repo, git_default_config, NULL); + clean_status_config_init(&clean_digest, repo->hash_algo); + repo_config(repo, checkout_index_config, &clean_digest); + clean_status_config_final(&clean_digest); + /* + * checkout-index never changes index contents. Keep closed semantic + * history attached when -u writes fresh stat data. + */ + clean_status_set_config_digest(repo, &clean_digest); prefix_length = prefix ? strlen(prefix) : 0; prepare_repo_settings(repo); diff --git a/builtin/checkout.c b/builtin/checkout.c index 55e3a89a852712..2992dfe0e99047 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -6,6 +6,8 @@ #include "branch.h" #include "cache-tree.h" #include "checkout.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "commit.h" #include "config.h" #include "diff.h" @@ -46,6 +48,7 @@ #include "add-interactive.h" struct checkout_opts { + struct clean_status_config_digest clean_digest; int patch_mode; int patch_context; int patch_interhunk_context; @@ -142,6 +145,11 @@ static int post_checkout_hook(struct commit *old_commit, struct commit *new_comm return run_hooks_opt(the_repository, "post-checkout", &opt); } +struct tree_checkout_context { + int overlay_mode; + int *index_changed; +}; + /* * Handle a tree object and determine if we need to recurse into the * tree (READ_TREE_RECURSIVE) or skip it (0). @@ -149,11 +157,12 @@ static int post_checkout_hook(struct commit *old_commit, struct commit *new_comm static int try_update_sparse_directory(const struct object_id *oid, struct strbuf *base, const char *pathname, - int overlay_mode) + struct tree_checkout_context *context) { struct strbuf dirpath = STRBUF_INIT; struct cache_entry *old; int pos, result = READ_TREE_RECURSIVE; + int overlay_mode = context ? context->overlay_mode : 1; if (!the_repository->index->sparse_index) return result; @@ -180,6 +189,8 @@ static int try_update_sparse_directory(const struct object_id *oid, * sparse directory OID directly since files not present in * the source tree should be removed anyway. */ + if (context && context->index_changed) + *context->index_changed = 1; oidcpy(&old->oid, oid); old->ce_flags |= CE_UPDATE; result = 0; @@ -196,11 +207,11 @@ static int update_some(const struct object_id *oid, struct strbuf *base, int len; struct cache_entry *ce; int pos; - int overlay_mode = context ? *((int *)context) : 1; + struct tree_checkout_context *checkout_context = context; if (S_ISDIR(mode)) return try_update_sparse_directory(oid, base, pathname, - overlay_mode); + checkout_context); len = base->len + strlen(pathname); ce = make_empty_cache_entry(the_repository->index, len); @@ -228,16 +239,23 @@ static int update_some(const struct object_id *oid, struct strbuf *base, } } + if (checkout_context && checkout_context->index_changed) + *checkout_context->index_changed = 1; add_index_entry(the_repository->index, ce, ADD_CACHE_OK_TO_ADD | ADD_CACHE_OK_TO_REPLACE); return 0; } static int read_tree_some(struct tree *tree, const struct pathspec *pathspec, - int overlay_mode) + int overlay_mode, int *index_changed) { + struct tree_checkout_context context = { + .overlay_mode = overlay_mode, + .index_changed = index_changed, + }; + read_tree(the_repository, tree, - pathspec, update_some, &overlay_mode); + pathspec, update_some, &context); /* update the index with the given tree's info * for all args, expanding wildcards, and exit @@ -420,20 +438,24 @@ static void mark_ce_for_checkout_overlay(struct cache_entry *ce, static void mark_ce_for_checkout_no_overlay(struct cache_entry *ce, char *ps_matched, - const struct checkout_opts *opts) + const struct checkout_opts *opts, + int *index_changed) { ce->ce_flags &= ~CE_MATCHED; if (!opts->ignore_skipworktree && ce_skip_worktree(ce)) return; if (ce_path_match(the_repository->index, ce, &opts->pathspec, ps_matched)) { ce->ce_flags |= CE_MATCHED; - if (opts->source_tree && !(ce->ce_flags & CE_UPDATE)) + if (opts->source_tree && !(ce->ce_flags & CE_UPDATE)) { /* - * In overlay mode, but the path is not in + * In no-overlay mode, but the path is not in * tree-ish, which means we should remove it * from the index and the working tree. */ + if (index_changed) + *index_changed = 1; ce->ce_flags |= CE_REMOVE | CE_WT_REMOVE; + } } } @@ -524,6 +546,8 @@ static int checkout_paths(const struct checkout_opts *opts, int errs = 0; struct lock_file lock_file = LOCK_INIT; int checkout_index; + int preserve_source_tree_history = 0; + int source_tree_index_changed = 0; trace2_cmd_mode(opts->patch_mode ? "patch" : "path"); @@ -628,12 +652,34 @@ static int checkout_paths(const struct checkout_opts *opts, } repo_hold_locked_index(the_repository, &lock_file, LOCK_DIE_ON_ERROR); + /* + * A plain worktree checkout from the index only rewrites stat data. + * A source-tree checkout may do the same when every selected entry + * already matches the index; if not, invalidate its proof below. + * Keep written paths fsmonitor-invalid in either case. Do not do + * this for --merge, which may recreate unmerged index entries from + * resolve undo data. + */ + preserve_source_tree_history = + opts->source_tree && opts->checkout_index && + !opts->merge && !opts->writeout_stage; + if ((opts->checkout_worktree && !opts->source_tree && + !opts->merge && !opts->writeout_stage) || + preserve_source_tree_history) + clean_status_set_config_digest(the_repository, + &opts->clean_digest); if (repo_read_index_preload(the_repository, &opts->pathspec, 0) < 0) return error(_("index file corrupt")); + if (preserve_source_tree_history && + (the_repository->index->split_index || + the_repository->index->sparse_index)) + source_tree_index_changed = 1; if (opts->source_tree) read_tree_some(opts->source_tree, &opts->pathspec, - opts->overlay_mode); + opts->overlay_mode, + preserve_source_tree_history ? + &source_tree_index_changed : NULL); if (opts->merge) unmerge_index(the_repository->index, &opts->pathspec, CE_MATCHED); @@ -651,7 +697,10 @@ static int checkout_paths(const struct checkout_opts *opts, else mark_ce_for_checkout_no_overlay(the_repository->index->cache[pos], ps_matched, - opts); + opts, + preserve_source_tree_history ? + &source_tree_index_changed : + NULL); if (report_path_error(ps_matched, &opts->pathspec)) { free(ps_matched); @@ -698,6 +747,10 @@ static int checkout_paths(const struct checkout_opts *opts, checkout_index = opts->checkout_index; if (checkout_index) { + if (preserve_source_tree_history && + (source_tree_index_changed || errs)) + clean_status_invalidate_current_proof( + the_repository->index); if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); } else { @@ -1282,6 +1335,8 @@ static int git_checkout_config(const char *var, const char *value, { struct checkout_opts *opts = cb; + clean_status_config_add(&opts->clean_digest, var, value, ctx); + if (!strcmp(var, "diff.ignoresubmodules")) { if (!value) return config_error_nonbool(var); @@ -1879,7 +1934,11 @@ static int checkout_main(int argc, const char **argv, const char *prefix, opts->prefix = prefix; opts->show_progress = -1; + show_usage_with_options_if_asked(argc, argv, usagestr, options); + + clean_status_config_init(&opts->clean_digest, the_repository->hash_algo); repo_config(the_repository, git_checkout_config, opts); + clean_status_config_final(&opts->clean_digest); if (the_repository->gitdir) { prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; diff --git a/builtin/describe.c b/builtin/describe.c index c0abc931a5948d..b39df0937ecd14 100644 --- a/builtin/describe.c +++ b/builtin/describe.c @@ -2,6 +2,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -598,11 +600,19 @@ static int option_parse_exact_match(const struct option *opt, const char *arg, return 0; } +static int describe_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + int cmd_describe(int argc, const char **argv, const char *prefix, struct repository *repo UNUSED ) { + struct clean_status_config_digest clean_digest; struct refs_for_each_ref_options for_each_ref_opts = { .flags = REFS_FOR_EACH_INCLUDE_BROKEN, }; @@ -647,7 +657,11 @@ int cmd_describe(int argc, OPT_END(), }; - repo_config(the_repository, git_default_config, NULL); + show_usage_with_options_if_asked(argc, argv, describe_usage, options); + + clean_status_config_init(&clean_digest, the_repository->hash_algo); + repo_config(the_repository, describe_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, prefix, options, describe_usage, 0); if (abbrev < 0) abbrev = DEFAULT_ABBREV; @@ -761,6 +775,12 @@ int cmd_describe(int argc, setup_work_tree(the_repository); prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; + /* + * The in-process dirty check only refreshes stat + * data before comparing the worktree with HEAD. + */ + clean_status_set_config_digest(the_repository, + &clean_digest); repo_read_index(the_repository); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED, NULL, NULL, NULL); diff --git a/builtin/reset.c b/builtin/reset.c index 78e69bd84ba2c3..a9d5183ab848d8 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -12,6 +12,8 @@ #include "builtin.h" #include "advice.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -325,12 +327,14 @@ static int reset_refs(const char *rev, const struct object_id *oid) } static int git_reset_config(const char *var, const char *value, - const struct config_context *ctx, void *cb) + const struct config_context *ctx, void *data) { + clean_status_config_add(data, var, value, ctx); + if (!strcmp(var, "submodule.recurse")) - return git_default_submodule_config(var, value, cb); + return git_default_submodule_config(var, value, NULL); - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } int cmd_reset(int argc, @@ -338,9 +342,11 @@ int cmd_reset(int argc, const char *prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; int reset_type = NONE, update_ref_status = 0, quiet = 0; int no_refresh = 0; int patch_mode = 0, pathspec_file_nul = 0, unborn; + int preserve_mixed_history = 0; const char *rev; char *pathspec_from_file = NULL; struct object_id oid; @@ -382,7 +388,11 @@ int cmd_reset(int argc, OPT_END() }; - repo_config(the_repository, git_reset_config, NULL); + show_usage_with_options_if_asked(argc, argv, git_reset_usage, options); + + clean_status_config_init(&clean_digest, the_repository->hash_algo); + repo_config(the_repository, git_reset_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, prefix, options, git_reset_usage, PARSE_OPT_KEEP_DASHDASH); @@ -477,6 +487,18 @@ int cmd_reset(int argc, if (intent_to_add && reset_type != MIXED) die(_("the option '%s' requires '%s'"), "-N", "--mixed"); + /* + * A no-path mixed reset is a candidate for a stat-only rewrite even + * when its target commit differs from HEAD. Attach history early + * enough for the initial index read, but keep it only if + * read_from_tree() confirms that no logical entries changed. + */ + if (reset_type == MIXED && !pathspec.nr && !intent_to_add && + !unborn) { + preserve_mixed_history = 1; + clean_status_set_config_digest(the_repository, &clean_digest); + } + if (repo_read_index(the_repository) < 0) die(_("index file corrupt")); @@ -496,6 +518,14 @@ int cmd_reset(int argc, update_ref_status = 1; goto cleanup; } + if (preserve_mixed_history && + (the_repository->index->split_index || + the_repository->index->sparse_index || + (the_repository->index->cache_changed & + (CE_ENTRY_CHANGED | CE_ENTRY_REMOVED | + CE_ENTRY_ADDED | RESOLVE_UNDO_CHANGED)))) + clean_status_invalidate_current_proof( + the_repository->index); the_repository->index->updated_skipworktree = 1; if (!no_refresh && repo_get_work_tree(the_repository)) { uint64_t t_begin, t_delta_in_ms; diff --git a/builtin/stash.c b/builtin/stash.c index 72c52571f8c06c..4fd7ec0c6258ad 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -2,6 +2,8 @@ #include "builtin.h" #include "abspath.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -150,6 +152,7 @@ static int show_stat = 1; static int show_patch; static int show_include_untracked; static int use_index; +static struct clean_status_config_digest stash_clean_digest; /* * w_commit is set to the commit containing the working tree @@ -975,6 +978,8 @@ static int list_stash(int argc, const char **argv, const char *prefix, static int git_stash_config(const char *var, const char *value, const struct config_context *ctx, void *cb) { + clean_status_config_add(cb, var, value, ctx); + if (!strcmp(var, "stash.showstat")) { show_stat = git_config_bool(var, value); return 0; @@ -991,7 +996,7 @@ static int git_stash_config(const char *var, const char *value, use_index = git_config_bool(var, value); return 0; } - return git_diff_basic_config(var, value, ctx, cb); + return git_diff_basic_config(var, value, ctx, NULL); } static void diff_include_untracked(const struct stash_info *info, struct diff_options *diff_opt) @@ -1671,6 +1676,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q int include_untracked, int only_staged) { int ret = 0; + int preserve_clean_history = !ps->nr && !include_untracked; struct stash_info info = STASH_INFO_INIT; struct strbuf patch = STRBUF_INIT; struct strbuf stash_msg_buf = STRBUF_INIT; @@ -1698,6 +1704,16 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q goto done; } + /* + * A clean stash push returns after its initial stat refresh. Keep + * that rewrite bound only for whole-worktree forms; paths and + * untracked discovery can change the index or its status inputs. + * If changes are found below, invalidate before the real stash + * machinery mutates the index or worktree. + */ + if (preserve_clean_history) + clean_status_set_config_digest(the_repository, + &stash_clean_digest); repo_read_index_preload(the_repository, NULL, 0); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); @@ -1728,6 +1744,8 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q printf_ln(_("No local changes to save")); goto done; } + if (preserve_clean_history) + clean_status_invalidate_current_proof(the_repository->index); if (!refs_reflog_exists(get_main_ref_store(the_repository), ref_stash) && do_clear_stash()) { ret = -1; @@ -2478,7 +2496,12 @@ int cmd_stash(int argc, const char **args_copy; int ret; - repo_config(the_repository, git_stash_config, NULL); + show_usage_with_options_if_asked(argc, argv, git_stash_usage, options); + + clean_status_config_init(&stash_clean_digest, + the_repository->hash_algo); + repo_config(the_repository, git_stash_config, &stash_clean_digest); + clean_status_config_final(&stash_clean_digest); argc = parse_options(argc, argv, prefix, options, git_stash_usage, PARSE_OPT_SUBCOMMAND_OPTIONAL | diff --git a/builtin/update-index.c b/builtin/update-index.c index 241abd4332dcf9..b8b565f0d6f632 100644 --- a/builtin/update-index.c +++ b/builtin/update-index.c @@ -8,6 +8,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -54,6 +56,28 @@ static int ignore_skip_worktree_entries; #define UNMARK_FLAG 2 static struct strbuf mtime_dir = STRBUF_INIT; +static int update_index_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + +static int is_proof_preserving_rewrite(int argc, const char **argv) +{ + if (argc == 2) + return !strcmp(argv[1], "--refresh") || + !strcmp(argv[1], "--force-write-index"); + + if (argc != 3) + return 0; + + return (!strcmp(argv[1], "--refresh") && + !strcmp(argv[2], "--force-write-index")) || + (!strcmp(argv[1], "--force-write-index") && + !strcmp(argv[2], "--refresh")); +} + /* Untracked cache mode */ enum uc_mode { UC_UNSPECIFIED = -1, @@ -917,6 +941,7 @@ int cmd_update_index(int argc, const char *prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; int newfd, entries, has_errors = 0, nul_term_line = 0; enum uc_mode untracked_cache = UC_UNSPECIFIED; int read_from_stdin = 0; @@ -932,6 +957,8 @@ int cmd_update_index(int argc, struct parse_opt_ctx_t ctx; strbuf_getline_fn getline_fn; int parseopt_state = PARSE_OPT_UNKNOWN; + int preserve_clean_history = + is_proof_preserving_rewrite(argc, argv); struct repository *r = the_repository; struct odb_transaction *transaction; struct option options[] = { @@ -1097,7 +1124,20 @@ int cmd_update_index(int argc, show_usage_with_options_if_asked(argc, argv, update_index_usage, options); - repo_config(the_repository, git_default_config, NULL); + if (preserve_clean_history) { + clean_status_config_init(&clean_digest, + the_repository->hash_algo); + repo_config(the_repository, update_index_config, + &clean_digest); + clean_status_config_final(&clean_digest); + /* + * These exact forms can refresh stat data, or no data at all, + * but cannot change the logical contents of the index. + */ + clean_status_set_config_digest(the_repository, &clean_digest); + } else { + repo_config(the_repository, git_default_config, NULL); + } prepare_repo_settings(r); the_repository->settings.command_requires_full_index = 0; diff --git a/clean-status-history.c b/clean-status-history.c index f77f4294e74af3..e6cdfc8f549826 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -111,3 +111,69 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) trace2_data_intmax("fsmonitor", istate->repo, "semantic/initial-mismatch", state->strong_mismatch); } + +static int current_proof_is_writable(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + state->config_enforced && state->current_config_valid && + state->current_semantic_valid && state->current_attr_valid && + state->manifest.current_valid && + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + !clean_status_filter_scope_needs_validation(istate) && + clean_status_revalidated_token_matches(istate); +} + +void clean_status_advance_fsmonitor_config_token( + struct index_state *istate, const char *next_token) +{ + struct clean_status_state *state = istate->clean_status; + + if (!next_token || !current_proof_is_writable(istate)) + return; + FREE_AND_NULL(state->config_revalidated_token); + state->config_revalidated_token = xstrdup(next_token); + trace2_data_intmax("fsmonitor", istate->repo, + "config/token-advanced", 1); +} + +int clean_status_should_write_fsmonitor_config( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return current_proof_is_writable(istate) || + (state && state->disk_config_valid && + !state->disk_config_invalid && state->disk_config_raw.len); +} + +void clean_status_write_fsmonitor_config(struct strbuf *out, + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + + if (current_proof_is_writable(istate)) { + struct fsmonitor_clean_proof proof = { + .flags = state->manifest.current_flags, + .token = (const unsigned char *)istate->fsmonitor_last_update, + .token_len = strlen(istate->fsmonitor_last_update), + .config_hash = state->current_config_hash, + .semantic_hash = state->current_semantic_hash, + .attr_hash = state->current_attr_hash, + .attr_manifest = + (const unsigned char *)state->manifest.current.buf, + .attr_manifest_len = state->manifest.current.len, + }; + + if (fsmonitor_clean_proof_write(out, &proof, algo)) + BUG("cannot serialize validated fsmonitor clean proof"); + return; + } + if (fsmonitor_clean_proof_copy_without_bindings( + out, state->disk_config_raw.buf, state->disk_config_raw.len, algo)) + BUG("cannot preserve validated fsmonitor clean proof"); +} diff --git a/clean-status-internal.h b/clean-status-internal.h index d4868d989b6cb1..9ea64b13685fdc 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -40,5 +40,7 @@ struct clean_status_state { }; struct clean_status_state *clean_status_get_state(struct index_state *istate); +int clean_status_revalidated_token_matches( + const struct index_state *istate); #endif /* CLEAN_STATUS_INTERNAL_H */ diff --git a/clean-status.c b/clean-status.c index f1c61c4e345f80..797d4730c1681f 100644 --- a/clean-status.c +++ b/clean-status.c @@ -77,6 +77,26 @@ int clean_status_filter_scope_needs_validation( state->filter_configured && !state->filter_scope_valid; } +int clean_status_revalidated_token_matches(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->config_revalidated && + state->config_revalidated_token && + istate->fsmonitor_last_update && + !strcmp(state->config_revalidated_token, + istate->fsmonitor_last_update); +} + +void clean_status_invalidate_current_proof(struct index_state *istate) +{ + if (!istate->clean_status) + return; + istate->clean_status->config_revalidated = 0; + istate->clean_status->initial_coherent = 0; + istate->clean_status->filter_scope_valid = 0; +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index 82ee75f8c52304..6c6db51b86a229 100644 --- a/clean-status.h +++ b/clean-status.h @@ -6,6 +6,7 @@ struct index_state; struct repository; struct stat; +struct strbuf; void clean_status_set_config_digest( struct repository *repo, @@ -21,6 +22,13 @@ int clean_status_verify_null_index(const struct index_state *istate, int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); void clean_status_prepare_fsmonitor_config(struct index_state *istate); +void clean_status_invalidate_current_proof(struct index_state *istate); +void clean_status_advance_fsmonitor_config_token( + struct index_state *istate, const char *next_token); +int clean_status_should_write_fsmonitor_config( + const struct index_state *istate); +void clean_status_write_fsmonitor_config(struct strbuf *out, + const struct index_state *istate); void clean_status_release(struct index_state *istate); diff --git a/fsmonitor.c b/fsmonitor.c index 94ccbceba7c307..9e90d158402fdd 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -3,6 +3,7 @@ #include "git-compat-util.h" #include "attr.h" +#include "clean-status.h" #include "config.h" #include "dir.h" #include "environment.h" @@ -636,6 +637,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) { int len = strlen(name); int pos; + int attributes_may_have_changed; size_t nr_in_cone; trace_printf_key(&trace_fsmonitor, @@ -645,6 +647,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) if (!strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE)) { unsigned int i; + clean_status_invalidate_current_proof(istate); git_attr_invalidate_all(); untracked_cache_invalidate_all(istate); for (i = 0; i < istate->cache_nr; i++) @@ -655,12 +658,15 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) return; } pos = index_name_pos(istate, name, len); - fsmonitor_invalidate_attributes_path(istate, name); + attributes_may_have_changed = + fsmonitor_invalidate_attributes_path(istate, name); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); else nr_in_cone = handle_path_without_trailing_slash(istate, name, pos); + if (pos < 0 && nr_in_cone) + attributes_may_have_changed = 1; /* * If we did not find an exact match for this pathname or any @@ -670,10 +676,15 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) */ if (!nr_in_cone && repo_ignore_case(the_repository)) { nr_in_cone = handle_using_name_hash_icase(istate, name); - if (!nr_in_cone) + if (!nr_in_cone) { nr_in_cone = handle_using_dir_name_hash_icase( istate, name); + if (nr_in_cone) + attributes_may_have_changed = 1; + } } + if (attributes_may_have_changed) + clean_status_invalidate_current_proof(istate); if (nr_in_cone) trace_printf_key(&trace_fsmonitor, @@ -1137,6 +1148,14 @@ void refresh_fsmonitor(struct index_state *istate) (fsm_mode == FSMONITOR_MODE_IPC || !is_trivial); istate->fsmonitor_untracked_valid = 0; } else { + /* + * The applied delta carries an existing proof forward: + * tracked paths are now invalid in FSMN, while semantic + * events have already expired the proof itself. + */ + if (fsm_mode == FSMONITOR_MODE_IPC) + clean_status_advance_fsmonitor_config_token( + istate, last_update_token.buf); FREE_AND_NULL(istate->fsmonitor_last_update); istate->fsmonitor_last_update = strbuf_detach(&last_update_token, NULL); diff --git a/read-cache-ll.h b/read-cache-ll.h index 943ccc296a71d6..53d0d0c0bfc9f6 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -412,6 +412,7 @@ int remove_file_from_index(struct index_state *, const char *path); #define ADD_CACHE_IGNORE_ERRORS 4 #define ADD_CACHE_IGNORE_REMOVAL 8 #define ADD_CACHE_INTENT 16 +#define ADD_CACHE_TRACK_CLEAN_HISTORY 32 /* * These two are used to add the contents of the file at path * to the index, marking the working tree up-to-date by storing diff --git a/read-cache.c b/read-cache.c index 7e769d3e4ddb5f..be047c466ee740 100644 --- a/read-cache.c +++ b/read-cache.c @@ -729,6 +729,20 @@ static struct cache_entry *create_alias_ce(struct index_state *istate, return new_entry; } +static int same_persistent_add_entry(const struct cache_entry *a, + const struct cache_entry *b) +{ + const unsigned int flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + + return a && b && + ce_namelen(a) == ce_namelen(b) && + !memcmp(a->name, b->name, ce_namelen(a)) && + a->ce_mode == b->ce_mode && + oideq(&a->oid, &b->oid) && + ((a->ce_flags ^ b->ce_flags) & flags) == 0; +} + void set_object_name_for_intent_to_add_entry(struct cache_entry *ce) { struct object_id oid; @@ -739,7 +753,8 @@ void set_object_name_for_intent_to_add_entry(struct cache_entry *ce) int add_to_index(struct index_state *istate, const char *path, struct stat *st, int flags) { - int namelen, was_same; + int namelen, was_same, logical_same; + int cache_nr = istate->cache_nr; mode_t st_mode = st->st_mode; struct cache_entry *ce, *alias = NULL; unsigned ce_option = CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE|CE_MATCH_RACY_IS_DIRTY; @@ -824,12 +839,22 @@ int add_to_index(struct index_state *istate, const char *path, struct stat *st, !ce_stage(alias) && oideq(&alias->oid, &ce->oid) && ce->ce_mode == alias->ce_mode); + logical_same = same_persistent_add_entry(alias, ce); + + if (!pretend && (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && + !logical_same) + clean_status_invalidate_current_proof(istate); if (pretend) discard_cache_entry(ce); - else if (add_index_entry(istate, ce, add_option)) { - discard_cache_entry(ce); - return error(_("unable to add '%s' to index"), path); + else { + if (add_index_entry(istate, ce, add_option)) { + discard_cache_entry(ce); + return error(_("unable to add '%s' to index"), path); + } + if ((flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && + cache_nr != istate->cache_nr) + clean_status_invalidate_current_proof(istate); } if (verbose && !was_same) printf("add '%s'\n", path); @@ -2840,6 +2865,7 @@ enum write_extensions { WRITE_RESOLVE_UNDO_EXTENSION = 1<<2, WRITE_UNTRACKED_CACHE_EXTENSION = 1<<3, WRITE_FSMONITOR_EXTENSION = 1<<4, + WRITE_FSCF_EXTENSION = 1<<5, }; #define WRITE_ALL_EXTENSIONS ((enum write_extensions)-1) @@ -3107,6 +3133,19 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, goto out; } } + if (write_extensions & WRITE_FSCF_EXTENSION && + clean_status_should_write_fsmonitor_config(istate)) { + strbuf_reset(&sb); + clean_status_write_fsmonitor_config(&sb, istate); + err = write_index_ext_header(f, eoie_c, + CACHE_EXT_FSMONITOR_CONFIG, + sb.len) < 0; + hashwrite(f, sb.buf, sb.len); + if (err) { + ret = -1; + goto out; + } + } if (istate->sparse_index) { if (write_index_ext_header(f, eoie_c, CACHE_EXT_SPARSE_DIRECTORIES, 0) < 0) { ret = -1; @@ -4064,8 +4103,12 @@ static void update_callback(struct diff_queue_struct *q, case DIFF_STATUS_DELETED: if (data->flags & ADD_CACHE_IGNORE_REMOVAL) break; - if (!(data->flags & ADD_CACHE_PRETEND)) + if (!(data->flags & ADD_CACHE_PRETEND)) { + if (data->flags & ADD_CACHE_TRACK_CLEAN_HISTORY) + clean_status_invalidate_current_proof( + data->index); remove_file_from_index(data->index, path); + } if (data->flags & (ADD_CACHE_PRETEND|ADD_CACHE_VERBOSE)) printf(_("remove '%s'\n"), path); break; diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 372b55b419d6b4..5228c2065e4404 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -2,13 +2,19 @@ #include "test-tool.h" #include "attr.h" +#include "attr-fingerprint.h" +#include "attr-manifest.h" +#include "clean-status.h" +#include "clean-status-internal.h" #include "config.h" #include "dir.h" #include "environment.h" #include "ewah/ewok.h" #include "ewah/ewok_rlw.h" #include "fsmonitor.h" +#include "fsmonitor-clean-proof.h" #include "fsmonitor-ll.h" +#include "lockfile.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" @@ -237,6 +243,114 @@ static int test_fsmn_parser(void) return 0; } +static int write_test_index(void) +{ + struct lock_file index_lock = LOCK_INIT; + + repo_hold_locked_index(the_repository, &index_lock, LOCK_DIE_ON_ERROR); + if (write_locked_index(the_repository->index, &index_lock, COMMIT_LOCK)) + return error("unable to write test index"); + return 0; +} + +static int test_fscf_history_is_coherent(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->disk_config_valid && + !state->disk_config_invalid && state->disk_semantic_valid && + state->disk_attr_valid && state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + state->disk_config_raw.len && state->initial_coherent; +} + +static int test_fscf_config(const char *key, const char *value, + const struct config_context *ctx, void *cb) +{ + struct clean_status_config_digest *config = cb; + + clean_status_config_add(config, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + +static int test_fscf_history(void) +{ + struct clean_status_config_digest config; + struct attr_fingerprint attrs; + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + struct strbuf encoded = STRBUF_INIT; + unsigned char index_hash[GIT_MAX_RAWSZ] = { 0 }; + const char *token; + struct fsmonitor_clean_proof proof = { + .flags = FSMONITOR_CLEAN_PROOF_ALL, + }; + const struct git_hash_algo *algo; + int ret = 1; + + setup_git_directory(the_repository); + algo = the_repository->hash_algo; + clean_status_config_init(&config, algo); + repo_config(the_repository, test_fscf_config, &config); + clean_status_config_final(&config); + clean_status_set_config_digest(the_repository, &config); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + token = "fscf-test-token"; + if (attr_fingerprint_repository(the_repository, &attrs)) + return error("unable to fingerprint attribute sources"); + + attr_manifest_writer_init(&writer, &manifest, algo); + if (attr_manifest_writer_add(&writer, ".gitattributes", + ATTR_MANIFEST_INDEX, index_hash)) + return error("unable to write test attribute manifest"); + proof.config_hash = config.hash; + proof.semantic_hash = config.semantic_hash; + proof.attr_hash = attrs.content_hash; + proof.token = (const unsigned char *)token; + proof.token_len = strlen(token); + proof.attr_manifest = (const unsigned char *)manifest.buf; + proof.attr_manifest_len = manifest.len; + if (fsmonitor_clean_proof_write(&encoded, &proof, algo)) + return error("unable to write test clean proof"); + + FREE_AND_NULL(the_repository->index->fsmonitor_last_update); + the_repository->index->fsmonitor_last_update = xstrdup(token); + the_repository->index->fsmonitor_token_valid = 1; + clean_status_read_fsmonitor_config(the_repository->index, + encoded.buf, encoded.len); + clean_status_prepare_fsmonitor_config(the_repository->index); + if (!test_fscf_history_is_coherent(the_repository->index)) + return error("test clean proof was not coherent"); + if (write_test_index()) + goto done; + + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) + return error("unable to reread test index"); + if (!test_fscf_history_is_coherent(the_repository->index)) + return error("FSCF did not survive an index round trip"); + + clean_status_invalidate_current_manifest(the_repository->index); + if (write_test_index()) + goto done; + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) + return error("unable to reread preserved test index"); + if (clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index)) + return error("generic rewrite retained FSCF epoch bindings"); + if (!clean_status_has_worktree_manifest_history(the_repository->index)) + return error("generic rewrite discarded FSCF manifest history"); + ret = 0; + +done: + strbuf_release(&encoded); + strbuf_release(&manifest); + return ret; +} + static int test_fsmonitor_directory_attributes(void) { struct attr_check *check; @@ -288,6 +402,8 @@ int cmd__read_cache(int argc, const char **argv) return test_fsuc_parser(); if (argc == 2 && !strcmp(argv[1], "--test-fsmn-parser")) return test_fsmn_parser(); + if (argc == 2 && !strcmp(argv[1], "--test-fscf-round-trip")) + return test_fscf_history(); if (argc == 2 && !strcmp(argv[1], "--test-fsmonitor-directory-attributes")) return test_fsmonitor_directory_attributes(); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 6b1fdd3bcbbc6f..90228af9d007d2 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3,6 +3,7 @@ test_description='git status with file system watcher' . ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh # Note, after "git reset --hard HEAD" no extensions exist other than 'TREE' # "git update-index --fsmonitor" can be used to get the extension written @@ -68,6 +69,18 @@ test_expect_success 'FSUC parser fails closed' ' test-tool read-cache --test-fsuc-parser ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'FSCF survives index I/O and generic rewrites' ' + test_when_finished "rm -rf fscf-round-trip" && + test_create_repo fscf-round-trip && + ( + cd fscf-round-trip && + test_commit base tracked && + test-tool read-cache --test-fscf-round-trip && + test_grep FSCF .git/index + ) +' + test_expect_success 'hook parser ignores empty path records' ' test_when_finished "rm -rf empty-hook-record" && test_create_repo empty-hook-record && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index dd9badbff281a7..a72cd29af06487 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -3,6 +3,7 @@ test_description='built-in file system watcher' . ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh if ! test_have_prereq FSMONITOR_DAEMON then diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 49795b05545ecf..1f3072b2a239cf 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -118,3 +118,84 @@ void test_clean_status_history__adopts_only_coherent_proofs(void) cl_assert(!state->initial_coherent); fixture_release(&fixture); } + +void test_clean_status_history__preserves_unbound_manifests(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct history_fixture fixture; + struct clean_status_state *state; + struct fsmonitor_clean_proof parsed; + struct strbuf rewritten = STRBUF_INIT; + + fixture_init(&fixture, algo); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(clean_status_should_write_fsmonitor_config(&fixture.istate)); + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, FSMONITOR_CLEAN_PROOF_ALL); + + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:3"); + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:2"); + state->current_config_valid = 0; + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + strbuf_release(&rewritten); + fixture_release(&fixture); +} + +void test_clean_status_history__advances_only_current_proofs(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct history_fixture fixture; + struct clean_status_state *state; + struct fsmonitor_clean_proof parsed; + struct strbuf rewritten = STRBUF_INIT; + + fixture_init(&fixture, algo); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + + clean_status_advance_fsmonitor_config_token( + &fixture.istate, "builtin:1:3"); + cl_assert_equal_s(state->config_revalidated_token, "builtin:1:3"); + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:3"); + cl_assert(clean_status_should_write_fsmonitor_config(&fixture.istate)); + + clean_status_invalidate_current_proof(&fixture.istate); + cl_assert(!state->config_revalidated); + cl_assert(!state->initial_coherent); + clean_status_advance_fsmonitor_config_token( + &fixture.istate, "builtin:1:4"); + cl_assert_equal_s(state->config_revalidated_token, "builtin:1:3"); + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:4"); + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + + strbuf_release(&rewritten); + fixture_release(&fixture); +} From 6bbc3c02fd9a6617888acc646c55f57553fe6a66 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:53:53 -0700 Subject: [PATCH 029/403] read-cache: preserve validated fsmonitor history across indexes move_index_extensions() transfers extensions to a replacement index, but index-owned FSCF history would otherwise remain on the old state. A generic rewrite could silently discard a validated manifest, while sharing its storage would create a lifetime hazard. Copy only a parsed, valid serialized record into independently owned destination storage. Reload the saved manifest through its validated parser, copy the existing token and hashes, and leave an absent or invalid source untouched. Invoke the transfer from move_index_extensions() so ordinary index release owns each copy. Extend the existing history unit suite with a real extension transfer. Verify the copied record and manifest, invalidate the source, reject a second transfer from that source, and confirm that the independent first destination remains valid. Signed-off-by: Taylor Blau --- builtin/checkout.c | 8 +++ builtin/read-tree.c | 51 ++++++++++++-- builtin/reset.c | 15 +++-- clean-status-history.c | 95 +++++++++++++++++++++++++++ clean-status.h | 4 ++ read-cache.c | 1 + t/unit-tests/u-clean-status-history.c | 40 ++++++++++- unpack-trees.c | 4 ++ 8 files changed, 203 insertions(+), 15 deletions(-) diff --git a/builtin/checkout.c b/builtin/checkout.c index 2992dfe0e99047..c18b8ce85f2a51 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -899,6 +899,14 @@ static int merge_working_tree(const struct checkout_opts *opts, struct tree *new_tree; repo_hold_locked_index(the_repository, &lock_file, LOCK_DIE_ON_ERROR); + /* + * A discarding switch may rewrite only worktree/stat state when the + * target tree matches the index. Let unpack_trees() transfer the + * proof only after it proves that the rebuilt index is identical. + */ + if (opts->discard_changes) + clean_status_set_config_digest(the_repository, + &opts->clean_digest); if (repo_read_index_preload(the_repository, NULL, 0) < 0) { rollback_lock_file(&lock_file); return error(_("index file corrupt")); diff --git a/builtin/read-tree.c b/builtin/read-tree.c index 999a82ecdfd737..8e3b023271723c 100644 --- a/builtin/read-tree.c +++ b/builtin/read-tree.c @@ -5,6 +5,8 @@ */ #define USE_THE_REPOSITORY_VARIABLE #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -46,10 +48,11 @@ static const char * const read_tree_usage[] = { NULL }; -static int index_output_cb(const struct option *opt UNUSED, const char *arg, +static int index_output_cb(const struct option *opt, const char *arg, int unset) { BUG_ON_OPT_NEG(unset); + *(int *)opt->value = 1; set_alternate_index_output(arg); return 0; } @@ -100,12 +103,14 @@ static int debug_merge(const struct cache_entry * const *stages, } static int git_read_tree_config(const char *var, const char *value, - const struct config_context *ctx, void *cb) + const struct config_context *ctx, void *data) { + clean_status_config_add(data, var, value, ctx); + if (!strcmp(var, "submodule.recurse")) - return git_default_submodule_config(var, value, cb); + return git_default_submodule_config(var, value, NULL); - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } int cmd_read_tree(int argc, @@ -113,7 +118,10 @@ int cmd_read_tree(int argc, const char *cmd_prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; int i, stage = 0; + int index_output = 0; + int preserve_history = 0; struct object_id oid; struct tree_desc t[MAX_UNPACK_TREES]; struct unpack_trees_options opts; @@ -121,7 +129,7 @@ int cmd_read_tree(int argc, struct lock_file lock_file = LOCK_INIT; const struct option read_tree_options[] = { OPT__SUPER_PREFIX(&opts.super_prefix), - OPT_CALLBACK_F(0, "index-output", NULL, N_("file"), + OPT_CALLBACK_F(0, "index-output", &index_output, N_("file"), N_("write resulting index to "), PARSE_OPT_NONEG, index_output_cb), OPT_BOOL(0, "empty", &read_empty, @@ -169,7 +177,12 @@ int cmd_read_tree(int argc, opts.src_index = the_repository->index; opts.dst_index = the_repository->index; - repo_config(the_repository, git_read_tree_config, NULL); + show_usage_with_options_if_asked(argc, argv, + read_tree_usage, read_tree_options); + + clean_status_config_init(&clean_digest, the_repository->hash_algo); + repo_config(the_repository, git_read_tree_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, cmd_prefix, read_tree_options, read_tree_usage, 0); @@ -190,6 +203,23 @@ int cmd_read_tree(int argc, repo_hold_locked_index(the_repository, &lock_file, LOCK_DIE_ON_ERROR); + /* + * A one-tree merge or reset can rewrite only stat and fsmonitor + * state when its tree matches the existing index. Attach history + * before reading that index; unpack_trees() will transfer it only + * after proving that the result has the same logical entries. + */ + if (argc == 1 && !read_empty && !opts.prefix && + (opts.reset || opts.merge) && + !opts.dry_run && + !opts.skip_sparse_checkout && !opts.internal.debug_unpack && + !opts.trivial_merges_only && !opts.aggressive && + !opts.super_prefix && !index_output && + !should_update_submodules()) { + preserve_history = 1; + clean_status_set_config_digest(the_repository, &clean_digest); + } + /* * NEEDSWORK * @@ -200,10 +230,17 @@ int cmd_read_tree(int argc, */ if (opts.reset || opts.merge || opts.prefix) { - if (repo_read_index_unmerged(the_repository) && (opts.prefix || opts.merge)) + int unmerged = repo_read_index_unmerged(the_repository); + + if (preserve_history && unmerged) + clean_status_invalidate_current_proof( + the_repository->index); + if (unmerged && (opts.prefix || opts.merge)) die(_("You need to resolve your current index first")); stage = opts.merge = 1; } + if (preserve_history && the_repository->index->resolve_undo) + clean_status_invalidate_current_proof(the_repository->index); resolve_undo_clear_index(the_repository->index); for (i = 0; i < argc; i++) { diff --git a/builtin/reset.c b/builtin/reset.c index a9d5183ab848d8..20a81a249ad472 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -488,14 +488,15 @@ int cmd_reset(int argc, die(_("the option '%s' requires '%s'"), "-N", "--mixed"); /* - * A no-path mixed reset is a candidate for a stat-only rewrite even - * when its target commit differs from HEAD. Attach history early - * enough for the initial index read, but keep it only if - * read_from_tree() confirms that no logical entries changed. + * A no-path mixed or hard reset is a candidate for a stat-only + * rewrite even when its target commit differs from HEAD. Attach + * history early enough for the initial index read. Mixed reset + * checks its in-place result below; hard reset lets unpack_trees() + * transfer only an equal logical index. */ - if (reset_type == MIXED && !pathspec.nr && !intent_to_add && - !unborn) { - preserve_mixed_history = 1; + if ((reset_type == MIXED || reset_type == HARD) && + !pathspec.nr && !intent_to_add && !unborn) { + preserve_mixed_history = reset_type == MIXED; clean_status_set_config_digest(the_repository, &clean_digest); } diff --git a/clean-status-history.c b/clean-status-history.c index e6cdfc8f549826..6369472b287ac8 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -177,3 +177,98 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, out, state->disk_config_raw.buf, state->disk_config_raw.len, algo)) BUG("cannot preserve validated fsmonitor clean proof"); } + +void clean_status_copy_fsmonitor_history(struct index_state *dst, + const struct index_state *src) +{ + const struct clean_status_state *src_state = src->clean_status; + struct clean_status_state *dst_state; + + if (!src_state || !src_state->disk_config_valid || + src_state->disk_config_invalid || !src_state->disk_config_raw.len) + return; + dst_state = clean_status_get_state(dst); + FREE_AND_NULL(dst_state->disk_config_token); + strbuf_reset(&dst_state->disk_config_raw); + dst_state->disk_config_token = + xstrdup_or_null(src_state->disk_config_token); + strbuf_addbuf(&dst_state->disk_config_raw, + &src_state->disk_config_raw); + memcpy(dst_state->disk_config_hash, src_state->disk_config_hash, + dst->repo->hash_algo->rawsz); + memcpy(dst_state->disk_semantic_hash, src_state->disk_semantic_hash, + dst->repo->hash_algo->rawsz); + memcpy(dst_state->disk_attr_hash, src_state->disk_attr_hash, + dst->repo->hash_algo->rawsz); + if (clean_status_manifest_load( + &dst_state->manifest, src_state->manifest.disk.buf, + src_state->manifest.disk.len, src_state->manifest.disk_flags, + dst->repo->hash_algo)) + BUG("cannot copy validated clean-status manifest"); + dst_state->disk_config_seen = 1; + dst_state->disk_config_valid = 1; + dst_state->disk_semantic_valid = src_state->disk_semantic_valid; + dst_state->disk_attr_valid = src_state->disk_attr_valid; + dst_state->disk_config_invalid = 0; +} + +static int same_persistent_index_contents(const struct index_state *a, + const struct index_state *b) +{ + const unsigned int semantic_flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + const unsigned int transient_flags = + CE_UPDATE | CE_REMOVE | CE_ADDED | CE_WT_REMOVE | + CE_CONFLICTED | CE_UNPACKED | CE_NEW_SKIP_WORKTREE | + CE_MATCHED | CE_STRIP_NAME; + unsigned int i; + + if (a->repo != b->repo || a->split_index || b->split_index || + a->sparse_index || b->sparse_index || + a->cache_nr != b->cache_nr) + return 0; + + for (i = 0; i < a->cache_nr; i++) { + const struct cache_entry *ce_a = a->cache[i]; + const struct cache_entry *ce_b = b->cache[i]; + + if (ce_namelen(ce_a) != ce_namelen(ce_b) || + memcmp(ce_a->name, ce_b->name, ce_namelen(ce_a) + 1) || + ce_a->ce_mode != ce_b->ce_mode || + !oideq(&ce_a->oid, &ce_b->oid) || + ((ce_a->ce_flags ^ ce_b->ce_flags) & semantic_flags) || + ((ce_a->ce_flags | ce_b->ce_flags) & transient_flags)) + return 0; + } + + return 1; +} + +int clean_status_transfer_current_proof_if_same_index( + struct index_state *dst, const struct index_state *src) +{ + struct strbuf proof = STRBUF_INIT; + int transferred; + + if (!current_proof_is_writable(src) || + !src->fsmonitor_last_update || + !dst->fsmonitor_last_update || + strcmp(src->fsmonitor_last_update, dst->fsmonitor_last_update) || + !same_persistent_index_contents(dst, src)) + return 0; + + /* + * Reparse the current proof as the destination's disk proof, then + * reattach the current command's digest. This copies only a proof + * which the destination's identical logical entries can support. + */ + clean_status_write_fsmonitor_config(&proof, src); + dst->fsmonitor_token_valid = src->fsmonitor_token_valid; + clean_status_read_fsmonitor_config(dst, proof.buf, proof.len); + clean_status_attach_config(dst); + clean_status_prepare_fsmonitor_config(dst); + transferred = current_proof_is_writable(dst); + strbuf_release(&proof); + + return transferred; +} diff --git a/clean-status.h b/clean-status.h index 6c6db51b86a229..02e3d6022ae2c3 100644 --- a/clean-status.h +++ b/clean-status.h @@ -29,6 +29,10 @@ int clean_status_should_write_fsmonitor_config( const struct index_state *istate); void clean_status_write_fsmonitor_config(struct strbuf *out, const struct index_state *istate); +void clean_status_copy_fsmonitor_history(struct index_state *dst, + const struct index_state *src); +int clean_status_transfer_current_proof_if_same_index( + struct index_state *dst, const struct index_state *src); void clean_status_release(struct index_state *istate); diff --git a/read-cache.c b/read-cache.c index be047c466ee740..e0a5fcfccabd0e 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3600,6 +3600,7 @@ void *read_blob_data_from_index(struct index_state *istate, void move_index_extensions(struct index_state *dst, struct index_state *src) { + clean_status_copy_fsmonitor_history(dst, src); dst->untracked = src->untracked; src->untracked = NULL; dst->cache_tree = src->cache_tree; diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 1f3072b2a239cf..4a39bfa8568811 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -159,7 +159,6 @@ void test_clean_status_history__preserves_unbound_manifests(void) strbuf_release(&rewritten); fixture_release(&fixture); } - void test_clean_status_history__advances_only_current_proofs(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -199,3 +198,42 @@ void test_clean_status_history__advances_only_current_proofs(void) strbuf_release(&rewritten); fixture_release(&fixture); } + +void test_clean_status_history__copies_validated_history(void) +{ + struct history_fixture fixture; + struct repository dst_repo = { + .hash_algo = &hash_algos[GIT_HASH_SHA1], + }; + struct index_state dst = INDEX_STATE_INIT(&dst_repo); + struct index_state invalid_dst = INDEX_STATE_INIT(&dst_repo); + struct clean_status_state *dst_state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA1]); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + move_index_extensions(&dst, &fixture.istate); + dst_state = dst.clean_status; + cl_assert(dst_state != NULL); + cl_assert(dst_state->disk_config_valid); + cl_assert(!dst_state->disk_config_invalid); + cl_assert(dst_state->disk_semantic_valid); + cl_assert(dst_state->disk_attr_valid); + cl_assert(dst_state->manifest.disk_valid); + cl_assert_equal_i(dst_state->manifest.disk_flags, + FSMONITOR_CLEAN_PROOF_ALL); + cl_assert_equal_i(dst_state->disk_config_raw.len, + fixture.encoded.len); + + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + move_index_extensions(&invalid_dst, &fixture.istate); + cl_assert(!invalid_dst.clean_status); + cl_assert(dst_state->disk_config_valid); + cl_assert_equal_i(dst_state->disk_config_raw.len, + fixture.encoded.len); + + release_index(&invalid_dst); + release_index(&dst); + fixture_release(&fixture); +} diff --git a/unpack-trees.c b/unpack-trees.c index 44d3567c83844b..06bcb0ee9bff0e 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -14,6 +14,7 @@ #include "tree.h" #include "tree-walk.h" #include "cache-tree.h" +#include "clean-status.h" #include "unpack-trees.h" #include "progress.h" #include "refs.h" @@ -2076,6 +2077,9 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options ret = check_updates(o, &o->internal.result) ? (-2) : 0; if (o->dst_index) { + if (!ret) + clean_status_transfer_current_proof_if_same_index( + &o->internal.result, o->src_index); move_index_extensions(&o->internal.result, o->src_index); if (!ret) { if (git_env_bool("GIT_TEST_CHECK_CACHE_TREE", 0) && From a4801980c7259cf6858f217ab1137cec965a8e6d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 20 Jul 2026 20:42:14 -0500 Subject: [PATCH 030/403] status: hold external attributes stable across refresh Fingerprinting an external attribute file while reading the index does not prevent the attribute parser from reopening a replaced file during preload or status collection. Cached stat data could then be evaluated with conversion rules that the original fingerprint did not cover. Capture the system, global, and info attribute bytes and namespace once and keep the immutable snapshot active from untracked-cache preload through collection. Parse snapshot lines with the ordinary attribute rules, including byte-order marks, embedded NULs, and line endings. End the snapshot and release its bounded source buffers with status. Make a failed capture or changed attribute content sticky and invalidate fsmonitor validity and the untracked cache before ordinary refresh. Preserve hook-provider behavior when semantic history is absent or only the namespace changes: hooks have no closing query and retain their reported-path contract. An observed content change still invalidates hook-derived state. Add t7531 integration coverage for file-parser parity, missing attribute history, an observed hook-time attribute change, and the hook missing-history exception. Update the existing history unit test to exercise the public strong-mismatch predicate. The namespace-only hook branch has no dedicated regression in this patch. Signed-off-by: Taylor Blau --- attr-fingerprint.c | 93 ++++++++++++++++++++-- attr-fingerprint.h | 18 +++++ attr.c | 89 +++++++++++++++++++-- attr.h | 12 +++ clean-status.c | 62 +++++++++++++++ clean-status.h | 10 +++ fsmonitor.c | 9 +++ fsmonitor.h | 2 + t/t7531-semantic-verify.sh | 110 ++++++++++++++++++++++++++ t/unit-tests/u-clean-status-history.c | 2 +- wt-status.c | 50 ++++++++++++ wt-status.h | 3 + 12 files changed, 446 insertions(+), 14 deletions(-) diff --git a/attr-fingerprint.c b/attr-fingerprint.c index 6a9cde2821614c..d7fdc1870dd2c3 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -10,6 +10,17 @@ #include "strbuf.h" #include "wrapper.h" +struct attr_source_snapshot_entry { + char *path; + char *buf; + size_t len; +}; + +struct attr_source_snapshot { + struct attr_fingerprint fingerprint; + struct attr_source_snapshot_entry sources[ATTR_SOURCE_SNAPSHOT_NR]; +}; + static int open_attr_source(const char *path) { #ifdef O_NONBLOCK @@ -24,7 +35,8 @@ static int open_attr_source(const char *path) static int hash_source(struct git_hash_ctx *content_ctx, struct git_hash_ctx *namespace_ctx, const struct attr_fingerprint_source *source, - int *present) + int *present, + struct attr_source_snapshot_entry *snapshot) { struct path_namespace_snapshot *before = NULL, *after = NULL; struct stat opened_before, opened_after, named; @@ -84,6 +96,12 @@ static int hash_source(struct git_hash_ctx *content_ctx, path_namespace_hash(namespace_ctx, before); path_namespace_hash_stat(namespace_ctx, &opened_after); hash_length_delimited(content_ctx, buf, size); + if (snapshot) { + snapshot->path = xstrdup(source->path); + snapshot->buf = buf; + snapshot->len = size; + buf = NULL; + } ret = 0; done: if (fd >= 0) @@ -98,11 +116,14 @@ static int hash_source(struct git_hash_ctx *content_ctx, static int fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, - const struct git_hash_algo *algo, struct attr_fingerprint *result) + const struct git_hash_algo *algo, struct attr_fingerprint *result, + struct attr_source_snapshot *snapshot) { struct git_hash_ctx content_ctx, namespace_ctx; uint32_t count; + if (snapshot && nr != ARRAY_SIZE(snapshot->sources)) + BUG("attribute snapshot source count mismatch"); memset(result, 0, sizeof(*result)); git_hash_init(&content_ctx, algo); git_hash_init(&namespace_ctx, algo); @@ -116,9 +137,11 @@ static int fingerprint_sources( hash_length_delimited(&namespace_ctx, &count, sizeof(count)); for (size_t i = 0; i < nr; i++) { int present; + struct attr_source_snapshot_entry *entry = + snapshot ? &snapshot->sources[i] : NULL; if (hash_source(&content_ctx, &namespace_ctx, &sources[i], - &present)) + &present, entry)) return -1; result->sources_present |= present; } @@ -131,7 +154,7 @@ int attr_fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, const struct git_hash_algo *algo, struct attr_fingerprint *result) { - return fingerprint_sources(sources, nr, algo, result); + return fingerprint_sources(sources, nr, algo, result, NULL); } static int repository_sources(struct repository *repo, @@ -153,7 +176,7 @@ static int repository_sources(struct repository *repo, int attr_fingerprint_repository(struct repository *repo, struct attr_fingerprint *result) { - struct attr_fingerprint_source sources[3]; + struct attr_fingerprint_source sources[ATTR_SOURCE_SNAPSHOT_NR]; char *info_attributes = NULL; int ret; @@ -165,3 +188,63 @@ int attr_fingerprint_repository(struct repository *repo, free(info_attributes); return ret; } + +int attr_source_snapshot_repository(struct repository *repo, + struct attr_source_snapshot **result) +{ + struct attr_fingerprint_source sources[ATTR_SOURCE_SNAPSHOT_NR]; + struct attr_source_snapshot *snapshot; + char *info_attributes = NULL; + + if (!result) + BUG("attr_source_snapshot_repository requires an output"); + *result = NULL; + if (repository_sources(repo, sources, &info_attributes)) + return -1; + CALLOC_ARRAY(snapshot, 1); + if (fingerprint_sources(sources, ARRAY_SIZE(sources), repo->hash_algo, + &snapshot->fingerprint, snapshot)) { + attr_source_snapshot_free(snapshot); + free(info_attributes); + return -1; + } + free(info_attributes); + *result = snapshot; + return 0; +} + +const struct attr_fingerprint *attr_source_snapshot_fingerprint( + const struct attr_source_snapshot *snapshot) +{ + return snapshot ? &snapshot->fingerprint : NULL; +} + +int attr_source_snapshot_read( + const struct attr_source_snapshot *snapshot, + enum attr_source_snapshot_kind kind, + const char **path, const char **buf, size_t *len) +{ + const struct attr_source_snapshot_entry *source; + + if (!snapshot || kind >= ATTR_SOURCE_SNAPSHOT_NR || + !path || !buf || !len) + BUG("invalid attribute snapshot read"); + source = &snapshot->sources[kind]; + if (!source->buf) + return 0; + *path = source->path; + *buf = source->buf; + *len = source->len; + return 1; +} + +void attr_source_snapshot_free(struct attr_source_snapshot *snapshot) +{ + if (!snapshot) + return; + for (size_t i = 0; i < ARRAY_SIZE(snapshot->sources); i++) { + free(snapshot->sources[i].path); + free(snapshot->sources[i].buf); + } + free(snapshot); +} diff --git a/attr-fingerprint.h b/attr-fingerprint.h index a159aa0697468c..6d15646fcd1975 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -16,10 +16,28 @@ struct attr_fingerprint { unsigned int sources_present : 1; }; +enum attr_source_snapshot_kind { + ATTR_SOURCE_SNAPSHOT_SYSTEM, + ATTR_SOURCE_SNAPSHOT_GLOBAL, + ATTR_SOURCE_SNAPSHOT_INFO, + ATTR_SOURCE_SNAPSHOT_NR, +}; + +struct attr_source_snapshot; + int attr_fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, const struct git_hash_algo *algo, struct attr_fingerprint *result); int attr_fingerprint_repository(struct repository *repo, struct attr_fingerprint *result); +int attr_source_snapshot_repository(struct repository *repo, + struct attr_source_snapshot **result); +const struct attr_fingerprint *attr_source_snapshot_fingerprint( + const struct attr_source_snapshot *snapshot); +int attr_source_snapshot_read( + const struct attr_source_snapshot *snapshot, + enum attr_source_snapshot_kind kind, + const char **path, const char **buf, size_t *len); +void attr_source_snapshot_free(struct attr_source_snapshot *snapshot); #endif /* ATTR_FINGERPRINT_H */ diff --git a/attr.c b/attr.c index 87808ba3755d04..04f28e119f2361 100644 --- a/attr.c +++ b/attr.c @@ -14,6 +14,7 @@ #include "environment.h" #include "exec-cmd.h" #include "attr.h" +#include "attr-fingerprint.h" #include "dir.h" #include "gettext.h" #include "path.h" @@ -477,6 +478,8 @@ static struct check_vector { pthread_mutex_t mutex; } check_vector; +static const struct attr_source_snapshot *source_snapshot; + static inline void vector_lock(void) { pthread_mutex_lock(&check_vector.mutex); @@ -541,6 +544,26 @@ void git_attr_invalidate_all(void) drop_all_attr_stacks(); } +void git_attr_source_snapshot_begin( + const struct attr_source_snapshot *snapshot) +{ + if (!snapshot) + BUG("cannot begin a NULL attribute source snapshot"); + if (source_snapshot) + BUG("attribute source snapshots cannot be nested"); + drop_all_attr_stacks(); + source_snapshot = snapshot; +} + +void git_attr_source_snapshot_end( + const struct attr_source_snapshot *snapshot) +{ + if (!snapshot || source_snapshot != snapshot) + BUG("ending an inactive attribute source snapshot"); + drop_all_attr_stacks(); + source_snapshot = NULL; +} + struct attr_check *attr_check_alloc(void) { struct attr_check *c = xcalloc(1, sizeof(struct attr_check)); @@ -673,6 +696,15 @@ static struct attr_stack *read_attr_from_array(const char **list) return res; } +static void handle_attr_line_buf(struct attr_stack *res, + struct strbuf *line, const char *path, + int *lineno, unsigned flags) +{ + if (!*lineno && starts_with(line->buf, utf8_bom)) + strbuf_remove(line, 0, strlen(utf8_bom)); + handle_attr_line(res, line->buf, path, ++*lineno, flags); +} + /* * Callers into the attribute system assume there is a single, system-wide * global state where attributes are read from and when the state is flipped by @@ -726,17 +758,48 @@ static struct attr_stack *read_attr_from_file(const char *path, unsigned flags) } CALLOC_ARRAY(res, 1); - while (strbuf_getline(&buf, fp) != EOF) { - if (!lineno && starts_with(buf.buf, utf8_bom)) - strbuf_remove(&buf, 0, strlen(utf8_bom)); - handle_attr_line(res, buf.buf, path, ++lineno, flags); - } + while (strbuf_getline(&buf, fp) != EOF) + handle_attr_line_buf(res, &buf, path, &lineno, flags); fclose(fp); strbuf_release(&buf); return res; } +static struct attr_stack *read_attr_from_snapshot( + enum attr_source_snapshot_kind kind, unsigned flags) +{ + struct attr_stack *res; + struct strbuf line = STRBUF_INIT; + const char *path, *buf; + size_t length; + size_t offset = 0; + int lineno = 0; + + if (!source_snapshot) + BUG("attribute source snapshot is not set"); + if (!attr_source_snapshot_read(source_snapshot, kind, + &path, &buf, &length)) + return NULL; + + CALLOC_ARRAY(res, 1); + while (offset < length) { + const char *start = buf + offset; + const char *newline = memchr(start, '\n', length - offset); + size_t len = newline ? (size_t)(newline - start) : + length - offset; + + if (newline && len && start[len - 1] == '\r') + len--; + strbuf_reset(&line); + strbuf_add(&line, start, len); + handle_attr_line_buf(res, &line, path, &lineno, flags); + offset = newline ? (size_t)(newline - buf) + 1 : length; + } + strbuf_release(&line); + return res; +} + static struct attr_stack *read_attr_from_buf(char *buf, size_t length, const char *path, unsigned flags) { @@ -927,13 +990,21 @@ static void bootstrap_attr_stack(struct index_state *istate, push_stack(stack, e, NULL, 0); /* system-wide frame */ - if (git_attr_system_is_enabled()) { + if (source_snapshot) { + e = read_attr_from_snapshot( + ATTR_SOURCE_SNAPSHOT_SYSTEM, flags); + push_stack(stack, e, NULL, 0); + } else if (git_attr_system_is_enabled()) { e = read_attr_from_file(git_attr_system_file(), flags); push_stack(stack, e, NULL, 0); } /* home directory */ - if (git_attr_global_file()) { + if (source_snapshot) { + e = read_attr_from_snapshot( + ATTR_SOURCE_SNAPSHOT_GLOBAL, flags); + push_stack(stack, e, NULL, 0); + } else if (git_attr_global_file()) { e = read_attr_from_file(git_attr_global_file(), flags); push_stack(stack, e, NULL, 0); } @@ -943,7 +1014,9 @@ static void bootstrap_attr_stack(struct index_state *istate, push_stack(stack, e, xstrdup(""), 0); /* info frame */ - if (startup_info->have_repository) + if (source_snapshot) + e = read_attr_from_snapshot(ATTR_SOURCE_SNAPSHOT_INFO, flags); + else if (startup_info->have_repository) e = read_attr_from_file(git_path_info_attributes(), flags); else e = NULL; diff --git a/attr.h b/attr.h index cca94379362f10..af2ae096d6642a 100644 --- a/attr.h +++ b/attr.h @@ -129,6 +129,7 @@ struct index_state; * `git_attr_name()`. */ struct git_attr; +struct attr_source_snapshot; /* opaque structures used internally for attribute collection */ struct all_attrs_item; @@ -230,6 +231,17 @@ void git_attr_set_direction(enum git_attr_direction new_direction); /* Discard cached attributes after a provider-wide invalidation. */ void git_attr_invalidate_all(void); +/* + * Read system, global, and info attributes from an immutable snapshot. + * begin() and end() must be strictly paired, cannot nest, and the caller must + * keep the snapshot alive until end(). Readers may run concurrently while a + * snapshot is active, but begin() and end() require that there are no readers. + */ +void git_attr_source_snapshot_begin( + const struct attr_source_snapshot *snapshot); +void git_attr_source_snapshot_end( + const struct attr_source_snapshot *snapshot); + void attr_start(void); /* Return the system gitattributes file. */ diff --git a/clean-status.c b/clean-status.c index 797d4730c1681f..a8fc917efed021 100644 --- a/clean-status.c +++ b/clean-status.c @@ -4,6 +4,7 @@ #include "clean-status-internal.h" #include "read-cache-ll.h" #include "repository.h" +#include "trace2.h" static struct repository *configured_repo; static unsigned char configured_hash[GIT_MAX_RAWSZ]; @@ -97,6 +98,67 @@ void clean_status_invalidate_current_proof(struct index_state *istate) istate->clean_status->filter_scope_valid = 0; } +int clean_status_capture_attr_snapshot( + struct index_state *istate, + struct attr_source_snapshot **snapshot) +{ + struct clean_status_state *state = istate->clean_status; + struct attr_source_snapshot *captured = NULL; + const struct attr_fingerprint *attrs; + int valid, changed = 0; + + if (!snapshot) + BUG("clean_status_capture_attr_snapshot requires an output"); + *snapshot = NULL; + if (!fstat_is_reliable() || !state || !state->current_config_valid || + !state->config_enforced) + return 0; + valid = !attr_source_snapshot_repository(istate->repo, &captured); + attrs = attr_source_snapshot_fingerprint(captured); + if (!valid || !state->current_attr_valid) { + changed = CLEAN_STATUS_ATTR_CONTENT_CHANGED | + CLEAN_STATUS_ATTR_NAMESPACE_CHANGED; + } else { + if (memcmp(attrs->content_hash, state->current_attr_hash, + istate->repo->hash_algo->rawsz)) + changed |= CLEAN_STATUS_ATTR_CONTENT_CHANGED; + if (memcmp(attrs->namespace_hash, + state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz)) + changed |= CLEAN_STATUS_ATTR_NAMESPACE_CHANGED; + } + if (valid) { + memcpy(state->current_attr_hash, attrs->content_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_namespace_hash, attrs->namespace_hash, + istate->repo->hash_algo->rawsz); + state->current_attr_valid = 1; + state->current_attr_sources_present = attrs->sources_present; + } else { + state->current_attr_valid = 0; + } + if (changed) { + clean_status_invalidate_current_proof(istate); + state->config_mismatch = 1; + state->strong_mismatch = 1; + } + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/rechecked", 1); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/mismatch", state->strong_mismatch); + if (!valid) + return -1; + *snapshot = captured; + return changed; +} + +int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) +{ + return istate->clean_status && + istate->clean_status->current_config_valid && + istate->clean_status->strong_mismatch; +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index 02e3d6022ae2c3..f3837e5d9db722 100644 --- a/clean-status.h +++ b/clean-status.h @@ -4,16 +4,26 @@ #include "clean-status-config.h" struct index_state; +struct attr_source_snapshot; struct repository; struct stat; struct strbuf; +enum clean_status_attr_change { + CLEAN_STATUS_ATTR_CONTENT_CHANGED = 1 << 0, + CLEAN_STATUS_ATTR_NAMESPACE_CHANGED = 1 << 1, +}; + void clean_status_set_config_digest( struct repository *repo, const struct clean_status_config_digest *digest); void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); +int clean_status_capture_attr_snapshot( + struct index_state *istate, + struct attr_source_snapshot **snapshot); +int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, diff --git a/fsmonitor.c b/fsmonitor.c index 9e90d158402fdd..02408ba801ad2f 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -905,6 +905,15 @@ static void invalidate_all_fsmonitor_strong(struct index_state *istate) fsmonitor_invalidate_cache_entry(istate->cache[i]); } +void fsmonitor_invalidate_semantics(struct index_state *istate) +{ + git_attr_invalidate_all(); + invalidate_all_fsmonitor_strong(istate); + istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/strong-invalidation", 1); +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; diff --git a/fsmonitor.h b/fsmonitor.h index e20d280e06a220..e6c617bec77f04 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -51,6 +51,8 @@ static inline int fsmonitor_stat_can_be_valid(const struct stat *st) return !S_ISREG(st->st_mode) || st->st_nlink <= 1; } +void fsmonitor_invalidate_semantics(struct index_state *istate); + /* * Check if refresh_fsmonitor has been called at least once. * refresh_fsmonitor is idempotent. Returns true if fsmonitor is diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh index a6e7edab9db034..1bfd85b0abac4c 100755 --- a/t/t7531-semantic-verify.sh +++ b/t/t7531-semantic-verify.sh @@ -167,4 +167,114 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "filter_scope_checked=1" actual ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'immutable attribute sources preserve file parsing' ' + attrs="$TRASH_DIRECTORY/attribute-parser-file" && + printf "\357\273\277*.dat text\nignored\0junk\n*.txt text\r\n*.bin text\r" \ + >"$attrs" && + test_create_repo attribute-parser && + git -C attribute-parser config core.attributesFile "$attrs" && + git -C attribute-parser config core.fsmonitor false && + git -C attribute-parser config core.untrackedCache false && + for extension in dat txt bin + do + printf "alpha\r\n" \ + >"attribute-parser/tracked.$extension" || return 1 + done && + git -C attribute-parser add . && + git -C attribute-parser commit -m base && + + GIT_OPTIONAL_LOCKS=0 GIT_TEST_COLD_BULK_STATUS=0 \ + git -C attribute-parser status --porcelain=v2 >actual && + test_must_be_empty actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'missing attribute history invalidates cached stats' ' + attrs="$TRASH_DIRECTORY/external-attributes-file" && + printf "*.txt text eol=crlf\n" >"$attrs" && + test_create_repo external-attributes && + git -C external-attributes config core.attributesFile "$attrs" && + git -C external-attributes config core.fsmonitor false && + git -C external-attributes config core.untrackedCache false && + printf "alpha\r\n" >external-attributes/tracked.txt && + git -C external-attributes add tracked.txt && + git -C external-attributes commit -m base && + + printf "*.txt -text\n" >"$attrs" && + GIT_OPTIONAL_LOCKS=0 GIT_TEST_COLD_BULK_STATUS=0 \ + git -C external-attributes status --porcelain=v2 >actual && + test_grep "^1 \.M .* tracked.txt$" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'hook cannot hide an observed external attribute change' ' + attrs="$TRASH_DIRECTORY/hook-attribute-change.rules" && + marker="$TRASH_DIRECTORY/hook-attribute-change.marker" && + printf "*.txt text eol=crlf\n" >"$attrs" && + test_create_repo hook-attribute-change && + ( + cd hook-attribute-change && + git config core.attributesFile "$attrs" && + git config core.untrackedCache false && + printf "alpha\r\n" >tracked.txt && + git add tracked.txt && + git commit -m base && + test_hook --setup fsmonitor-test <<-\EOF && + if test -n "$GIT_TEST_ATTR_FILE" + then + printf "*.txt -text\n" >"$GIT_TEST_ATTR_FILE" + : >"$GIT_TEST_ATTR_MARKER" + fi + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + + GIT_TEST_ATTR_FILE="$attrs" \ + GIT_TEST_ATTR_MARKER="$marker" \ + git status --porcelain=v2 >actual && + test_path_is_file "$marker" && + test_grep "^1 \.M .* tracked.txt$" actual + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'hook missing-history exception preserves reported paths' ' + attrs="$TRASH_DIRECTORY/hook-missing-history.rules" && + printf "*.txt -text\n" >"$attrs" && + test_create_repo hook-missing-history && + ( + cd hook-missing-history && + git config core.attributesFile "$attrs" && + git config core.untrackedCache false && + git config core.trustctime false && + git config core.checkStat minimal && + printf "aaaa\n" >tracked.txt && + git add tracked.txt && + git commit -m base && + test-tool chmtime =-60 tracked.txt && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked.txt) && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + test_grep ! FSCF .git/index && + + printf "bbbb\n" >tracked.txt && + test-tool chmtime =$mtime tracked.txt && + GIT_OPTIONAL_LOCKS=0 \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual + ) +' + test_done diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 4a39bfa8568811..899e6838d8f02a 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -114,7 +114,7 @@ void test_clean_status_history__adopts_only_coherent_proofs(void) state->current_semantic_hash[0] ^= 1; clean_status_prepare_fsmonitor_config(&fixture.istate); - cl_assert(state->strong_mismatch); + cl_assert(clean_status_fsmonitor_strong_mismatch(&fixture.istate)); cl_assert(!state->initial_coherent); fixture_release(&fixture); } diff --git a/wt-status.c b/wt-status.c index 57e2321275dc07..7146f3e42f1760 100644 --- a/wt-status.c +++ b/wt-status.c @@ -3,10 +3,13 @@ #include "git-compat-util.h" #include "advice.h" +#include "attr.h" +#include "attr-fingerprint.h" #include "wt-status.h" #include "object.h" #include "dir.h" #include "commit.h" +#include "clean-status.h" #include "diff.h" #include "environment.h" #include "gettext.h" @@ -811,6 +814,46 @@ static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; } +static int wt_status_begin_attr_snapshot(struct wt_status *s) +{ + int ret; + int hook_provider = + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_HOOK; + + if (s->attr_source_snapshot) + return 0; + if (s->attr_snapshot_failed) + return -1; + ret = clean_status_capture_attr_snapshot( + s->repo->index, &s->attr_source_snapshot); + if (ret < 0) { + s->attr_snapshot_failed = 1; + untracked_cache_invalidate_all(s->repo->index); + fsmonitor_invalidate_semantics(s->repo->index); + return -1; + } + if (s->attr_source_snapshot) + git_attr_source_snapshot_begin(s->attr_source_snapshot); + if ((ret > 0 && + (!hook_provider || + (ret & CLEAN_STATUS_ATTR_CONTENT_CHANGED))) || + (clean_status_fsmonitor_strong_mismatch(s->repo->index) && + !hook_provider)) { + /* + * Hook providers have no closing query with which to adopt + * missing semantic history, so absence alone must preserve + * their established path-reporting contract. Namespace-only + * churn can arise from an index rewrite and is likewise not + * evidence that the hook missed a semantic change. Changed + * attribute contents are current evidence and invalidate + * cached semantics regardless of provider. + */ + untracked_cache_invalidate_all(s->repo->index); + fsmonitor_invalidate_semantics(s->repo->index); + } + return ret; +} + void wt_status_start_untracked_cache_preload(struct wt_status *s) { struct index_state *istate = s->repo->index; @@ -820,6 +863,7 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); + wt_status_begin_attr_snapshot(s); if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) @@ -1087,6 +1131,7 @@ void wt_status_collect(struct wt_status *s) if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED) wt_status_finish_untracked_cache_preload(s); + wt_status_begin_attr_snapshot(s); wt_status_close_fsmonitor_token( s, REFRESH_QUIET | REFRESH_UNMERGED, s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && @@ -1130,6 +1175,11 @@ void wt_status_collect_free_buffers(struct wt_status *s) { untracked_cache_preload_release(s->untracked_cache_preload); s->untracked_cache_preload = NULL; + if (s->attr_source_snapshot) + git_attr_source_snapshot_end(s->attr_source_snapshot); + attr_source_snapshot_free(s->attr_source_snapshot); + s->attr_source_snapshot = NULL; + s->attr_snapshot_failed = 0; wt_status_state_free_buffers(&s->state); } diff --git a/wt-status.h b/wt-status.h index 34beac22576fc9..74798dd593aacb 100644 --- a/wt-status.h +++ b/wt-status.h @@ -7,6 +7,7 @@ #include "remote.h" struct repository; +struct attr_source_snapshot; struct worktree; struct untracked_cache_preload; @@ -148,7 +149,9 @@ struct wt_status { struct string_list ignored; uint32_t untracked_in_ms; struct untracked_cache_preload *untracked_cache_preload; + struct attr_source_snapshot *attr_source_snapshot; unsigned untracked_cache_preloaded : 1; + unsigned attr_snapshot_failed : 1; }; size_t wt_status_locate_end(const char *s, size_t len); From 2181cb2ea95fec642cce6f8cd11afcc6af1eb495 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:06:21 -0500 Subject: [PATCH 031/403] fsmonitor: seed missing-history baselines from legacy tokens An fsmonitor token can mark an entry valid even when the index has no coherent history for the configuration and attributes that determine its content. With minimal stat checks, a same-size rewrite can then be reported as clean. Rebuild the attribute manifest for expanded indexes during IPC bootstrap. Compare it with the current in-process or retained on-disk manifest, invalidate only the tracked and untracked scopes whose attribute sources changed, and preserve the last complete manifest when a rebuild fails. A legacy index with no FSCF extension is different from a mismatched proof: it contains no claim about semantic history to disprove. When it also has a valid nontrivial FSMN token with core.trustctime enabled and full core.checkStat, clear FSMN validity and seed a forward baseline through ordinary configured stat checks. This avoids hashing every tracked file solely because the index predates FSCF. The baseline still needs to finish in the bootstrap command. Preserve the freshly-proven FSMN-valid bit on entries replaced by that refresh, so that the accepted token does not defer the same migration work into the next status. Keep strong global invalidation for semantic or attribute mismatches, weak stat settings, a present FSCF without complete manifest history, provider reset or failure, manifest rebuild failure, and fresh indexes without a prior nontrivial FSMN token. Retain ordinary provider handling when reliable file identity is unavailable. The migration exception has ordinary Git stat semantics rather than a content-proof guarantee; same-size changes hidden by the platform's configured stat identity can remain hidden at that boundary. Add coverage for the forward-baseline lane, the weak-stat same-size rewrite, and the refreshed baseline FSMN bits, along with unit coverage for coherent, manifest-only, missing, and present-without-manifest history. Signed-off-by: Taylor Blau --- clean-status-history.c | 86 ++++++++++++++++++++ clean-status-internal.h | 1 + clean-status-manifest.c | 104 ++++++++++++++++++++++++ clean-status-manifest.h | 7 ++ clean-status.c | 21 +++++ clean-status.h | 18 +++++ fsmonitor.c | 79 +++++++++++++++++- read-cache.c | 12 ++- t/unit-tests/u-clean-status-history.c | 78 ++++++++++++++++++ t/unit-tests/u-clean-status-manifest.c | 108 +++++++++++++++++++++++++ 10 files changed, 510 insertions(+), 4 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index 6369472b287ac8..fc917bb1bcbc3f 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -112,6 +112,92 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) "semantic/initial-mismatch", state->strong_mismatch); } +int clean_status_has_persistent_fsmonitor_semantic_history( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->disk_config_valid && + !state->disk_config_invalid && state->disk_semantic_valid && + state->disk_attr_valid && state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + state->disk_config_raw.len; +} + +int clean_status_has_worktree_manifest_history( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + return state && state->disk_config_valid && + !state->disk_config_invalid && state->manifest.disk_valid && + (state->manifest.disk_flags & required) == required; +} + +int clean_status_fsmonitor_semantic_adoption_needed( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + int missing_history; + + if (!state || !state->current_config_valid || !state->config_enforced) + return 0; + if (state->semantic_baseline_pending) + return 0; + missing_history = + !clean_status_has_persistent_fsmonitor_semantic_history(istate) && + !clean_status_has_worktree_manifest_history(istate); + /* + * Keep missing history on the proof path until fsmonitor explicitly + * chooses the narrow forward-baseline lane for a valid legacy token. + */ + return state->strong_mismatch || missing_history || + clean_status_filter_scope_needs_validation(istate); +} + +int clean_status_fsmonitor_semantic_baseline_needed( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + const struct repo_config_values *cfg; + + if (!state || !state->current_config_valid || !state->config_enforced || + state->strong_mismatch || state->disk_config_seen || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + !*istate->fsmonitor_last_update) + return 0; + /* + * This helper is exercised by isolated index-state unit fixtures, + * which are not the_repository. The config values are already + * initialized with the repository and need no lazy parsing here. + */ + cfg = &istate->repo->config_values_private_; + if (!cfg->trust_ctime || !cfg->check_stat) + return 0; + return !clean_status_has_persistent_fsmonitor_semantic_history(istate) && + !clean_status_has_worktree_manifest_history(istate); +} + +int clean_status_fsmonitor_semantic_baseline_pending( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->semantic_baseline_pending; +} + +void clean_status_begin_fsmonitor_semantic_baseline( + struct index_state *istate) +{ + struct clean_status_state *state = clean_status_get_state(istate); + + state->semantic_baseline_pending = 1; +} + static int current_proof_is_writable(const struct index_state *istate) { const struct clean_status_state *state = istate->clean_status; diff --git a/clean-status-internal.h b/clean-status-internal.h index 9ea64b13685fdc..1cf565c46ca6af 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -37,6 +37,7 @@ struct clean_status_state { unsigned disk_attr_valid : 1; unsigned disk_config_seen : 1; unsigned disk_config_invalid : 1; + unsigned semantic_baseline_pending : 1; }; struct clean_status_state *clean_status_get_state(struct index_state *istate); diff --git a/clean-status-manifest.c b/clean-status-manifest.c index 713d8bd4d5e104..b13066a23c2c47 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -1,8 +1,30 @@ #include "git-compat-util.h" #include "attr-manifest.h" #include "clean-status-manifest.h" +#include "dir.h" #include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" #include "hash-framing.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" +#include "worktree-attr-manifest.h" + +struct invalidate_manifest_data { + struct index_state *istate; + int invalidated; +}; + +static int build_manifest(struct index_state *istate, + struct strbuf *manifest, + unsigned char *manifest_hash, + struct worktree_attr_manifest_stats *stats) +{ + if (istate->sparse_index != INDEX_EXPANDED) + return -1; + return worktree_attr_manifest_build( + istate, manifest, manifest_hash, stats); +} void clean_status_manifest_init(struct clean_status_manifest_state *state) { @@ -48,6 +70,88 @@ void clean_status_manifest_adopt_disk( state->checked = 1; } +static int invalidate_manifest_path(const struct attr_manifest_entry *entry, + void *cb_data) +{ + struct invalidate_manifest_data *data = cb_data; + char *path = xmemdupz(entry->path, entry->path_len); + + untracked_cache_invalidate_trimmed_path(data->istate, path, 0); + data->invalidated += + fsmonitor_invalidate_attributes_path(data->istate, path); + free(path); + return 0; +} + +int clean_status_manifest_refresh(struct index_state *istate, + struct clean_status_manifest_state *state) +{ + struct worktree_attr_manifest_stats stats; + struct invalidate_manifest_data invalidation = { .istate = istate }; + const struct git_hash_algo *algo = istate->repo->hash_algo; + const struct strbuf *baseline = NULL; + struct strbuf next = STRBUF_INIT; + unsigned char next_hash[GIT_MAX_RAWSZ]; + + state->scan_count++; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-scan-count", state->scan_count); + if (attr_manifest_valid(state->current.buf, state->current.len, algo)) + baseline = &state->current; + else if (state->disk_valid) + baseline = &state->disk; + state->checked = 1; + state->changed = 0; + state->global_fallback = 0; + state->current_valid = 0; + state->current_flags = 0; + if (build_manifest(istate, &next, next_hash, &stats)) { + state->global_fallback = !!baseline; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-scan-failed", 1); + strbuf_release(&next); + return -1; + } + if (baseline) { + if (attr_manifest_for_each_changed( + baseline->buf, baseline->len, + next.buf, next.len, algo, + invalidate_manifest_path, &invalidation)) { + state->global_fallback = 1; + strbuf_release(&next); + return -1; + } + state->changed = baseline->len != next.len || + memcmp(baseline->buf, next.buf, next.len); + } + strbuf_swap(&state->current, &next); + strbuf_release(&next); + memcpy(state->current_hash, next_hash, algo->rawsz); + state->current_valid = 1; + state->current_flags = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-candidates", stats.candidates); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-threads", stats.threads); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-thread-failures", + stats.thread_failures); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-worktree-sources", + stats.worktree_sources); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-index-sources", stats.index_sources); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-bytes", state->current.len); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-changed", state->changed); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-invalidated", + invalidation.invalidated); + return invalidation.invalidated; +} + void clean_status_manifest_invalidate( struct clean_status_manifest_state *state) { diff --git a/clean-status-manifest.h b/clean-status-manifest.h index e924ba9fade2fe..04a56d56abe65c 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -4,6 +4,8 @@ #include "hash.h" #include "strbuf.h" +struct index_state; + struct clean_status_manifest_state { struct strbuf disk; struct strbuf current; @@ -11,9 +13,12 @@ struct clean_status_manifest_state { unsigned char current_hash[GIT_MAX_RAWSZ]; uint32_t disk_flags; uint32_t current_flags; + uint32_t scan_count; unsigned disk_valid : 1; unsigned current_valid : 1; unsigned checked : 1; + unsigned changed : 1; + unsigned global_fallback : 1; }; void clean_status_manifest_init(struct clean_status_manifest_state *state); @@ -23,6 +28,8 @@ int clean_status_manifest_load(struct clean_status_manifest_state *state, const struct git_hash_algo *algo); void clean_status_manifest_adopt_disk( struct clean_status_manifest_state *state); +int clean_status_manifest_refresh(struct index_state *istate, + struct clean_status_manifest_state *state); void clean_status_manifest_invalidate( struct clean_status_manifest_state *state); diff --git a/clean-status.c b/clean-status.c index a8fc917efed021..96ea9511ac2a2b 100644 --- a/clean-status.c +++ b/clean-status.c @@ -96,6 +96,7 @@ void clean_status_invalidate_current_proof(struct index_state *istate) istate->clean_status->config_revalidated = 0; istate->clean_status->initial_coherent = 0; istate->clean_status->filter_scope_valid = 0; + istate->clean_status->semantic_baseline_pending = 0; } int clean_status_capture_attr_snapshot( @@ -152,6 +153,13 @@ int clean_status_capture_attr_snapshot( return changed; } +int clean_status_fsmonitor_config_mismatch(const struct index_state *istate) +{ + return istate->clean_status && + istate->clean_status->current_config_valid && + istate->clean_status->config_mismatch; +} + int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) { return istate->clean_status && @@ -159,6 +167,19 @@ int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) istate->clean_status->strong_mismatch; } +int clean_status_refresh_worktree_manifest(struct index_state *istate) +{ + struct clean_status_state *state = clean_status_get_state(istate); + + return clean_status_manifest_refresh(istate, &state->manifest); +} + +int clean_status_manifest_global_fallback(const struct index_state *istate) +{ + return istate->clean_status && + istate->clean_status->manifest.global_fallback; +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index f3837e5d9db722..a609769f9c56ea 100644 --- a/clean-status.h +++ b/clean-status.h @@ -23,7 +23,25 @@ int clean_status_filter_scope_needs_validation( int clean_status_capture_attr_snapshot( struct index_state *istate, struct attr_source_snapshot **snapshot); + +int clean_status_fsmonitor_config_mismatch(const struct index_state *istate); int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); + +int clean_status_has_persistent_fsmonitor_semantic_history( + const struct index_state *istate); +int clean_status_has_worktree_manifest_history( + const struct index_state *istate); +int clean_status_fsmonitor_semantic_adoption_needed( + const struct index_state *istate); +int clean_status_fsmonitor_semantic_baseline_needed( + const struct index_state *istate); +int clean_status_fsmonitor_semantic_baseline_pending( + const struct index_state *istate); +void clean_status_begin_fsmonitor_semantic_baseline( + struct index_state *istate); + +int clean_status_refresh_worktree_manifest(struct index_state *istate); +int clean_status_manifest_global_fallback(const struct index_state *istate); void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, diff --git a/fsmonitor.c b/fsmonitor.c index 02408ba801ad2f..c90bfebc2e4712 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -896,6 +896,22 @@ static void invalidate_all_fsmonitor(struct index_state *istate) istate->cache_changed |= FSMONITOR_CHANGED; } +/* + * A forward baseline still needs one ordinary stat refresh before its + * provider token can certify the index. Clear only process-local + * uptodate state so that refresh_index() performs those stats without + * escalating to content checks. + */ +static void invalidate_all_fsmonitor_for_baseline( + struct index_state *istate) +{ + unsigned int i; + + invalidate_all_fsmonitor(istate); + for (i = 0; i < istate->cache_nr; i++) + istate->cache[i]->ce_flags &= ~CE_UPTODATE; +} + static void invalidate_all_fsmonitor_strong(struct index_state *istate) { unsigned int i; @@ -914,6 +930,45 @@ void fsmonitor_invalidate_semantics(struct index_state *istate) "semantic/strong-invalidation", 1); } +static void invalidate_fsmonitor_for_bootstrap( + struct index_state *istate, enum fsmonitor_mode mode, + int semantic_adoption_needed, int semantic_baseline_needed, + int physical_history_unavailable) +{ + int manifest_refresh_failed; + + if (!fstat_is_reliable() || mode != FSMONITOR_MODE_IPC || + istate->split_index) { + invalidate_all_fsmonitor(istate); + return; + } + + if (physical_history_unavailable) { + if (semantic_adoption_needed) + clean_status_refresh_worktree_manifest(istate); + fsmonitor_invalidate_semantics(istate); + untracked_cache_invalidate_all(istate); + return; + } + + manifest_refresh_failed = + clean_status_refresh_worktree_manifest(istate) < 0; + if (manifest_refresh_failed || + clean_status_manifest_global_fallback(istate) || + (semantic_adoption_needed && !semantic_baseline_needed)) { + fsmonitor_invalidate_semantics(istate); + } else { + if (semantic_baseline_needed) { + clean_status_begin_fsmonitor_semantic_baseline(istate); + invalidate_all_fsmonitor_for_baseline(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/adoption-baseline", 1); + } else { + invalidate_all_fsmonitor(istate); + } + } +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -927,6 +982,8 @@ void refresh_fsmonitor(struct index_state *istate) int is_trivial = 0; int tracked_requires_bootstrap; int untracked_requires_bootstrap; + int semantic_adoption_needed; + int semantic_baseline_needed; struct repository *r = istate->repo; enum fsmonitor_mode fsm_mode = fsm_settings__get_mode(r); enum fsmonitor_reason reason = fsm_settings__get_reason(r); @@ -943,6 +1000,14 @@ void refresh_fsmonitor(struct index_state *istate) return; istate->fsmonitor_has_run_once = 1; + semantic_adoption_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_adoption_needed(istate); + semantic_baseline_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_baseline_needed(istate); trace_printf_key(&trace_fsmonitor, "refresh fsmonitor"); @@ -1068,7 +1133,10 @@ void refresh_fsmonitor(struct index_state *istate) trace2_region_enter("fsmonitor", "apply_results", istate->repo); tracked_requires_bootstrap = !query_success || is_trivial || - !istate->fsmonitor_token_valid; + !istate->fsmonitor_token_valid || + (fstat_is_reliable() && !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_config_mismatch(istate)); untracked_requires_bootstrap = !istate->fsmonitor_untracked_valid; if (query_success && !is_trivial) { @@ -1099,7 +1167,10 @@ void refresh_fsmonitor(struct index_state *istate) } if (tracked_requires_bootstrap) - invalidate_all_fsmonitor(istate); + invalidate_fsmonitor_for_bootstrap( + istate, fsm_mode, semantic_adoption_needed, + semantic_baseline_needed, + !istate->fsmonitor_token_valid); /* Now mark the untracked cache for fsmonitor usage */ if (istate->untracked) @@ -1122,7 +1193,9 @@ void refresh_fsmonitor(struct index_state *istate) * we've actually changed entries, so keep track if we * actually changed entries or not. */ - invalidate_all_fsmonitor(istate); + invalidate_fsmonitor_for_bootstrap( + istate, fsm_mode, semantic_adoption_needed, + semantic_baseline_needed, 1); } trace2_region_leave("fsmonitor", "apply_results", istate->repo); diff --git a/read-cache.c b/read-cache.c index e0a5fcfccabd0e..40f2fffbf37890 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1658,7 +1658,17 @@ int refresh_index(struct index_state *istate, unsigned int flags, continue; } - replace_index_entry(istate, i, new_entry); + { + int baseline_valid = + clean_status_fsmonitor_semantic_baseline_pending( + istate) && + (new_entry->ce_flags & CE_FSMONITOR_VALID); + + replace_index_entry(istate, i, new_entry); + if (baseline_valid) + mark_fsmonitor_valid(istate, + istate->cache[i]); + } } trace2_data_intmax("index", NULL, "refresh/sum_lstat", t2_sum_lstat); trace2_data_intmax("index", NULL, "refresh/sum_scan", t2_sum_scan); diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 899e6838d8f02a..feb813c185c624 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -27,6 +27,7 @@ static void fixture_init(struct history_fixture *fixture, memset(fixture, 0, sizeof(*fixture)); fixture->repo.hash_algo = algo; + repo_config_values_init(&fixture->repo.config_values_private_); index_state_init(&fixture->istate, &fixture->repo); fixture->manifest = (struct strbuf)STRBUF_INIT; fixture->encoded = (struct strbuf)STRBUF_INIT; @@ -54,6 +55,7 @@ static void fixture_release(struct history_fixture *fixture) { clean_status_release(&fixture->istate); free(fixture->istate.fsmonitor_last_update); + repo_config_values_clear(&fixture->repo.config_values_private_); strbuf_release(&fixture->encoded); strbuf_release(&fixture->manifest); } @@ -72,6 +74,7 @@ static struct clean_status_state *install_current( state->current_semantic_valid = 1; state->current_attr_valid = 1; state->config_enforced = 1; + FREE_AND_NULL(fixture->istate.fsmonitor_last_update); fixture->istate.fsmonitor_last_update = xstrdup("builtin:1:2"); fixture->istate.fsmonitor_token_valid = 1; return state; @@ -119,6 +122,81 @@ void test_clean_status_history__adopts_only_coherent_proofs(void) fixture_release(&fixture); } +void test_clean_status_history__distinguishes_available_history(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct history_fixture fixture; + struct clean_status_state *state; + struct strbuf manifest_only = STRBUF_INIT; + + fixture_init(&fixture, algo); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + cl_assert(clean_status_has_persistent_fsmonitor_semantic_history( + &fixture.istate)); + cl_assert(clean_status_has_worktree_manifest_history(&fixture.istate)); + state = install_current(&fixture); + cl_assert(!clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + state->strong_mismatch = 1; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture_release(&fixture); + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_copy_without_bindings( + &manifest_only, fixture.encoded.buf, fixture.encoded.len, + algo), 0); + clean_status_read_fsmonitor_config( + &fixture.istate, manifest_only.buf, manifest_only.len); + cl_assert(!clean_status_has_persistent_fsmonitor_semantic_history( + &fixture.istate)); + cl_assert(clean_status_has_worktree_manifest_history(&fixture.istate)); + install_current(&fixture); + cl_assert(!clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture_release(&fixture); + + fixture_init(&fixture, algo); + state = install_current(&fixture); + fixture.istate.fsmonitor_token_valid = 1; + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:test:1"); + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + clean_status_begin_fsmonitor_semantic_baseline(&fixture.istate); + cl_assert(!clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + state->semantic_baseline_pending = 0; + state->disk_config_seen = 1; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + state->disk_config_seen = 0; + fixture.repo.config_values_private_.trust_ctime = 0; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture.repo.config_values_private_.trust_ctime = 1; + state->strong_mismatch = 1; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture_release(&fixture); + strbuf_release(&manifest_only); +} + void test_clean_status_history__preserves_unbound_manifests(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c index e6d83c564a8a6b..fc17fd8ec0dbb8 100644 --- a/t/unit-tests/u-clean-status-manifest.c +++ b/t/unit-tests/u-clean-status-manifest.c @@ -1,7 +1,12 @@ #include "unit-test.h" #include "attr-manifest.h" #include "clean-status-manifest.h" +#include "dir.h" #include "fsmonitor-clean-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "wrapper.h" static void make_manifest(struct strbuf *manifest, const struct git_hash_algo *algo) @@ -68,3 +73,106 @@ void test_clean_status_manifest__rejects_invalid_history(void) clean_status_manifest_release(&state); strbuf_release(&manifest); } +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static char *create_worktree(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/status-manifest.XXXXXX", + tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void add_index_path(struct index_state *istate, size_t pos, + const char *path) +{ + size_t len = strlen(path); + struct cache_entry *ce = make_empty_cache_entry(istate, len); + + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = len; + memcpy(ce->name, path, len + 1); + istate->cache[pos] = ce; +} +#endif + +void test_clean_status_manifest__invalidates_only_changed_scopes(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct clean_status_manifest_state state; + struct strbuf path = STRBUF_INIT, old = STRBUF_INIT, cleanup = STRBUF_INIT; + + strbuf_addf(&path, "%s/a", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/b", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/a/.gitattributes", worktree); + write_file(path.buf, "*.txt text\n"); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_index_path(&istate, 0, "a/file"); + add_index_path(&istate, 1, "b/file"); + clean_status_manifest_init(&state); + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 0); + strbuf_addbuf(&old, &state.current); + cl_assert_equal_i(clean_status_manifest_load( + &state, old.buf, old.len, FSMONITOR_CLEAN_PROOF_ALL, algo), 0); + + write_file(path.buf, "*.txt -text\n"); + for (size_t i = 0; i < istate.cache_nr; i++) { + istate.cache[i]->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE; + memset(&istate.cache[i]->ce_stat_data, 1, + sizeof(istate.cache[i]->ce_stat_data)); + } + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); + cl_assert(state.changed); + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + + /* + * Preserve the last complete in-process value when a rebuild fails, + * then return to the on-disk value. The final comparison must use + * the preserved value, not the matching on-disk history. + */ + strbuf_reset(&old); + strbuf_addbuf(&old, &state.current); + clean_status_manifest_invalidate(&state); + istate.cache[0]->ce_flags = create_ce_flags(1); + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), -1); + cl_assert(!state.current_valid); + cl_assert(state.global_fallback); + cl_assert_equal_i(strbuf_cmp(&state.current, &old), 0); + + write_file(path.buf, "*.txt text\n"); + for (size_t i = 0; i < istate.cache_nr; i++) { + istate.cache[i]->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE; + memset(&istate.cache[i]->ce_stat_data, 1, + sizeof(istate.cache[i]->ce_stat_data)); + } + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); + cl_assert(state.changed); + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + + clean_status_manifest_release(&state); + strbuf_release(&old); + strbuf_release(&path); + release_index(&istate); + strbuf_addstr(&cleanup, worktree); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + free(worktree); +#endif +} From 4ba322a33ed9fb9b0ada2d56e007779bf5ea8ab5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:51:39 -0500 Subject: [PATCH 032/403] status: prepare logical-index history checkpoints An exact clean-result sidecar must remain bound to one physical index, but resumable fsmonitor history needs to survive a format-only rewrite by another Git implementation. It cannot use the index checksum or file identity as its cross-implementation key. Promote the checksummed path snapshot operations needed by an external store. They open the named index without following its final symlink and retain the descriptor, then require the descriptor and current pathname to identify the same valid index. Null checksums remain ineligible for durable snapshot pins. When fstat identity is reliable, retain the validated reader descriptor for process-local proof epochs only; generic certification and persisted CSHS still require a non-null checksum. Define a canonical digest of the ordered logical entries. Include the entry count and each path, stage, object ID, mode, CE_VALID, skip-worktree, and intent-to-add state, while excluding index encoding, cached stat data, and acceleration-only flags. Unsupported transient state rejects the digest rather than disappearing with the process. Add the checksummed CSHS codec and a local-APFS-only, nofollow, atomically-replaced store bounded to eight 16-MiB namespace slots. This commit has no status caller; the following history patch restores and saves complete checkpoints through this persistence layer. Cover both object formats, malformed and null-checksum snapshots, pathname replacement, logical-entry bindings, malformed and independent checkpoint namespaces, bounded retention, and idempotent writes. A checkpoint may contain only the required FSMN and FSCF payloads. Skip absent optional payloads rather than handing a NULL source and zero length to memcpy(). Extend the malformed-checkpoint unit test to round-trip that minimal valid form before its rejection cases. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-history-store.c | 462 ++++++++++++++++++++ clean-status-history-store.h | 50 +++ clean-status-index.c | 231 ++++++++++ clean-status-index.h | 34 ++ clean-status-internal.h | 3 + clean-status.c | 3 + clean-status.h | 3 + meson.build | 1 + read-cache.c | 14 +- t/meson.build | 1 + t/unit-tests/u-clean-status-history-store.c | 363 +++++++++++++++ t/unit-tests/u-clean-status-index.c | 349 +++++++++++++++ 13 files changed, 1513 insertions(+), 3 deletions(-) create mode 100644 clean-status-history-store.c create mode 100644 clean-status-history-store.h create mode 100644 clean-status-index.h create mode 100644 t/unit-tests/u-clean-status-history-store.c diff --git a/Makefile b/Makefile index cde58e8bd4728f..5da7272e3e5e67 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-history-store.o LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o @@ -1557,6 +1558,7 @@ CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-clean-status-history +CLAR_TEST_SUITES += u-clean-status-history-store CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest diff --git a/clean-status-history-store.c b/clean-status-history-store.c new file mode 100644 index 00000000000000..572264ffafb4b2 --- /dev/null +++ b/clean-status-history-store.c @@ -0,0 +1,462 @@ +#include "git-compat-util.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-history-store.h" +#include "clean-status-identity.h" +#include "clean-status-index.h" +#include "hash-framing.h" +#include "hex.h" +#include "lockfile.h" +#include "path.h" +#include "strbuf.h" +#include "wrapper.h" + +#define CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC "CSHS" +#define CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION 1 +#define CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE (16 * 1024 * 1024) +#define CLEAN_STATUS_HISTORY_STORE_MAX_FILES 8 +#define CLEAN_STATUS_HISTORY_HAS_FSMN (1U << 0) +#define CLEAN_STATUS_HISTORY_HAS_UNTR (1U << 1) +#define CLEAN_STATUS_HISTORY_HAS_FSCF (1U << 2) +#define CLEAN_STATUS_HISTORY_HAS_FSUC (1U << 3) +#define CLEAN_STATUS_FILESYSTEM_ID_SIZE 16 + +struct clean_status_filesystem_id { + unsigned char value[CLEAN_STATUS_FILESYSTEM_ID_SIZE]; +}; + +static int checksum_valid(const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *bytes = data; + unsigned char actual[GIT_MAX_RAWSZ]; + + if (len < algo->rawsz) + return 0; + hash_buffer_digest(algo, data, len - algo->rawsz, actual); + return !memcmp(actual, bytes + len - algo->rawsz, algo->rawsz); +} + +static void proof_namespace_hash(const char *proof_namespace, + const struct git_hash_algo *algo, + unsigned char *out) +{ + static const char domain[] = "git-clean-status-history-namespace-v1"; + struct git_hash_ctx ctx; + + git_hash_init(&ctx, algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, proof_namespace, strlen(proof_namespace)); + git_hash_final(out, &ctx); +} + +static char *history_store_path(const char *index_path, + const char *proof_namespace, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + char hex[GIT_MAX_HEXSZ + 1]; + + proof_namespace_hash(proof_namespace, algo, hash); + hash_to_hex_algop_r(hex, hash, algo); + return xstrfmt("%s.csh1.%s", index_path, hex); +} + +struct history_store_file { + char *path; + timestamp_t mtime; + unsigned int mtime_nsec; + unsigned retained : 1; +}; + +static int history_store_file_cmp(const void *va, const void *vb) +{ + const struct history_store_file *a = va; + const struct history_store_file *b = vb; + + if (a->mtime != b->mtime) + return a->mtime < b->mtime ? -1 : 1; + if (a->mtime_nsec != b->mtime_nsec) + return a->mtime_nsec < b->mtime_nsec ? -1 : 1; + return strcmp(a->path, b->path); +} + +/* + * The status caller holds index.lock while publishing a checkpoint. That + * serializes this directory-level retention step with every supported + * publisher, while per-slot lockfiles still make each replacement atomic. + */ +static int prune_history_store(const char *index_path, + const char *retained_path, + const struct git_hash_algo *algo, + size_t limit) +{ + struct history_store_file *files = NULL; + struct strbuf directory = STRBUF_INIT; + struct strbuf prefix = STRBUF_INIT; + struct strbuf candidate = STRBUF_INIT; + const char *slash = find_last_dir_sep(index_path); + const char *base = slash ? slash + 1 : index_path; + const char *retained_slash = find_last_dir_sep(retained_path); + const char *retained_base = retained_slash ? + retained_slash + 1 : retained_path; + DIR *dir = NULL; + struct dirent *de; + size_t nr = 0, alloc = 0, remove_nr; + int ret = -1; + + if (slash) { + if (slash == index_path) + strbuf_addch(&directory, '/'); + else + strbuf_add(&directory, index_path, slash - index_path); + } else { + strbuf_addch(&directory, '.'); + } + strbuf_addf(&prefix, "%s.csh1.", base); + dir = opendir(directory.buf); + if (!dir) + goto done; + while ((de = readdir(dir))) { + const char *suffix; + struct stat st; + + if (!starts_with(de->d_name, prefix.buf)) + continue; + suffix = de->d_name + prefix.len; + if (strlen(suffix) != algo->hexsz || + strspn(suffix, "0123456789abcdef") != algo->hexsz) + continue; + strbuf_reset(&candidate); + strbuf_addf(&candidate, "%s/%s", directory.buf, de->d_name); + if (lstat(candidate.buf, &st) || !S_ISREG(st.st_mode)) + continue; + ALLOC_GROW(files, nr + 1, alloc); + files[nr].path = xstrdup(candidate.buf); + files[nr].mtime = st.st_mtime; + files[nr].mtime_nsec = ST_MTIME_NSEC(st); + files[nr].retained = !strcmp(de->d_name, retained_base); + nr++; + } + if (limit >= nr) { + ret = 0; + goto done; + } + QSORT(files, nr, history_store_file_cmp); + remove_nr = nr - limit; + for (size_t i = 0; i < nr && remove_nr; i++) { + struct stat st; + + if (files[i].retained) + continue; + /* Recheck without following links immediately before removal. */ + if (lstat(files[i].path, &st) || !S_ISREG(st.st_mode) || + unlink(files[i].path)) + goto done; + remove_nr--; + } + ret = remove_nr ? -1 : 0; + +done: + if (dir) + closedir(dir); + for (size_t i = 0; i < nr; i++) + free(files[i].path); + free(files); + strbuf_release(&candidate); + strbuf_release(&prefix); + strbuf_release(&directory); + return ret; +} + +static int open_nofollow_nonblocking(const char *path, int flags) +{ +#ifdef O_NONBLOCK + return open_nofollow(path, flags | O_NONBLOCK); +#else + (void)path; + (void)flags; + errno = ENOSYS; + return -1; +#endif +} + +int clean_status_history_checkpoint_parse( + struct clean_status_history_checkpoint *checkpoint, + const char *proof_namespace, const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *p = data; + const unsigned char *end; + const unsigned char *payload; + unsigned char expected_namespace[GIT_MAX_RAWSZ]; + size_t minimum = 4 + 2 * sizeof(uint32_t) + 2 * algo->rawsz + + 4 * sizeof(uint32_t) + algo->rawsz; + uint32_t flags, lengths[4]; + + memset(checkpoint, 0, sizeof(*checkpoint)); + if (!proof_namespace || !*proof_namespace || len < minimum || + len > CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE || + memcmp(p, CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC, 4) || + !checksum_valid(data, len, algo)) + return -1; + end = p + len - algo->rawsz; + p += 4; + if (get_be32(p) != CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) + return -1; + p += sizeof(uint32_t); + flags = get_be32(p); + p += sizeof(uint32_t); + if ((flags & (CLEAN_STATUS_HISTORY_HAS_FSMN | + CLEAN_STATUS_HISTORY_HAS_FSCF)) != + (CLEAN_STATUS_HISTORY_HAS_FSMN | + CLEAN_STATUS_HISTORY_HAS_FSCF) || + !!(flags & CLEAN_STATUS_HISTORY_HAS_UNTR) != + !!(flags & CLEAN_STATUS_HISTORY_HAS_FSUC) || + flags & ~(CLEAN_STATUS_HISTORY_HAS_FSMN | + CLEAN_STATUS_HISTORY_HAS_UNTR | + CLEAN_STATUS_HISTORY_HAS_FSCF | + CLEAN_STATUS_HISTORY_HAS_FSUC)) + return -1; + proof_namespace_hash(proof_namespace, algo, expected_namespace); + if (memcmp(p, expected_namespace, algo->rawsz)) + return -1; + p += algo->rawsz; + memcpy(checkpoint->index_hash, p, algo->rawsz); + p += algo->rawsz; + for (size_t i = 0; i < ARRAY_SIZE(lengths); i++) { + lengths[i] = get_be32(p); + p += sizeof(uint32_t); + } + payload = p; + if (!!lengths[0] != !!(flags & CLEAN_STATUS_HISTORY_HAS_FSMN) || + !!lengths[1] != !!(flags & CLEAN_STATUS_HISTORY_HAS_UNTR) || + !!lengths[2] != !!(flags & CLEAN_STATUS_HISTORY_HAS_FSCF) || + !!lengths[3] != !!(flags & CLEAN_STATUS_HISTORY_HAS_FSUC)) + return -1; + for (size_t i = 0; i < ARRAY_SIZE(lengths); i++) { + if ((size_t)(end - p) < lengths[i]) + return -1; + p += lengths[i]; + } + if (p != end) + return -1; + p = payload; + if (lengths[0]) { + checkpoint->fsmonitor = p; + checkpoint->fsmonitor_len = lengths[0]; + p += lengths[0]; + } + if (lengths[1]) { + checkpoint->untracked_cache = p; + checkpoint->untracked_cache_len = lengths[1]; + p += lengths[1]; + } + if (lengths[2]) { + checkpoint->fsmonitor_config = p; + checkpoint->fsmonitor_config_len = lengths[2]; + p += lengths[2]; + } + if (lengths[3]) { + checkpoint->fsmonitor_untracked = p; + checkpoint->fsmonitor_untracked_len = lengths[3]; + } + return 0; +} + +int clean_status_history_checkpoint_write( + struct strbuf *out, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct git_hash_algo *algo) +{ + unsigned char namespace_hash[GIT_MAX_RAWSZ]; + uint32_t value, flags = 0; + + strbuf_reset(out); + if (!proof_namespace || !*proof_namespace || + checkpoint->fsmonitor_len > UINT32_MAX || + checkpoint->untracked_cache_len > UINT32_MAX || + checkpoint->fsmonitor_config_len > UINT32_MAX || + checkpoint->fsmonitor_untracked_len > UINT32_MAX || + (!!checkpoint->fsmonitor != !!checkpoint->fsmonitor_len) || + (!!checkpoint->untracked_cache != + !!checkpoint->untracked_cache_len) || + (!!checkpoint->fsmonitor_config != + !!checkpoint->fsmonitor_config_len) || + (!!checkpoint->fsmonitor_untracked != + !!checkpoint->fsmonitor_untracked_len) || + !checkpoint->fsmonitor_len || !checkpoint->fsmonitor_config_len || + (!!checkpoint->untracked_cache_len != + !!checkpoint->fsmonitor_untracked_len)) + return -1; + flags |= CLEAN_STATUS_HISTORY_HAS_FSMN; + if (checkpoint->untracked_cache_len) + flags |= CLEAN_STATUS_HISTORY_HAS_UNTR; + if (checkpoint->fsmonitor_config_len) + flags |= CLEAN_STATUS_HISTORY_HAS_FSCF; + if (checkpoint->fsmonitor_untracked_len) + flags |= CLEAN_STATUS_HISTORY_HAS_FSUC; + proof_namespace_hash(proof_namespace, algo, namespace_hash); + strbuf_add(out, CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC, 4); + put_be32(&value, CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, flags); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, namespace_hash, algo->rawsz); + strbuf_add(out, checkpoint->index_hash, algo->rawsz); + put_be32(&value, checkpoint->fsmonitor_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->untracked_cache_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->fsmonitor_config_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->fsmonitor_untracked_len); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, checkpoint->fsmonitor, checkpoint->fsmonitor_len); + if (checkpoint->untracked_cache_len) + strbuf_add(out, checkpoint->untracked_cache, + checkpoint->untracked_cache_len); + strbuf_add(out, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len); + if (checkpoint->fsmonitor_untracked_len) + strbuf_add(out, checkpoint->fsmonitor_untracked, + checkpoint->fsmonitor_untracked_len); + hash_append_checksum(out, algo); + if (out->len > CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE) { + strbuf_reset(out); + return -1; + } + return 0; +} + +int clean_status_history_store_load( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo, + struct clean_status_history_store_record *record) +{ + struct stat st; + char extra; + char *path = history_store_path(index_path, proof_namespace, algo); + int fd = -1, ret = -1; + size_t size; + + memset(&record->checkpoint, 0, sizeof(record->checkpoint)); + strbuf_reset(&record->storage); + fd = open_nofollow_nonblocking(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || fstat(fd, &st) || !S_ISREG(st.st_mode) || + st.st_size < 0 || + st.st_size > CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE) + goto done; + size = xsize_t(st.st_size); + strbuf_grow(&record->storage, size); + strbuf_setlen(&record->storage, size); + if ((size_t)read_in_full(fd, record->storage.buf, size) != size || + read(fd, &extra, 1) != 0 || + clean_status_history_checkpoint_parse( + &record->checkpoint, proof_namespace, record->storage.buf, + record->storage.len, algo)) + goto done; + ret = 0; + +done: + if (ret) + strbuf_reset(&record->storage); + if (fd >= 0) + close(fd); + free(path); + return ret; +} + +void clean_status_history_store_record_release( + struct clean_status_history_store_record *record) +{ + strbuf_release(&record->storage); + memset(&record->checkpoint, 0, sizeof(record->checkpoint)); +} + +static int local_apfs_id(int fd MAYBE_UNUSED, + struct clean_status_filesystem_id *id) +{ +#ifdef __APPLE__ + struct statfs fs; +#endif + + memset(id, 0, sizeof(*id)); +#ifdef __APPLE__ + if (fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs") || + sizeof(fs.f_fsid) > sizeof(id->value)) + return -1; + memcpy(id->value, &fs.f_fsid, sizeof(fs.f_fsid)); + return 0; +#else + return -1; +#endif +} + +int clean_status_history_store_install( + const char *index_path, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct clean_status_filesystem_id fsid; + struct clean_status_history_store_record current = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct strbuf encoded = STRBUF_INIT; + struct lock_file lock = LOCK_INIT; + char *path = history_store_path(index_path, proof_namespace, algo); + struct stat st; + int current_is_regular, encoded_matches = 0; + int checkpoint_fd = -1, ret = -1; + + if (!clean_status_identity_is_durable() || !snapshot || + snapshot->fd < 0 || local_apfs_id(snapshot->fd, &fsid) || + !clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo) || + clean_status_history_checkpoint_write( + &encoded, proof_namespace, checkpoint, algo)) + goto done; + current_is_regular = !lstat(path, &st) && S_ISREG(st.st_mode); + if (!clean_status_history_store_load( + index_path, proof_namespace, algo, ¤t)) + encoded_matches = current.storage.len == encoded.len && + !memcmp(current.storage.buf, encoded.buf, encoded.len); + clean_status_history_store_record_release(¤t); + + /* + * If this namespace is new, make room before the atomic install so a + * successful publication never takes the bounded store above eight + * regular schema-v1 slots. No other checkpoint schema is considered. + */ + if (prune_history_store( + index_path, path, algo, + current_is_regular ? CLEAN_STATUS_HISTORY_STORE_MAX_FILES : + CLEAN_STATUS_HISTORY_STORE_MAX_FILES - 1) || + !clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo)) + goto done; + if (encoded_matches) { + ret = 0; + goto done; + } + checkpoint_fd = hold_lock_file_for_update(&lock, path, 0); + if (checkpoint_fd < 0 || + (size_t)write_in_full(checkpoint_fd, encoded.buf, encoded.len) != + encoded.len || + !clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo) || + commit_lock_file(&lock)) + goto done; + ret = 0; + +done: + if (ret) + rollback_lock_file(&lock); + free(path); + strbuf_release(&encoded); + return ret; +} diff --git a/clean-status-history-store.h b/clean-status-history-store.h new file mode 100644 index 00000000000000..22f3f6d5a085fd --- /dev/null +++ b/clean-status-history-store.h @@ -0,0 +1,50 @@ +#ifndef CLEAN_STATUS_HISTORY_STORE_H +#define CLEAN_STATUS_HISTORY_STORE_H + +#include "hash.h" +#include "strbuf.h" + +struct clean_status_index_snapshot; + +struct clean_status_history_checkpoint { + unsigned char index_hash[GIT_MAX_RAWSZ]; + const unsigned char *fsmonitor; + size_t fsmonitor_len; + const unsigned char *untracked_cache; + size_t untracked_cache_len; + const unsigned char *fsmonitor_config; + size_t fsmonitor_config_len; + const unsigned char *fsmonitor_untracked; + size_t fsmonitor_untracked_len; +}; + +struct clean_status_history_store_record { + struct clean_status_history_checkpoint checkpoint; + struct strbuf storage; +}; + +#define CLEAN_STATUS_HISTORY_STORE_RECORD_INIT { \ + .storage = STRBUF_INIT, \ +} + +int clean_status_history_checkpoint_parse( + struct clean_status_history_checkpoint *checkpoint, + const char *proof_namespace, const void *data, size_t len, + const struct git_hash_algo *algo); +int clean_status_history_checkpoint_write( + struct strbuf *out, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct git_hash_algo *algo); +int clean_status_history_store_load( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo, + struct clean_status_history_store_record *record); +void clean_status_history_store_record_release( + struct clean_status_history_store_record *record); +int clean_status_history_store_install( + const char *index_path, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo); + +#endif /* CLEAN_STATUS_HISTORY_STORE_H */ diff --git a/clean-status-index.c b/clean-status-index.c index 4733e53e3a9fcc..51399c0f24f720 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -1,7 +1,215 @@ #include "git-compat-util.h" #include "clean-status.h" +#include "clean-status-index.h" #include "clean-status-internal.h" +#include "hash-framing.h" #include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" +#include "wrapper.h" + +static int snapshot_read( + int fd, const struct stat *st, const struct git_hash_algo *algo, + uint32_t *version, uint32_t *cache_nr, struct object_id *checksum) +{ + unsigned char header[12]; + unsigned char trailer[GIT_MAX_RAWSZ]; + + if (st->st_size < 0 || + (uintmax_t)st->st_size < sizeof(header) + algo->rawsz || + (size_t)pread_in_full(fd, header, sizeof(header), 0) != + sizeof(header) || + memcmp(header, "DIRC", 4) || + (size_t)pread_in_full(fd, trailer, algo->rawsz, + st->st_size - (off_t)algo->rawsz) != + algo->rawsz) + return -1; + *version = get_be32(header + 4); + *cache_nr = get_be32(header + 8); + if (*version < 2 || *version > 4) + return -1; + oidread(checksum, trailer, algo); + return 0; +} + +static int snapshot_matches( + int fd, const struct stat *st, uint32_t expected_version, + uint32_t expected_cache_nr, const struct object_id *expected_checksum, + const struct git_hash_algo *algo) +{ + struct object_id checksum; + uint32_t version, cache_nr; + + return !snapshot_read(fd, st, algo, &version, &cache_nr, &checksum) && + version == expected_version && cache_nr == expected_cache_nr && + oideq(&checksum, expected_checksum); +} + +static int snapshot_open( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo, int allow_null_checksum) +{ + struct clean_status_identity named; + struct stat fd_st, named_st; + int fd; + + memset(snapshot, 0, sizeof(*snapshot)); + snapshot->fd = -1; + fd = open_nofollow(path, O_RDONLY); + if (fd < 0 || + fstat(fd, &fd_st) || + lstat(path, &named_st) || + clean_status_identity_from_stat(&snapshot->identity, &fd_st) || + clean_status_identity_from_stat(&named, &named_st) || + !clean_status_identity_equal(&snapshot->identity, &named) || + snapshot_read(fd, &fd_st, algo, &snapshot->version, + &snapshot->cache_nr, &snapshot->checksum) || + (!allow_null_checksum && is_null_oid(&snapshot->checksum))) + goto fail; + snapshot->fd = fd; + return 0; + +fail: + if (fd >= 0) + close(fd); + return -1; +} + +int clean_status_index_snapshot_open( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo) +{ + return snapshot_open(snapshot, path, algo, 0); +} + +int clean_status_index_snapshot_still_matches_path( + const struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo) +{ + struct clean_status_identity fd_identity, named_identity; + struct stat fd_st, named_st; + + return snapshot->fd >= 0 && + !fstat(snapshot->fd, &fd_st) && + !lstat(path, &named_st) && + !clean_status_identity_from_stat(&fd_identity, &fd_st) && + !clean_status_identity_from_stat(&named_identity, &named_st) && + clean_status_identity_equal(&fd_identity, &snapshot->identity) && + clean_status_identity_equal(&named_identity, + &snapshot->identity) && + snapshot_matches(snapshot->fd, &fd_st, snapshot->version, + snapshot->cache_nr, &snapshot->checksum, algo); +} + +static int snapshot_matches_index_state( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return istate->version == snapshot->version && + istate->cache_nr == snapshot->cache_nr && + oideq(&istate->oid, &snapshot->checksum) && + (!is_null_oid(&snapshot->checksum) || + (clean_status_identity_is_durable() && state && + state->source_identity_valid && + clean_status_identity_equal(&snapshot->identity, + &state->source_identity))); +} + +int clean_status_index_snapshot_pin( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate) +{ + if (snapshot_open(snapshot, istate->repo->index_file, + istate->repo->hash_algo, 1)) + return -1; + if (snapshot_matches_index_state(snapshot, istate)) + return 0; + clean_status_index_snapshot_release(snapshot); + return -1; +} + +int clean_status_index_snapshot_still_matches( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + return snapshot_matches_index_state(snapshot, istate) && + clean_status_index_snapshot_still_matches_path( + snapshot, istate->repo->index_file, + istate->repo->hash_algo); +} + +void clean_status_index_snapshot_release( + struct clean_status_index_snapshot *snapshot) +{ + if (snapshot->fd >= 0) + close(snapshot->fd); + snapshot->fd = -1; +} + +static int index_logical_digest(const struct index_state *istate, + unsigned int extra_benign_flags, + unsigned char *out) +{ + static const char domain[] = "git-clean-status-logical-index-v1"; + const unsigned int persistent_flags = + CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; + const unsigned int benign_flags = + CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID; + struct git_hash_ctx ctx; + uint32_t value; + int initialized = 0, ret = -1; + + if (!istate->repo || !istate->repo->hash_algo || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + istate->cache_nr > UINT32_MAX) + return -1; + trace2_region_enter("fsmonitor", "history_logical_digest", + istate->repo); + git_hash_init(&ctx, istate->repo->hash_algo); + initialized = 1; + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + put_be32(&value, istate->cache_nr); + hash_length_delimited(&ctx, &value, sizeof(value)); + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + /* + * Every in-memory flag not explicitly known to be an + * acceleration hint may describe work which must be completed + * before the entry is safe to externalize. In particular, + * CE_CONTENT_CHECK_REQUIRED must not disappear with the process + * which raised it. + */ + if (ce->ce_flags & ~(persistent_flags | benign_flags | + extra_benign_flags)) + goto done; + put_be32(&value, ce->ce_mode); + hash_length_delimited(&ctx, &value, sizeof(value)); + put_be32(&value, ce->ce_flags & persistent_flags); + hash_length_delimited(&ctx, &value, sizeof(value)); + hash_length_delimited(&ctx, ce->oid.hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, ce->name, ce_namelen(ce)); + } + git_hash_final(out, &ctx); + initialized = 0; + ret = 0; + +done: + if (initialized) + git_hash_discard(&ctx); + trace2_region_leave("fsmonitor", "history_logical_digest", + istate->repo); + return ret; +} + +int clean_status_index_logical_digest(const struct index_state *istate, + unsigned char *out) +{ + return index_logical_digest(istate, 0, out); +} void clean_status_record_source_identity(struct index_state *istate, const struct stat *st) @@ -15,6 +223,29 @@ void clean_status_record_source_identity(struct index_state *istate, state->source_identity_valid = 1; } +int clean_status_retain_source_index_fd(struct index_state *istate, int fd, + const struct stat *st) +{ + struct clean_status_state *state = istate->clean_status; + struct clean_status_identity identity, current_identity; + struct stat current; + + if (!fstat_is_reliable() || fd < 0 || !state || + !state->config_enforced || istate->split_index || + !is_null_oid(&istate->oid) || + state->source_index_fd >= 0 || + clean_status_identity_from_stat(&identity, st) || + fstat(fd, ¤t) || + clean_status_identity_from_stat(¤t_identity, ¤t) || + !clean_status_identity_equal(&identity, ¤t_identity)) + return 0; + state->source_index_fd = fd; + state->source_index_identity = identity; + state->source_index_identity_valid = 1; + /* Ownership transfers only after every fail-closed check succeeds. */ + return 1; +} + int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st) { diff --git a/clean-status-index.h b/clean-status-index.h new file mode 100644 index 00000000000000..b8c7dbb78487f0 --- /dev/null +++ b/clean-status-index.h @@ -0,0 +1,34 @@ +#ifndef CLEAN_STATUS_INDEX_H +#define CLEAN_STATUS_INDEX_H + +#include "clean-status-identity.h" +#include "hash.h" + +struct index_state; + +struct clean_status_index_snapshot { + struct clean_status_identity identity; + uint32_t version; + uint32_t cache_nr; + struct object_id checksum; + int fd; +}; + +int clean_status_index_snapshot_open( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo); +int clean_status_index_snapshot_still_matches_path( + const struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo); +int clean_status_index_snapshot_pin( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate); +int clean_status_index_snapshot_still_matches( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate); +void clean_status_index_snapshot_release( + struct clean_status_index_snapshot *snapshot); +int clean_status_index_logical_digest(const struct index_state *istate, + unsigned char *out); + +#endif /* CLEAN_STATUS_INDEX_H */ diff --git a/clean-status-internal.h b/clean-status-internal.h index 1cf565c46ca6af..65dd4da0ae4019 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -8,10 +8,12 @@ struct index_state; struct clean_status_state { struct clean_status_identity source_identity; + struct clean_status_identity source_index_identity; struct clean_status_manifest_state manifest; struct strbuf disk_config_raw; char *disk_config_token; char *config_revalidated_token; + int source_index_fd; unsigned char current_config_hash[GIT_MAX_RAWSZ]; unsigned char disk_config_hash[GIT_MAX_RAWSZ]; unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; @@ -32,6 +34,7 @@ struct clean_status_state { unsigned config_revalidated : 1; unsigned initial_coherent : 1; unsigned source_identity_valid : 1; + unsigned source_index_identity_valid : 1; unsigned disk_config_valid : 1; unsigned disk_semantic_valid : 1; unsigned disk_attr_valid : 1; diff --git a/clean-status.c b/clean-status.c index 96ea9511ac2a2b..ea1d02e00ae09f 100644 --- a/clean-status.c +++ b/clean-status.c @@ -17,6 +17,7 @@ struct clean_status_state *clean_status_get_state(struct index_state *istate) { if (!istate->clean_status) { CALLOC_ARRAY(istate->clean_status, 1); + istate->clean_status->source_index_fd = -1; clean_status_manifest_init(&istate->clean_status->manifest); strbuf_init(&istate->clean_status->disk_config_raw, 0); } @@ -184,6 +185,8 @@ void clean_status_release(struct index_state *istate) { if (!istate->clean_status) return; + if (istate->clean_status->source_index_fd >= 0) + close(istate->clean_status->source_index_fd); clean_status_manifest_release(&istate->clean_status->manifest); strbuf_release(&istate->clean_status->disk_config_raw); free(istate->clean_status->disk_config_token); diff --git a/clean-status.h b/clean-status.h index a609769f9c56ea..f6c001a6e834f4 100644 --- a/clean-status.h +++ b/clean-status.h @@ -44,6 +44,9 @@ int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); +/* Takes ownership of fd only when it returns 1. */ +int clean_status_retain_source_index_fd(struct index_state *istate, int fd, + const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st); diff --git a/meson.build b/meson.build index 59b7097fca049d..1c52ff8fb71826 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-history-store.c', 'clean-status-history.c', 'clean-status-identity.c', 'clean-status-index.c', diff --git a/read-cache.c b/read-cache.c index 40f2fffbf37890..0e8be0ab995058 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2288,7 +2288,7 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) istate->timestamp.sec = 0; istate->timestamp.nsec = 0; - fd = open(path, O_RDONLY); + fd = git_open_cloexec(path, O_RDONLY); if (fd < 0) { if (!must_exist && errno == ENOENT) { set_new_index_sparsity(istate); @@ -2307,10 +2307,14 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) die(_("%s: index file smaller than expected"), path); mmap = xmmap_gently(NULL, mmap_size, PROT_READ, MAP_PRIVATE, fd, 0); - if (mmap == MAP_FAILED) + if (mmap == MAP_FAILED) { + int mmap_errno = errno; + + close(fd); + errno = mmap_errno; die_errno(_("%s: unable to map index file%s"), path, mmap_os_err()); - close(fd); + } hdr = (const struct cache_header *)mmap; if (verify_hdr(hdr, mmap_size) < 0) @@ -2402,9 +2406,13 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) else ensure_correct_sparsity(istate); + if (!clean_status_retain_source_index_fd(istate, fd, &st)) + close(fd); + return istate->cache_nr; unmap: + close(fd); munmap((void *)mmap, mmap_size); die(_("index file corrupt")); } diff --git a/t/meson.build b/t/meson.build index e4131eac00a60e..a250fa109d3f4c 100644 --- a/t/meson.build +++ b/t/meson.build @@ -3,6 +3,7 @@ clar_test_suites = [ 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-clean-status-history.c', + 'unit-tests/u-clean-status-history-store.c', 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', diff --git a/t/unit-tests/u-clean-status-history-store.c b/t/unit-tests/u-clean-status-history-store.c new file mode 100644 index 00000000000000..53fdf0c92d3628 --- /dev/null +++ b/t/unit-tests/u-clean-status-history-store.c @@ -0,0 +1,363 @@ +#include "unit-test.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-history-store.h" +#include "clean-status-index.h" +#include "dir.h" +#include "hash-framing.h" +#include "hex.h" +#include "strbuf.h" + +struct history_store_fixture { + char *directory; + struct strbuf index_path; +}; + +static void fixture_init(struct history_store_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct strbuf index = STRBUF_INIT; + const char *tmp = getenv("TMPDIR"); + uint32_t value; + + memset(fixture, 0, sizeof(*fixture)); + fixture->index_path = (struct strbuf)STRBUF_INIT; + fixture->directory = xstrfmt("%s/status-history-store.XXXXXX", + tmp ? tmp : "/tmp"); + cl_assert(mkdtemp(fixture->directory) != NULL); + strbuf_addf(&fixture->index_path, "%s/index", fixture->directory); + strbuf_addstr(&index, "DIRC"); + put_be32(&value, 4); + strbuf_add(&index, &value, sizeof(value)); + put_be32(&value, 5); + strbuf_add(&index, &value, sizeof(value)); + strbuf_addchars(&index, 2, algo->rawsz); + write_file_buf(fixture->index_path.buf, index.buf, index.len); + strbuf_release(&index); +} + +static void fixture_release(struct history_store_fixture *fixture) +{ + struct strbuf cleanup = STRBUF_INIT; + + strbuf_addstr(&cleanup, fixture->directory); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + strbuf_release(&fixture->index_path); + free(fixture->directory); +} + +static void replace_checksum(struct strbuf *encoded, + const struct git_hash_algo *algo) +{ + strbuf_setlen(encoded, encoded->len - algo->rawsz); + hash_append_checksum(encoded, algo); +} + +static struct strbuf history_store_path_for_index( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo) +{ + static const char domain[] = "git-clean-status-history-namespace-v1"; + struct git_hash_ctx ctx; + unsigned char hash[GIT_MAX_RAWSZ]; + char hex[GIT_MAX_HEXSZ + 1]; + struct strbuf path = STRBUF_INIT; + + git_hash_init(&ctx, algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, proof_namespace, strlen(proof_namespace)); + git_hash_final(hash, &ctx); + hash_to_hex_algop_r(hex, hash, algo); + strbuf_addf(&path, "%s.csh1.%s", index_path, hex); + return path; +} + +static struct strbuf history_store_path( + struct history_store_fixture *fixture, const char *proof_namespace, + const struct git_hash_algo *algo) +{ + return history_store_path_for_index( + fixture->index_path.buf, proof_namespace, algo); +} + +static size_t count_history_store_files(const char *directory, + const char *index_basename, + const struct git_hash_algo *algo) +{ + struct strbuf prefix = STRBUF_INIT; + struct dirent *de; + DIR *dir = opendir(directory); + size_t nr = 0; + + cl_assert(dir != NULL); + strbuf_addf(&prefix, "%s.csh1.", index_basename); + while ((de = readdir(dir))) { + const char *suffix; + + if (!starts_with(de->d_name, prefix.buf)) + continue; + suffix = de->d_name + prefix.len; + if (strlen(suffix) == algo->hexsz && + strspn(suffix, "0123456789abcdef") == algo->hexsz) + nr++; + } + closedir(dir); + strbuf_release(&prefix); + return nr; +} + +static void require_local_apfs(const char *path MAYBE_UNUSED) +{ +#ifdef __APPLE__ + struct statfs fs; + int fd = git_open_cloexec(path, O_RDONLY); + + if (fd < 0 || fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs")) { + if (fd >= 0) + close(fd); + cl_skip(); + } + close(fd); +#else + cl_skip(); +#endif +} + +void test_clean_status_history_store__rejects_incomplete_checkpoints(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + static const unsigned char fsmn[] = "fsmn"; + static const unsigned char fscf[] = "fscf"; + struct clean_status_history_checkpoint checkpoint = { 0 }, parsed; + struct strbuf encoded = STRBUF_INIT; + const size_t flags_offset = 4 + sizeof(uint32_t); + + memset(checkpoint.index_hash, 1, algo->rawsz); + checkpoint.fsmonitor = fsmn; + checkpoint.fsmonitor_len = sizeof(fsmn) - 1; + checkpoint.fsmonitor_config = fscf; + checkpoint.fsmonitor_config_len = sizeof(fscf) - 1; + cl_assert_equal_i(clean_status_history_checkpoint_write( + &encoded, "proof-schema", &checkpoint, algo), 0); + + /* The optional UNTR and FSUC pair may both be absent. */ + cl_assert_equal_i(clean_status_history_checkpoint_parse( + &parsed, "proof-schema", encoded.buf, encoded.len, algo), 0); + cl_assert_equal_i(parsed.fsmonitor_len, sizeof(fsmn) - 1); + cl_assert(!memcmp(parsed.fsmonitor, fsmn, sizeof(fsmn) - 1)); + cl_assert_equal_i(parsed.untracked_cache_len, 0); + cl_assert(parsed.untracked_cache == NULL); + cl_assert_equal_i(parsed.fsmonitor_config_len, sizeof(fscf) - 1); + cl_assert(!memcmp(parsed.fsmonitor_config, fscf, sizeof(fscf) - 1)); + cl_assert_equal_i(parsed.fsmonitor_untracked_len, 0); + cl_assert(parsed.fsmonitor_untracked == NULL); + + /* A checkpoint must contain both FSMN and FSCF. */ + put_be32(encoded.buf + flags_offset, 1U << 1); + replace_checksum(&encoded, algo); + cl_assert_equal_i(clean_status_history_checkpoint_parse( + &parsed, "proof-schema", encoded.buf, encoded.len, algo), -1); + + /* UNTR is useful only together with its FSUC binding. */ + put_be32(encoded.buf + flags_offset, + (1U << 0) | (1U << 1) | (1U << 2)); + replace_checksum(&encoded, algo); + cl_assert_equal_i(clean_status_history_checkpoint_parse( + &parsed, "proof-schema", encoded.buf, encoded.len, algo), -1); + + strbuf_release(&encoded); +} + +void test_clean_status_history_store__keeps_namespaces_independent(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_history_store_record first_record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_history_store_record second_record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_history_checkpoint first = { 0 }, second = { 0 }; + struct clean_status_index_snapshot snapshot; + struct history_store_fixture fixture; + static const unsigned char first_fsmn[] = "first-fsmn"; + static const unsigned char first_fscf[] = "first-fscf"; + static const unsigned char second_fsmn[] = "second-fsmn"; + static const unsigned char second_fscf[] = "second-fscf"; + + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + fixture_init(&fixture, algo); + memset(first.index_hash, 1, algo->rawsz); + first.fsmonitor = first_fsmn; + first.fsmonitor_len = sizeof(first_fsmn) - 1; + first.fsmonitor_config = first_fscf; + first.fsmonitor_config_len = sizeof(first_fscf) - 1; + memset(second.index_hash, 2, algo->rawsz); + second.fsmonitor = second_fsmn; + second.fsmonitor_len = sizeof(second_fsmn) - 1; + second.fsmonitor_config = second_fscf; + second.fsmonitor_config_len = sizeof(second_fscf) - 1; + + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.index_path.buf, algo), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema-one", &first, + &snapshot, algo), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema-two", &second, + &snapshot, algo), 0); + cl_assert_equal_i(clean_status_history_store_load( + fixture.index_path.buf, "proof-schema-one", algo, + &first_record), 0); + cl_assert_equal_i(clean_status_history_store_load( + fixture.index_path.buf, "proof-schema-two", algo, + &second_record), 0); + cl_assert_equal_i(first_record.checkpoint.fsmonitor_config_len, + sizeof(first_fscf) - 1); + cl_assert(!memcmp(first_record.checkpoint.fsmonitor_config, + first_fscf, sizeof(first_fscf) - 1)); + cl_assert_equal_i(second_record.checkpoint.fsmonitor_config_len, + sizeof(second_fscf) - 1); + cl_assert(!memcmp(second_record.checkpoint.fsmonitor_config, + second_fscf, sizeof(second_fscf) - 1)); + + clean_status_history_store_record_release(&second_record); + clean_status_history_store_record_release(&first_record); + clean_status_index_snapshot_release(&snapshot); + fixture_release(&fixture); +} + +void test_clean_status_history_store__does_not_rewrite_unchanged_checkpoint(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + static const unsigned char fsmn[] = "fsmn"; + static const unsigned char fscf[] = "fscf"; + struct clean_status_history_checkpoint checkpoint = { 0 }; + struct clean_status_index_snapshot snapshot; + struct history_store_fixture fixture; + struct strbuf path; + struct stat before, after; + + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + fixture_init(&fixture, algo); + memset(checkpoint.index_hash, 1, algo->rawsz); + checkpoint.fsmonitor = fsmn; + checkpoint.fsmonitor_len = sizeof(fsmn) - 1; + checkpoint.fsmonitor_config = fscf; + checkpoint.fsmonitor_config_len = sizeof(fscf) - 1; + path = history_store_path(&fixture, "proof-schema", algo); + + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.index_path.buf, algo), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema", &checkpoint, + &snapshot, algo), 0); + cl_assert_equal_i(lstat(path.buf, &before), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema", &checkpoint, + &snapshot, algo), 0); + cl_assert_equal_i(lstat(path.buf, &after), 0); + cl_assert_equal_i(before.st_ino, after.st_ino); + + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&path); + fixture_release(&fixture); +} + +void test_clean_status_history_store__bounds_namespaces(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + static const unsigned char fsmn[] = "fsmn"; + static const unsigned char fscf[] = "fscf"; + struct clean_status_history_checkpoint checkpoint = { 0 }; + struct clean_status_index_snapshot snapshot; + struct clean_status_history_store_record record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct history_store_fixture fixture; + struct strbuf cwd = STRBUF_INIT; + struct strbuf encoded = STRBUF_INIT; + struct strbuf extra = STRBUF_INIT; + struct utimbuf times; + char namespace[32]; + + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + fixture_init(&fixture, algo); + checkpoint.fsmonitor = fsmn; + checkpoint.fsmonitor_len = sizeof(fsmn) - 1; + checkpoint.fsmonitor_config = fscf; + checkpoint.fsmonitor_config_len = sizeof(fscf) - 1; + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.index_path.buf, algo), 0); + cl_assert_equal_i(strbuf_getcwd(&cwd), 0); + cl_assert_equal_i(chdir(fixture.directory), 0); + for (size_t i = 0; i < 10; i++) { + struct strbuf path; + + xsnprintf(namespace, sizeof(namespace), "proof-schema-%"PRIuMAX, + (uintmax_t)i); + memset(checkpoint.index_hash, i + 1, algo->rawsz); + cl_assert_equal_i(clean_status_history_store_install( + "index", namespace, &checkpoint, &snapshot, algo), 0); + path = history_store_path_for_index("index", namespace, algo); + times.actime = times.modtime = 100 + i; + cl_assert_equal_i(utime(path.buf, ×), 0); + strbuf_release(&path); + } + for (size_t i = 0; i < 10; i++) { + xsnprintf(namespace, sizeof(namespace), "proof-schema-%"PRIuMAX, + (uintmax_t)i); + if (i < 2) { + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), -1); + } else { + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), 0); + clean_status_history_store_record_release(&record); + } + } + cl_assert_equal_i(count_history_store_files(".", "index", algo), 8); + + /* + * An identical reinstall must retain its target even when a relative + * index path makes the scanned candidate spell that path as "./...". + */ + xsnprintf(namespace, sizeof(namespace), "proof-schema-2"); + { + struct strbuf retained = history_store_path_for_index( + "index", namespace, algo); + + times.actime = times.modtime = 1; + cl_assert_equal_i(utime(retained.buf, ×), 0); + strbuf_addf(&extra, "index.csh1.%0*d", (int)algo->hexsz, 0); + cl_assert(strbuf_read_file(&encoded, retained.buf, 0) > 0); + write_file_buf(extra.buf, encoded.buf, encoded.len); + times.actime = times.modtime = 1000; + cl_assert_equal_i(utime(extra.buf, ×), 0); + cl_assert_equal_i( + count_history_store_files(".", "index", algo), 9); + + memset(checkpoint.index_hash, 3, algo->rawsz); + cl_assert_equal_i(clean_status_history_store_install( + "index", namespace, &checkpoint, &snapshot, algo), 0); + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), 0); + clean_status_history_store_record_release(&record); + cl_assert_equal_i( + count_history_store_files(".", "index", algo), 8); + strbuf_release(&retained); + } + xsnprintf(namespace, sizeof(namespace), "proof-schema-3"); + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), -1); + cl_assert_equal_i(chdir(cwd.buf), 0); + + clean_status_history_store_record_release(&record); + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&extra); + strbuf_release(&encoded); + strbuf_release(&cwd); + fixture_release(&fixture); +} diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index 5d769692eea894..35b4637a22be28 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -1,11 +1,287 @@ #include "unit-test.h" #include "clean-status.h" +#include "clean-status-index.h" #include "clean-status-internal.h" #include "dir.h" #include "read-cache-ll.h" +#include "repository.h" #include "strbuf.h" #include "wrapper.h" +struct index_fixture { + char *path; + int fd; + struct stat st; + struct object_id checksum; +}; + +static void fixture_init(struct index_fixture *fixture, + const struct git_hash_algo *algo) +{ + const char *tmp = getenv("TMPDIR"); + unsigned char header[12] = "DIRC"; + unsigned char hash[GIT_MAX_RAWSZ]; + static const char payload[] = "payload"; + + memset(fixture, 0, sizeof(*fixture)); + fixture->path = xstrfmt("%s/index-snapshot.XXXXXX", + tmp ? tmp : "/tmp"); + fixture->fd = mkstemp(fixture->path); + cl_assert(fixture->fd >= 0); + put_be32(header + 4, 4); + put_be32(header + 8, 7); + memset(hash, 1, algo->rawsz); + oidread(&fixture->checksum, hash, algo); + cl_assert_equal_i(write_in_full(fixture->fd, header, sizeof(header)), + sizeof(header)); + cl_assert_equal_i(write_in_full(fixture->fd, payload, sizeof(payload)), + sizeof(payload)); + cl_assert_equal_i(write_in_full(fixture->fd, hash, algo->rawsz), + algo->rawsz); + cl_assert_equal_i(fstat(fixture->fd, &fixture->st), 0); +} + +static void fixture_release(struct index_fixture *fixture) +{ + cl_assert_equal_i(close(fixture->fd), 0); + cl_assert_equal_i(unlink(fixture->path), 0); + free(fixture->path); +} + +static void write_at(int fd, const void *data, size_t len, off_t offset) +{ + cl_assert_equal_i(lseek(fd, offset, SEEK_SET), offset); + cl_assert_equal_i(write_in_full(fd, data, len), len); +} + +static void fixture_clear_checksum(struct index_fixture *fixture, + const struct git_hash_algo *algo) +{ + unsigned char null_hash[GIT_MAX_RAWSZ] = { 0 }; + + write_at(fixture->fd, null_hash, algo->rawsz, + fixture->st.st_size - algo->rawsz); + oidclr(&fixture->checksum, algo); + cl_assert_equal_i(fstat(fixture->fd, &fixture->st), 0); +} + +static void assert_reads_snapshot(const struct git_hash_algo *algo) +{ + struct index_fixture fixture; + struct clean_status_index_snapshot snapshot; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), 0); + cl_assert_equal_i(snapshot.version, 4); + cl_assert_equal_i(snapshot.cache_nr, 7); + cl_assert(oideq(&snapshot.checksum, &fixture.checksum)); + cl_assert(clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + clean_status_index_snapshot_release(&snapshot); + fixture_release(&fixture); +} + +void test_clean_status_index__reads_both_object_formats(void) +{ + assert_reads_snapshot(&hash_algos[GIT_HASH_SHA1]); + assert_reads_snapshot(&hash_algos[GIT_HASH_SHA256]); +} + +void test_clean_status_index__rejects_invalid_headers(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct index_fixture fixture; + struct clean_status_index_snapshot snapshot; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + uint32_t value; + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + write_at(fixture.fd, "NOPE", 4, 0); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + write_at(fixture.fd, "DIRC", 4, 0); + put_be32(&value, 1); + write_at(fixture.fd, &value, sizeof(value), 4); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + fixture_release(&fixture); +} + +static void assert_rejects_null_checksum(const struct git_hash_algo *algo) +{ + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + fixture_clear_checksum(&fixture, algo); + oidcpy(&istate.oid, &fixture.checksum); + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.path, algo), -1); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + fixture_release(&fixture); +} + +void test_clean_status_index__rejects_null_checksums(void) +{ + assert_rejects_null_checksum(&hash_algos[GIT_HASH_SHA1]); + assert_rejects_null_checksum(&hash_algos[GIT_HASH_SHA256]); +} + +static void assert_pins_null_checksum_source( + const struct git_hash_algo *algo) +{ + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture, replacement; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct index_state parsed = INDEX_STATE_INIT(&repo); + char *moved; + + fixture_init(&fixture, algo); + fixture_init(&replacement, algo); + fixture_clear_checksum(&fixture, algo); + fixture_clear_checksum(&replacement, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + parsed.version = 4; + parsed.cache_nr = 7; + oidcpy(&parsed.oid, &replacement.checksum); + clean_status_get_state(&istate); + clean_status_record_source_identity(&istate, &fixture.st); + clean_status_get_state(&parsed); + clean_status_record_source_identity(&parsed, &replacement.st); + + if (clean_status_identity_is_durable()) { + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), 0); + cl_assert(clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + + /* + * Model an A-to-B-to-A replacement while a second index state + * parses B. The named path and held descriptor are back on A, + * while the parsed state's source identity still binds it to B. + */ + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &parsed)); + clean_status_index_snapshot_release(&snapshot); + } else { + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + clean_status_release(&istate); + clean_status_release(&parsed); + fixture_release(&fixture); + fixture_release(&replacement); + return; + } + + moved = xstrfmt("%s.old", fixture.path); + cl_assert_equal_i(rename(fixture.path, moved), 0); + cl_assert_equal_i(rename(replacement.path, fixture.path), 0); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + + clean_status_release(&istate); + clean_status_release(&parsed); + cl_assert_equal_i(close(fixture.fd), 0); + cl_assert_equal_i(close(replacement.fd), 0); + cl_assert_equal_i(unlink(fixture.path), 0); + cl_assert_equal_i(unlink(moved), 0); + free(moved); + free(fixture.path); + free(replacement.path); +} + +void test_clean_status_index__pins_null_checksum_source_identity(void) +{ + assert_pins_null_checksum_source(&hash_algos[GIT_HASH_SHA1]); + assert_pins_null_checksum_source(&hash_algos[GIT_HASH_SHA256]); +} + +void test_clean_status_index__pins_named_index_identity(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); +#ifndef GIT_WINDOWS_NATIVE + char *moved; + int replacement; +#endif + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), 0); + cl_assert(clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + + istate.cache_nr++; + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + istate.cache_nr--; + +#ifndef GIT_WINDOWS_NATIVE + moved = xstrfmt("%s.old", fixture.path); + cl_assert_equal_i(rename(fixture.path, moved), 0); + replacement = open(fixture.path, O_WRONLY | O_CREAT | O_EXCL, 0600); + cl_assert(replacement >= 0); + cl_assert_equal_i(close(replacement), 0); + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + cl_assert_equal_i(unlink(fixture.path), 0); + cl_assert_equal_i(rename(moved, fixture.path), 0); + free(moved); +#endif + clean_status_index_snapshot_release(&snapshot); + +#ifndef GIT_WINDOWS_NATIVE + { + char *symlink_path = xstrfmt("%s.link", fixture.path); + + cl_assert_equal_i(symlink(fixture.path, symlink_path), 0); + repo.index_file = symlink_path; + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + repo.index_file = fixture.path; + cl_assert_equal_i(unlink(symlink_path), 0); + free(symlink_path); + } +#endif + + fixture_release(&fixture); +} + void test_clean_status_index__binds_the_parsed_source(void) { const char *tmp = getenv("TMPDIR"); @@ -41,3 +317,76 @@ void test_clean_status_index__binds_the_parsed_source(void) strbuf_release(&path); free(worktree); } + +void test_clean_status_index__digests_only_persistent_logical_entries(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct cache_entry *ce; + unsigned char baseline[GIT_MAX_RAWSZ]; + unsigned char changed[GIT_MAX_RAWSZ]; + static const char path[] = "tracked"; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + ce = make_empty_cache_entry(&istate, sizeof(path) - 1); + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = sizeof(path) - 1; + memcpy(ce->name, path, sizeof(path)); + memset(ce->oid.hash, 1, repo.hash_algo->rawsz); + oid_set_algo(&ce->oid, repo.hash_algo); + istate.cache[0] = ce; + + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, baseline), 0); + ce->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE | CE_HASHED; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(!memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_flags |= CE_VALID; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_flags = CE_SKIP_WORKTREE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_flags = CE_INTENT_TO_ADD; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_flags = create_ce_flags(1); + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_flags = 0; + ce->ce_mode = S_IFREG | 0755; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_mode = S_IFREG | 0644; + ce->oid.hash[0] = 2; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->oid.hash[0] = 1; + ce->name[0] = 'T'; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->name[0] = 't'; + ce->ce_flags = CE_WT_REMOVE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + ce->ce_flags = CE_CONTENT_CHECK_REQUIRED; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + ce->ce_flags = CE_UPDATE_IN_BASE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + + release_index(&istate); +} From 949c3c6812b84882571001b0742ea098bbc69ec9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:07:02 -0500 Subject: [PATCH 033/403] fsmonitor: rebuild sparse-index history without expanding the live index A collapsed sparse index cannot enumerate every tracked path needed for a complete attribute manifest. Expanding the live index would discard the sparse representation that status is supposed to preserve. Pin the named index with S09/P02, reread the verified index into a scratch index, and expand only that scratch copy. Build the complete manifest from the expanded scratch index. Check that both the parsed scratch state and original live state still match the held descriptor and stored trailer checksum; discard the manifest if either check fails. Add a sparse-checkout regression that checks the collapsed outside entry before and after status while detecting a same-size tracked rewrite. Extend the existing index unit case with a parsed A-to-B-to-A mismatch. Failed snapshot validation retains ordinary full-invalidation fallback. Signed-off-by: Taylor Blau --- clean-status-manifest.c | 31 ++++++++++++++++++++++++++--- t/unit-tests/u-clean-status-index.c | 14 +++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/clean-status-manifest.c b/clean-status-manifest.c index b13066a23c2c47..b2c8aaec97e71e 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "attr-manifest.h" +#include "clean-status-index.h" #include "clean-status-manifest.h" #include "dir.h" #include "fsmonitor-clean-proof.h" @@ -7,6 +8,7 @@ #include "hash-framing.h" #include "read-cache-ll.h" #include "repository.h" +#include "sparse-index.h" #include "trace2.h" #include "worktree-attr-manifest.h" @@ -20,10 +22,33 @@ static int build_manifest(struct index_state *istate, unsigned char *manifest_hash, struct worktree_attr_manifest_stats *stats) { - if (istate->sparse_index != INDEX_EXPANDED) + struct clean_status_index_snapshot snapshot; + struct index_state scratch = INDEX_STATE_INIT(istate->repo); + int ret = -1; + + if (istate->sparse_index == INDEX_EXPANDED) + return worktree_attr_manifest_build( + istate, manifest, manifest_hash, stats); + if (clean_status_index_snapshot_pin(&snapshot, istate)) return -1; - return worktree_attr_manifest_build( - istate, manifest, manifest_hash, stats); + scratch.fsmonitor_has_run_once = 1; + if (read_index_from(&scratch, istate->repo->index_file, + istate->repo->gitdir) < 0 || + !clean_status_index_snapshot_still_matches(&snapshot, &scratch)) + goto done; + ensure_full_index(&scratch); + ret = worktree_attr_manifest_build( + &scratch, manifest, manifest_hash, stats); + if (ret || + !clean_status_index_snapshot_still_matches(&snapshot, istate)) { + strbuf_reset(manifest); + ret = -1; + } + +done: + release_index(&scratch); + clean_status_index_snapshot_release(&snapshot); + return ret; } void clean_status_manifest_init(struct clean_status_manifest_state *state) diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index 35b4637a22be28..0a62f4dfeede5e 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -229,6 +229,8 @@ void test_clean_status_index__pins_named_index_identity(void) struct index_fixture fixture; struct repository repo = { 0 }; struct index_state istate = INDEX_STATE_INIT(&repo); + struct index_state parsed = INDEX_STATE_INIT(&repo); + unsigned char replacement_hash[GIT_MAX_RAWSZ]; #ifndef GIT_WINDOWS_NATIVE char *moved; int replacement; @@ -246,6 +248,18 @@ void test_clean_status_index__pins_named_index_identity(void) cl_assert(clean_status_index_snapshot_still_matches( &snapshot, &istate)); + /* + * Model an A-to-B-to-A replacement while a consumer parses B. + * Matching the restored named path is insufficient unless the parsed + * state is also bound to the pinned A contents. + */ + memset(replacement_hash, 2, algo->rawsz); + parsed.version = 4; + parsed.cache_nr = 7; + oidread(&parsed.oid, replacement_hash, algo); + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &parsed)); + istate.cache_nr++; cl_assert(!clean_status_index_snapshot_still_matches( &snapshot, &istate)); From b0d71acb3046c663658178556465fdc4adce0b6e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:41:16 -0500 Subject: [PATCH 034/403] status: bind each closing token to its complete proof epoch A clean provider response closes only the filesystem interval after its starting token. It cannot certify a refresh that started before the named index, configuration, attributes, and manifest were captured, or one whose semantic inputs subsequently changed. Capture the proof epoch before each refresh whose provider token may be accepted. Pin the named index, starting token, repository configuration, external attribute fingerprint, and complete full-index manifest. Recheck those inputs after the closing query. Record semantic history only for the accepted token; reject missing or changed inputs and fall back to a complete refresh. For a null-checksum index, let only the proof-epoch pin use the process-local reader descriptor retained by the preceding patch. The proof-only exception rechecks both the retained source descriptor's original stat identity and the current named path when pinning and closing the epoch. Generic certification and persisted CSHS continue to reject the null trailer. Always rebuild the manifest when physical history is unavailable, even if the stored semantic configuration already matches. Without that manifest, a trivial response invalidates the old binding and leaves the closing query with no complete epoch to bind, so each later status repeats the fallback. Teach this lifecycle to restore and save complete external history checkpoints through the preceding CSHS store. A restore validates the logical index and all FSMN, UNTR/FSUC, and FSCF sections in scratch state, then rechecks the pinned index before installing them together. A save requires the same logical entries before and after status and a closed, writable proof. Keep both paths dormant until a later patch enables them only for a normal top-level status. A retry inside a captured epoch can also lose a freshly acquired CE_FSMONITOR_VALID bit when replace_index_entry() applies its generic conservative invalidation. Mark proof-epoch refreshes explicitly and restore only a validity bit acquired by the replacement itself. Changed or rejected closures still invalidate those provisional bits before falling back. Register clean-status-epoch.c in Make and Meson alongside its first production consumer in wt-status.c. Add scripted regressions for capture-before-refresh ordering and recovery from unbound physical history. Add unit coverage for the complete full-index manifest, the restricted post-status logical-digest exception, retained-descriptor lifetime, a stat-visible same-inode size change, and atomic path replacement. Later activation patches cover external checkpoint recovery and the immediate warm run. Signed-off-by: Taylor Blau --- Makefile | 1 + clean-status-config.c | 26 ++ clean-status-config.h | 4 + clean-status-epoch.c | 193 ++++++++++++++ clean-status-history.c | 339 ++++++++++++++++++++++++- clean-status-index.c | 113 +++++++-- clean-status-index.h | 8 + clean-status-internal.h | 3 + clean-status.c | 58 +++++ clean-status.h | 26 ++ fsmonitor-ll.h | 2 + fsmonitor.c | 43 +++- meson.build | 1 + read-cache-ll.h | 1 + read-cache.c | 12 +- t/t7519-status-fsmonitor.sh | 23 ++ t/unit-tests/u-clean-status-index.c | 193 ++++++++++++++ t/unit-tests/u-clean-status-manifest.c | 47 ++++ wt-status.c | 84 +++++- 19 files changed, 1131 insertions(+), 46 deletions(-) create mode 100644 clean-status-epoch.c diff --git a/Makefile b/Makefile index 5da7272e3e5e67..85cbac77e5faea 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-epoch.o LIB_OBJS += clean-status-history-store.o LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o diff --git a/clean-status-config.c b/clean-status-config.c index 951893ac833117..0cbab0fe50acd4 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -2,6 +2,7 @@ #include "clean-status-config.h" #include "config.h" #include "hash-framing.h" +#include "repository.h" #include "strbuf.h" #define CLEAN_STATUS_FILTER_PROOF_DOMAIN \ @@ -92,3 +93,28 @@ void clean_status_config_final(struct clean_status_config_digest *digest) git_hash_final(digest->semantic_hash, &digest->semantic_ctx); digest->finalized = 1; } + +static int config_digest_callback(const char *key, const char *value, + const struct config_context *ctx, + void *data) +{ + clean_status_config_add(data, key, value, ctx); + return 0; +} + +int clean_status_config_read_repository( + struct repository *repo, + struct clean_status_config_digest *digest) +{ + struct config_options opts = { 0 }; + + clean_status_config_init(digest, repo->hash_algo); + opts.respect_includes = 1; + opts.commondir = repo->commondir; + opts.git_dir = repo->gitdir; + if (config_with_options(config_digest_callback, digest, NULL, + repo, &opts) < 0) + return -1; + clean_status_config_final(digest); + return 0; +} diff --git a/clean-status-config.h b/clean-status-config.h index 47420ed282d4d9..0a4275ea92242f 100644 --- a/clean-status-config.h +++ b/clean-status-config.h @@ -4,6 +4,7 @@ #include "hash.h" struct config_context; +struct repository; struct clean_status_config_digest { struct git_hash_ctx ctx; @@ -22,5 +23,8 @@ void clean_status_config_add(struct clean_status_config_digest *digest, const char *key, const char *value, const struct config_context *ctx); void clean_status_config_final(struct clean_status_config_digest *digest); +int clean_status_config_read_repository( + struct repository *repo, + struct clean_status_config_digest *digest); #endif /* CLEAN_STATUS_CONFIG_H */ diff --git a/clean-status-epoch.c b/clean-status-epoch.c new file mode 100644 index 00000000000000..b5760649cb3c10 --- /dev/null +++ b/clean-status-epoch.c @@ -0,0 +1,193 @@ +#include "git-compat-util.h" +#include "attr-fingerprint.h" +#include "clean-status.h" +#include "clean-status-index.h" +#include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" + +/* + * State captured before a worktree scan. Every recorded input must still + * match after the closing provider query before scan results are accepted. + */ +struct clean_status_proof_epoch { + struct index_state *istate; + struct clean_status_index_snapshot index; + char *scan_start_token; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char attr_hash[GIT_MAX_RAWSZ]; + unsigned char attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned char manifest_hash[GIT_MAX_RAWSZ]; + uint32_t manifest_flags; + unsigned semantic_explicit : 1; + unsigned attr_sources_present : 1; + unsigned filter_configured : 1; + unsigned filter_scope_valid : 1; + unsigned strong_mismatch : 1; + unsigned config_mismatch : 1; +}; + +static int config_matches_epoch( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + struct clean_status_state *state = istate->clean_status; + struct clean_status_config_digest digest; + const struct git_hash_algo *algo = istate->repo->hash_algo; + + if (clean_status_config_read_repository(istate->repo, &digest)) + return 0; + return digest.finalized && + digest.filter_configured == epoch->filter_configured && + digest.semantic_config_explicit == epoch->semantic_explicit && + !memcmp(digest.hash, epoch->config_hash, algo->rawsz) && + !memcmp(digest.semantic_hash, epoch->semantic_hash, algo->rawsz) && + state && state->current_config_valid && + state->current_semantic_valid && + !memcmp(state->current_config_hash, epoch->config_hash, + algo->rawsz) && + !memcmp(state->current_semantic_hash, epoch->semantic_hash, + algo->rawsz); +} + +struct clean_status_proof_epoch *clean_status_capture_proof_epoch( + struct index_state *istate, + const struct attr_source_snapshot *attrs, + int validate_filter_scope) +{ + struct clean_status_state *state = istate->clean_status; + struct clean_status_proof_epoch *epoch; + struct clean_status_config_digest digest; + struct clean_status_index_snapshot index; + const struct attr_fingerprint *fingerprint = + attr_source_snapshot_fingerprint(attrs); + uint32_t manifest_requirements = + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + if (istate->split_index || !state || !state->current_config_valid || + !state->config_enforced || + !state->current_semantic_valid || !state->current_attr_valid || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.global_fallback || + (clean_status_filter_scope_needs_validation(istate) && + !validate_filter_scope) || + (state->manifest.current_flags & manifest_requirements) != + manifest_requirements || + !fsmonitor_pending_token_from_provider(istate) || + !istate->fsmonitor_last_update_pending || !fingerprint || + memcmp(fingerprint->content_hash, state->current_attr_hash, + istate->repo->hash_algo->rawsz) || + memcmp(fingerprint->namespace_hash, + state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz) || + fingerprint->sources_present != + state->current_attr_sources_present) + return NULL; + if (clean_status_config_read_repository(istate->repo, &digest) || + !digest.finalized || + digest.filter_configured != state->filter_configured || + digest.semantic_config_explicit != + state->current_semantic_explicit || + memcmp(digest.hash, state->current_config_hash, + istate->repo->hash_algo->rawsz) || + memcmp(digest.semantic_hash, state->current_semantic_hash, + istate->repo->hash_algo->rawsz) || + clean_status_index_snapshot_pin_proof_epoch(&index, istate)) + return NULL; + + CALLOC_ARRAY(epoch, 1); + epoch->istate = istate; + epoch->index = index; + epoch->scan_start_token = xstrdup(istate->fsmonitor_last_update_pending); + memcpy(epoch->config_hash, state->current_config_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->semantic_hash, state->current_semantic_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->attr_hash, state->current_attr_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->attr_namespace_hash, + state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->manifest_hash, state->manifest.current_hash, + istate->repo->hash_algo->rawsz); + epoch->manifest_flags = state->manifest.current_flags; + epoch->semantic_explicit = state->current_semantic_explicit; + epoch->attr_sources_present = state->current_attr_sources_present; + epoch->filter_configured = state->filter_configured; + epoch->filter_scope_valid = state->filter_scope_valid; + epoch->strong_mismatch = state->strong_mismatch; + epoch->config_mismatch = state->config_mismatch; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/proof-epoch-captured", 1); + return epoch; +} + +int clean_status_proof_epoch_start_token_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + return epoch && epoch->istate == istate && epoch->scan_start_token && + fsmonitor_pending_token_from_provider(istate) && + istate->fsmonitor_last_update_pending && + !strcmp(epoch->scan_start_token, + istate->fsmonitor_last_update_pending); +} + +int clean_status_proof_epoch_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + struct clean_status_state *state; + struct attr_fingerprint attrs; + const struct git_hash_algo *algo = istate->repo->hash_algo; + int matched = 0; + + if (!epoch || epoch->istate != istate || + !fsmonitor_pending_token_from_provider(istate) || + !istate->fsmonitor_last_update_pending) + goto done; + state = istate->clean_status; + if (!state || !state->current_config_valid || !state->config_enforced || + !state->current_semantic_valid || !state->current_attr_valid || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.global_fallback || + state->manifest.current_flags != epoch->manifest_flags || + state->current_semantic_explicit != epoch->semantic_explicit || + state->current_attr_sources_present != epoch->attr_sources_present || + state->filter_configured != epoch->filter_configured || + state->filter_scope_valid != epoch->filter_scope_valid || + state->strong_mismatch != epoch->strong_mismatch || + state->config_mismatch != epoch->config_mismatch) + goto done; + if (attr_fingerprint_repository(istate->repo, &attrs) || + memcmp(attrs.content_hash, epoch->attr_hash, algo->rawsz) || + memcmp(attrs.namespace_hash, epoch->attr_namespace_hash, + algo->rawsz) || + attrs.sources_present != epoch->attr_sources_present || + memcmp(state->manifest.current_hash, epoch->manifest_hash, + algo->rawsz) || + !config_matches_epoch(istate, epoch) || + !clean_status_index_snapshot_still_matches_proof_epoch( + &epoch->index, istate)) + goto done; + matched = 1; +done: + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/proof-epoch-matched", matched); + return matched; +} + +void clean_status_release_proof_epoch( + struct clean_status_proof_epoch *epoch) +{ + if (!epoch) + return; + clean_status_index_snapshot_release(&epoch->index); + free(epoch->scan_start_token); + free(epoch); +} diff --git a/clean-status-history.c b/clean-status-history.c index fc917bb1bcbc3f..28f32092325d06 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1,11 +1,22 @@ #include "git-compat-util.h" +#include "abspath.h" #include "clean-status.h" +#include "clean-status-history-store.h" +#include "clean-status-index.h" #include "clean-status-internal.h" +#include "dir.h" +#include "environment.h" #include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" +#include "hash-framing.h" +#include "hex.h" #include "read-cache-ll.h" #include "repository.h" #include "strbuf.h" #include "trace2.h" +#include "ewah/ewok.h" + +#define CLEAN_STATUS_HISTORY_SCHEMA "builtin-fsmonitor-history-v2" static void invalidate_disk_history(struct clean_status_state *state) { @@ -56,7 +67,7 @@ int clean_status_read_fsmonitor_config(struct index_state *istate, return 0; } -void clean_status_prepare_fsmonitor_config(struct index_state *istate) +static int prepare_fsmonitor_config(struct index_state *istate, int trace) { struct clean_status_state *state = istate->clean_status; const struct git_hash_algo *algo = istate->repo->hash_algo; @@ -64,7 +75,7 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) int coherent; if (!state || !state->current_config_valid) - return; + return 0; token_coherent = state->disk_config_valid && !state->disk_config_invalid && istate->fsmonitor_token_valid && istate->fsmonitor_last_update && state->disk_config_token && @@ -106,10 +117,24 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) (!state->disk_attr_valid && state->current_attr_sources_present) || clean_status_filter_scope_needs_validation(istate)); - trace2_data_intmax("fsmonitor", istate->repo, - "config/coherent", coherent); - trace2_data_intmax("fsmonitor", istate->repo, - "semantic/initial-mismatch", state->strong_mismatch); + if (trace) { + trace2_data_intmax("fsmonitor", istate->repo, + "config/coherent", coherent); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/initial-mismatch", + state->strong_mismatch); + } + return coherent; +} + +void clean_status_prepare_fsmonitor_config(struct index_state *istate) +{ + prepare_fsmonitor_config(istate, 1); +} + +int clean_status_probe_fsmonitor_config(struct index_state *istate) +{ + return prepare_fsmonitor_config(istate, 0); } int clean_status_has_persistent_fsmonitor_semantic_history( @@ -264,6 +289,308 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, BUG("cannot preserve validated fsmonitor clean proof"); } +struct clean_status_external_checkpoint { + char proof_namespace[GIT_MAX_HEXSZ + 1]; + struct clean_status_history_checkpoint checkpoint; + struct strbuf fsmonitor; + struct strbuf untracked_cache; + struct strbuf fsmonitor_config; + struct strbuf fsmonitor_untracked; +}; + +static void clean_status_release_external_history( + struct clean_status_external_checkpoint *checkpoint); + +static int external_history_namespace(struct index_state *istate, char *out) +{ + static const char domain[] = "git-clean-status-history-key-v2"; + struct clean_status_state *state = istate->clean_status; + struct git_hash_ctx ctx; + unsigned char hash[GIT_MAX_RAWSZ]; + char *worktree = NULL, *gitdir = NULL, *commondir = NULL; + int ret = -1; + + if (!state || !state->current_config_valid || + !state->current_semantic_valid || !state->current_attr_valid || + !repo_get_work_tree(istate->repo)) + return -1; + worktree = real_pathdup(repo_get_work_tree(istate->repo), 0); + gitdir = real_pathdup(repo_get_git_dir(istate->repo), 0); + commondir = real_pathdup(repo_get_common_dir(istate->repo), 0); + if (!worktree || !gitdir || !commondir) + goto done; + git_hash_init(&ctx, istate->repo->hash_algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, CLEAN_STATUS_HISTORY_SCHEMA, + strlen(CLEAN_STATUS_HISTORY_SCHEMA)); + hash_length_delimited(&ctx, state->current_config_hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, state->current_semantic_hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, worktree, strlen(worktree)); + hash_length_delimited(&ctx, gitdir, strlen(gitdir)); + hash_length_delimited(&ctx, commondir, strlen(commondir)); + git_hash_final(hash, &ctx); + hash_to_hex_algop_r(out, hash, istate->repo->hash_algo); + ret = 0; + +done: + free(worktree); + free(gitdir); + free(commondir); + return ret; +} + +static struct clean_status_external_checkpoint * +clean_status_prepare_external_history(struct index_state *istate) +{ + struct clean_status_external_checkpoint *checkpoint; + struct clean_status_state *state = istate->clean_status; + const unsigned int acceleration_changes = + CE_ENTRY_CHANGED | FSMONITOR_CHANGED | UNTRACKED_CHANGED; + + if (!clean_status_external_history_enabled(istate) || + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || + !state || !state->source_logical_hash_valid || + !current_proof_is_writable(istate) || + (istate->cache_changed & ~acceleration_changes) || + has_racy_timestamp(istate)) + return NULL; + CALLOC_ARRAY(checkpoint, 1); + checkpoint->fsmonitor = (struct strbuf)STRBUF_INIT; + checkpoint->untracked_cache = (struct strbuf)STRBUF_INIT; + checkpoint->fsmonitor_config = (struct strbuf)STRBUF_INIT; + checkpoint->fsmonitor_untracked = (struct strbuf)STRBUF_INIT; + if (external_history_namespace( + istate, checkpoint->proof_namespace)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "namespace"); + goto fail; + } + if (clean_status_index_logical_digest_after_status( + istate, checkpoint->checkpoint.index_hash)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "logical-flags"); + goto fail; + } + if (memcmp(checkpoint->checkpoint.index_hash, + state->source_logical_hash, + istate->repo->hash_algo->rawsz)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "logical-change"); + goto fail; + } + snapshot_fsmonitor_extension(&checkpoint->fsmonitor, istate); + clean_status_write_fsmonitor_config( + &checkpoint->fsmonitor_config, istate); + if (istate->untracked) { + if (!istate->fsmonitor_untracked_valid || + !istate->fsmonitor_untracked_token || + strcmp(istate->fsmonitor_untracked_token, + istate->fsmonitor_last_update)) { + trace2_data_string( + "fsmonitor", istate->repo, + "history/external-save-reject", "untracked-token"); + goto fail; + } + write_untracked_extension( + &checkpoint->untracked_cache, istate->untracked); + write_fsmonitor_untracked_extension( + &checkpoint->fsmonitor_untracked, istate); + } + checkpoint->checkpoint.fsmonitor = + (const unsigned char *)checkpoint->fsmonitor.buf; + checkpoint->checkpoint.fsmonitor_len = checkpoint->fsmonitor.len; + checkpoint->checkpoint.untracked_cache = + (const unsigned char *)checkpoint->untracked_cache.buf; + checkpoint->checkpoint.untracked_cache_len = + checkpoint->untracked_cache.len; + checkpoint->checkpoint.fsmonitor_config = + (const unsigned char *)checkpoint->fsmonitor_config.buf; + checkpoint->checkpoint.fsmonitor_config_len = + checkpoint->fsmonitor_config.len; + checkpoint->checkpoint.fsmonitor_untracked = + (const unsigned char *)checkpoint->fsmonitor_untracked.buf; + checkpoint->checkpoint.fsmonitor_untracked_len = + checkpoint->fsmonitor_untracked.len; + return checkpoint; + +fail: + clean_status_release_external_history(checkpoint); + return NULL; +} + +static int clean_status_install_external_history( + struct index_state *istate, + struct clean_status_external_checkpoint *checkpoint) +{ + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + int installed = 0; + + if (!checkpoint || clean_status_index_snapshot_pin(&snapshot, istate) || + clean_status_history_store_install( + istate->repo->index_file, checkpoint->proof_namespace, + &checkpoint->checkpoint, &snapshot, + istate->repo->hash_algo)) + goto done; + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-stored", 1); + installed = 1; + +done: + clean_status_index_snapshot_release(&snapshot); + return installed; +} + +static void clean_status_release_external_history( + struct clean_status_external_checkpoint *checkpoint) +{ + if (!checkpoint) + return; + strbuf_release(&checkpoint->fsmonitor); + strbuf_release(&checkpoint->untracked_cache); + strbuf_release(&checkpoint->fsmonitor_config); + strbuf_release(&checkpoint->fsmonitor_untracked); + free(checkpoint); +} + +int clean_status_save_external_history(struct index_state *istate) +{ + struct clean_status_external_checkpoint *checkpoint = + clean_status_prepare_external_history(istate); + int saved = clean_status_install_external_history( + istate, checkpoint); + + clean_status_release_external_history(checkpoint); + return saved; +} + +static int on_index_history_is_coherent(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || !state->disk_config_seen) + return 0; + clean_status_probe_fsmonitor_config(istate); + prepare_fsmonitor_untracked(istate); + return state->initial_coherent && + (!istate->untracked || istate->fsmonitor_untracked_valid); +} + +int clean_status_restore_external_history(struct index_state *istate) +{ + struct clean_status_history_store_record record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + struct clean_status_state *state = istate->clean_status; + struct index_state parsed = INDEX_STATE_INIT(istate->repo); + unsigned char index_hash[GIT_MAX_RAWSZ]; + char proof_namespace[GIT_MAX_HEXSZ + 1]; + int restored = 0; + + if (!clean_status_external_history_enabled(istate) || !state || + !state->config_enforced || + !state->current_config_valid || !state->current_semantic_valid || + !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || + istate != istate->repo->index || + on_index_history_is_coherent(istate) || + clean_status_index_snapshot_pin(&snapshot, istate) || + clean_status_index_logical_digest(istate, index_hash)) + goto done; + memcpy(state->source_logical_hash, index_hash, + istate->repo->hash_algo->rawsz); + state->source_logical_hash_valid = 1; + if (external_history_namespace(istate, proof_namespace) || + clean_status_history_store_load( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo, &record) || + memcmp(index_hash, record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz)) + goto done; + parsed.cache_nr = istate->cache_nr; + if (read_fsmonitor_extension( + &parsed, record.checkpoint.fsmonitor, + record.checkpoint.fsmonitor_len) || + !parsed.fsmonitor_token_valid || !parsed.fsmonitor_last_update || + !parsed.fsmonitor_dirty) + goto done; + if (record.checkpoint.untracked_cache_len) { + parsed.untracked = read_untracked_extension( + record.checkpoint.untracked_cache, + record.checkpoint.untracked_cache_len); + read_fsmonitor_untracked_extension( + &parsed, record.checkpoint.fsmonitor_untracked, + record.checkpoint.fsmonitor_untracked_len); + if (!parsed.untracked || + parsed.fsmonitor_untracked_extension_invalid || + !parsed.fsmonitor_untracked_token || + strcmp(parsed.fsmonitor_untracked_token, + parsed.fsmonitor_last_update)) + goto done; + } + clean_status_attach_config(&parsed); + clean_status_read_fsmonitor_config( + &parsed, record.checkpoint.fsmonitor_config, + record.checkpoint.fsmonitor_config_len); + prepare_fsmonitor_untracked(&parsed); + clean_status_probe_fsmonitor_config(&parsed); + if (!current_proof_is_writable(&parsed) || + (!!parsed.untracked && !parsed.fsmonitor_untracked_valid)) + goto done; + if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + goto done; + clean_status_invalidate_current_proof(istate); + clean_status_copy_fsmonitor_history(istate, &parsed); + FREE_AND_NULL(istate->fsmonitor_last_update); + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + if (istate->fsmonitor_dirty) + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_last_update = parsed.fsmonitor_last_update; + parsed.fsmonitor_last_update = NULL; + istate->fsmonitor_dirty = parsed.fsmonitor_dirty; + parsed.fsmonitor_dirty = NULL; + istate->fsmonitor_token_valid = 1; + istate->fsmonitor_extension_seen = 1; + free_untracked_cache(istate->untracked); + istate->untracked = parsed.untracked; + parsed.untracked = NULL; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + parsed.fsmonitor_untracked_token; + parsed.fsmonitor_untracked_token = NULL; + istate->fsmonitor_untracked_extension_seen = + parsed.fsmonitor_untracked_extension_seen; + istate->fsmonitor_untracked_extension_invalid = 0; + istate->fsmonitor_untracked_valid = + parsed.fsmonitor_untracked_valid; + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-restored", 1); + state->external_history_restored = 1; + restored = 1; + +done: + if (parsed.fsmonitor_dirty) + ewah_free(parsed.fsmonitor_dirty); + parsed.fsmonitor_dirty = NULL; + parsed.cache_nr = 0; + release_index(&parsed); + clean_status_index_snapshot_release(&snapshot); + clean_status_history_store_record_release(&record); + return restored; +} + +int clean_status_external_history_was_restored( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->external_history_restored; +} + + void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src) { diff --git a/clean-status-index.c b/clean-status-index.c index 51399c0f24f720..0042fb7086ff19 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -101,45 +101,107 @@ int clean_status_index_snapshot_still_matches_path( snapshot->cache_nr, &snapshot->checksum, algo); } +static int source_index_matches_snapshot( + const struct clean_status_index_snapshot *snapshot, + const struct clean_status_state *state, + const struct git_hash_algo *algo) +{ + struct clean_status_identity identity; + struct stat st; + + return state && state->source_index_fd >= 0 && + state->source_index_identity_valid && + !fstat(state->source_index_fd, &st) && + !clean_status_identity_from_stat(&identity, &st) && + clean_status_identity_equal( + &identity, &state->source_index_identity) && + clean_status_identity_equal( + &snapshot->identity, &state->source_index_identity) && + snapshot_matches(state->source_index_fd, &st, + snapshot->version, snapshot->cache_nr, + &snapshot->checksum, algo); +} + static int snapshot_matches_index_state( const struct clean_status_index_snapshot *snapshot, - const struct index_state *istate) + const struct index_state *istate, int allow_process_local_source) { const struct clean_status_state *state = istate->clean_status; - return istate->version == snapshot->version && - istate->cache_nr == snapshot->cache_nr && - oideq(&istate->oid, &snapshot->checksum) && - (!is_null_oid(&snapshot->checksum) || - (clean_status_identity_is_durable() && state && - state->source_identity_valid && - clean_status_identity_equal(&snapshot->identity, - &state->source_identity))); + if (istate->version != snapshot->version || + istate->cache_nr != snapshot->cache_nr || + !oideq(&istate->oid, &snapshot->checksum)) + return 0; + if (!is_null_oid(&snapshot->checksum)) + return 1; + if (clean_status_identity_is_durable() && state && + state->source_identity_valid && + clean_status_identity_equal(&snapshot->identity, + &state->source_identity)) + return 1; + return allow_process_local_source && + source_index_matches_snapshot( + snapshot, state, istate->repo->hash_algo); } -int clean_status_index_snapshot_pin( +static int snapshot_pin( struct clean_status_index_snapshot *snapshot, - struct index_state *istate) + struct index_state *istate, int allow_process_local_source) { if (snapshot_open(snapshot, istate->repo->index_file, istate->repo->hash_algo, 1)) return -1; - if (snapshot_matches_index_state(snapshot, istate)) + if (snapshot_matches_index_state( + snapshot, istate, allow_process_local_source)) return 0; clean_status_index_snapshot_release(snapshot); return -1; } -int clean_status_index_snapshot_still_matches( +int clean_status_index_snapshot_pin( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate) +{ + return snapshot_pin(snapshot, istate, 0); +} + +int clean_status_index_snapshot_pin_proof_epoch( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate) +{ + /* + * A proof epoch is process-local and dies with its index state. It may + * therefore use the descriptor for the file which populated that state. + * Persisted history and sidecars continue to use the generic pin above. + */ + return snapshot_pin(snapshot, istate, 1); +} + +static int snapshot_still_matches( const struct clean_status_index_snapshot *snapshot, - const struct index_state *istate) + const struct index_state *istate, int allow_process_local_source) { - return snapshot_matches_index_state(snapshot, istate) && + return snapshot_matches_index_state( + snapshot, istate, allow_process_local_source) && clean_status_index_snapshot_still_matches_path( snapshot, istate->repo->index_file, istate->repo->hash_algo); } +int clean_status_index_snapshot_still_matches( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + return snapshot_still_matches(snapshot, istate, 0); +} + +int clean_status_index_snapshot_still_matches_proof_epoch( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + return snapshot_still_matches(snapshot, istate, 1); +} + void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot) { @@ -211,6 +273,27 @@ int clean_status_index_logical_digest(const struct index_state *istate, return index_logical_digest(istate, 0, out); } +int clean_status_index_logical_digest_after_status( + const struct index_state *istate, unsigned char *out) +{ + const unsigned int acceleration_changes = + CE_ENTRY_CHANGED | FSMONITOR_CHANGED | UNTRACKED_CHANGED; + + /* + * CE_UPDATE_IN_BASE has no independent meaning for a full index; status + * uses it as stat-refresh bookkeeping. Keep that exception confined + * to the main, expanded, acceleration-only status result. The common + * digest still rejects split/sparse indexes and every other transient + * flag, and hashes every persistent logical field. + */ + if (!istate->repo || + !clean_status_external_history_enabled(istate) || + istate != istate->repo->index || + (istate->cache_changed & ~acceleration_changes)) + return -1; + return index_logical_digest(istate, CE_UPDATE_IN_BASE, out); +} + void clean_status_record_source_identity(struct index_state *istate, const struct stat *st) { diff --git a/clean-status-index.h b/clean-status-index.h index b8c7dbb78487f0..61b288d3de2e4f 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -23,12 +23,20 @@ int clean_status_index_snapshot_still_matches_path( int clean_status_index_snapshot_pin( struct clean_status_index_snapshot *snapshot, struct index_state *istate); +int clean_status_index_snapshot_pin_proof_epoch( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate); int clean_status_index_snapshot_still_matches( const struct clean_status_index_snapshot *snapshot, const struct index_state *istate); +int clean_status_index_snapshot_still_matches_proof_epoch( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate); void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot); int clean_status_index_logical_digest(const struct index_state *istate, unsigned char *out); +int clean_status_index_logical_digest_after_status( + const struct index_state *istate, unsigned char *out); #endif /* CLEAN_STATUS_INDEX_H */ diff --git a/clean-status-internal.h b/clean-status-internal.h index 65dd4da0ae4019..62f73acfcd00cf 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -21,6 +21,7 @@ struct clean_status_state { unsigned char current_attr_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; unsigned char disk_attr_hash[GIT_MAX_RAWSZ]; + unsigned char source_logical_hash[GIT_MAX_RAWSZ]; unsigned current_config_valid : 1; unsigned current_semantic_valid : 1; unsigned current_attr_valid : 1; @@ -35,6 +36,8 @@ struct clean_status_state { unsigned initial_coherent : 1; unsigned source_identity_valid : 1; unsigned source_index_identity_valid : 1; + unsigned source_logical_hash_valid : 1; + unsigned external_history_restored : 1; unsigned disk_config_valid : 1; unsigned disk_semantic_valid : 1; unsigned disk_attr_valid : 1; diff --git a/clean-status.c b/clean-status.c index ea1d02e00ae09f..5d8858ce3fdc3f 100644 --- a/clean-status.c +++ b/clean-status.c @@ -2,6 +2,7 @@ #include "attr-fingerprint.h" #include "clean-status.h" #include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" #include "read-cache-ll.h" #include "repository.h" #include "trace2.h" @@ -9,10 +10,21 @@ static struct repository *configured_repo; static unsigned char configured_hash[GIT_MAX_RAWSZ]; static unsigned char configured_semantic_hash[GIT_MAX_RAWSZ]; +static struct repository *external_history_repo; static int configured_hash_valid; static int configured_filter_configured; static int configured_semantic_explicit; +void clean_status_enable_external_history(struct repository *repo) +{ + external_history_repo = repo; +} + +int clean_status_external_history_enabled(const struct index_state *istate) +{ + return istate && istate->repo == external_history_repo; +} + struct clean_status_state *clean_status_get_state(struct index_state *istate) { if (!istate->clean_status) { @@ -79,6 +91,18 @@ int clean_status_filter_scope_needs_validation( state->filter_configured && !state->filter_scope_valid; } +void clean_status_mark_filter_scope_valid(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || !state->current_config_valid || !state->config_enforced || + !state->filter_configured) + return; + state->filter_scope_valid = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "filter-scope/valid", 1); +} + int clean_status_revalidated_token_matches(const struct index_state *istate) { const struct clean_status_state *state = istate->clean_status; @@ -181,6 +205,40 @@ int clean_status_manifest_global_fallback(const struct index_state *istate) istate->clean_status->manifest.global_fallback; } +void clean_status_mark_fsmonitor_config_valid(struct index_state *istate, + const char *closed_token) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || !state->current_config_valid) + return; + if (!closed_token || clean_status_filter_scope_needs_validation(istate) || + !state->manifest.current_valid || !state->manifest.checked || + (state->manifest.current_flags & + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) != + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) { + clean_status_invalidate_current_proof(istate); + FREE_AND_NULL(state->config_revalidated_token); + trace2_data_intmax("fsmonitor", istate->repo, + "config/manifest-unbound", 1); + return; + } + state->config_mismatch = 0; + state->strong_mismatch = 0; + state->semantic_baseline_pending = 0; + state->manifest.current_flags = FSMONITOR_CLEAN_PROOF_ALL; + state->config_revalidated = state->current_semantic_valid && + state->current_attr_valid && state->manifest.current_valid; + state->initial_coherent = state->config_revalidated; + FREE_AND_NULL(state->config_revalidated_token); + if (state->config_revalidated) + state->config_revalidated_token = xstrdup(closed_token); + trace2_data_intmax("fsmonitor", istate->repo, + "config/revalidated", 1); +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index f6c001a6e834f4..bd518740eaaeb9 100644 --- a/clean-status.h +++ b/clean-status.h @@ -5,6 +5,7 @@ struct index_state; struct attr_source_snapshot; +struct clean_status_proof_epoch; struct repository; struct stat; struct strbuf; @@ -17,12 +18,28 @@ enum clean_status_attr_change { void clean_status_set_config_digest( struct repository *repo, const struct clean_status_config_digest *digest); +void clean_status_enable_external_history(struct repository *repo); +int clean_status_external_history_enabled(const struct index_state *istate); void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); +void clean_status_mark_filter_scope_valid(struct index_state *istate); + int clean_status_capture_attr_snapshot( struct index_state *istate, struct attr_source_snapshot **snapshot); +struct clean_status_proof_epoch *clean_status_capture_proof_epoch( + struct index_state *istate, + const struct attr_source_snapshot *attrs, + int validate_filter_scope); +int clean_status_proof_epoch_start_token_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); +int clean_status_proof_epoch_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); +void clean_status_release_proof_epoch( + struct clean_status_proof_epoch *epoch); int clean_status_fsmonitor_config_mismatch(const struct index_state *istate); int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); @@ -42,6 +59,10 @@ void clean_status_begin_fsmonitor_semantic_baseline( int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); + +void clean_status_mark_fsmonitor_config_valid( + struct index_state *istate, const char *closed_token); + void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); /* Takes ownership of fd only when it returns 1. */ @@ -53,6 +74,7 @@ int clean_status_verify_null_index(const struct index_state *istate, int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); void clean_status_prepare_fsmonitor_config(struct index_state *istate); +int clean_status_probe_fsmonitor_config(struct index_state *istate); void clean_status_invalidate_current_proof(struct index_state *istate); void clean_status_advance_fsmonitor_config_token( struct index_state *istate, const char *next_token); @@ -60,6 +82,10 @@ int clean_status_should_write_fsmonitor_config( const struct index_state *istate); void clean_status_write_fsmonitor_config(struct strbuf *out, const struct index_state *istate); +int clean_status_restore_external_history(struct index_state *istate); +int clean_status_external_history_was_restored( + const struct index_state *istate); +int clean_status_save_external_history(struct index_state *istate); void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src); int clean_status_transfer_current_proof_if_same_index( diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 7e7564e5e2c493..d7522222fc09cb 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -34,6 +34,8 @@ void prepare_fsmonitor_untracked(struct index_state *istate); * before it is split during writing. */ void fill_fsmonitor_bitmap(struct index_state *istate); +void snapshot_fsmonitor_extension(struct strbuf *sb, + struct index_state *istate); /* * Write the CE_FSMONITOR_VALID state into the fsmonitor index diff --git a/fsmonitor.c b/fsmonitor.c index c90bfebc2e4712..2e046b15a4a66c 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -263,19 +263,29 @@ void prepare_fsmonitor_untracked(struct index_state *istate) istate->fsmonitor_untracked_token))); } -void fill_fsmonitor_bitmap(struct index_state *istate) +static struct ewah_bitmap *fsmonitor_bitmap_from_index( + struct index_state *istate) { + struct ewah_bitmap *bitmap = ewah_new(); unsigned int i, skipped = 0; - istate->fsmonitor_dirty = ewah_new(); + for (i = 0; i < istate->cache_nr; i++) { if (istate->cache[i]->ce_flags & CE_REMOVE) skipped++; else if (!(istate->cache[i]->ce_flags & CE_FSMONITOR_VALID)) - ewah_set(istate->fsmonitor_dirty, i - skipped); + ewah_set(bitmap, i - skipped); } + return bitmap; } -void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) +void fill_fsmonitor_bitmap(struct index_state *istate) +{ + istate->fsmonitor_dirty = fsmonitor_bitmap_from_index(istate); +} + +static void serialize_fsmonitor_extension(struct strbuf *sb, + struct index_state *istate, + struct ewah_bitmap *bitmap) { uint32_t hdr_version; uint32_t ewah_start; @@ -283,7 +293,7 @@ void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) int fixup = 0; if (!istate->split_index) - assert_index_minimum(istate, istate->fsmonitor_dirty->bit_size); + assert_index_minimum(istate, bitmap->bit_size); put_be32(&hdr_version, INDEX_EXTENSION_VERSION2); strbuf_add(sb, &hdr_version, sizeof(uint32_t)); @@ -295,13 +305,27 @@ void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) strbuf_add(sb, &ewah_size, sizeof(uint32_t)); /* we'll fix this up later */ ewah_start = sb->len; - ewah_serialize_strbuf(istate->fsmonitor_dirty, sb); - ewah_free(istate->fsmonitor_dirty); - istate->fsmonitor_dirty = NULL; + ewah_serialize_strbuf(bitmap, sb); /* fix up size field */ put_be32(&ewah_size, sb->len - ewah_start); memcpy(sb->buf + fixup, &ewah_size, sizeof(uint32_t)); +} + +void snapshot_fsmonitor_extension(struct strbuf *sb, + struct index_state *istate) +{ + struct ewah_bitmap *bitmap = fsmonitor_bitmap_from_index(istate); + + serialize_fsmonitor_extension(sb, istate, bitmap); + ewah_free(bitmap); +} + +void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) +{ + serialize_fsmonitor_extension(sb, istate, istate->fsmonitor_dirty); + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; trace2_data_string("index", NULL, "extension/fsmn/write/token", istate->fsmonitor_last_update); @@ -944,8 +968,7 @@ static void invalidate_fsmonitor_for_bootstrap( } if (physical_history_unavailable) { - if (semantic_adoption_needed) - clean_status_refresh_worktree_manifest(istate); + clean_status_refresh_worktree_manifest(istate); fsmonitor_invalidate_semantics(istate); untracked_cache_invalidate_all(istate); return; diff --git a/meson.build b/meson.build index 1c52ff8fb71826..1e1b624fab11a3 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-epoch.c', 'clean-status-history-store.c', 'clean-status-history.c', 'clean-status-identity.c', diff --git a/read-cache-ll.h b/read-cache-ll.h index 53d0d0c0bfc9f6..ea14e94de1ef7c 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -477,6 +477,7 @@ int fake_lstat(const struct cache_entry *ce, struct stat *st); #define REFRESH_IN_PORCELAIN (1 << 5) /* user friendly output, not "needs update" */ #define REFRESH_PROGRESS (1 << 6) /* show progress bar if stderr is tty */ #define REFRESH_IGNORE_SKIP_WORKTREE (1 << 7) /* ignore skip_worktree entries */ +#define REFRESH_IN_PROOF_EPOCH (1 << 9) /* refresh is bounded by a proof epoch */ int refresh_index(struct index_state *, unsigned int flags, const struct pathspec *pathspec, char *seen, const char *header_msg); /* * Refresh the index and write it to disk. diff --git a/read-cache.c b/read-cache.c index 0e8be0ab995058..3934122a1f4957 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1659,13 +1659,14 @@ int refresh_index(struct index_state *istate, unsigned int flags, } { - int baseline_valid = - clean_status_fsmonitor_semantic_baseline_pending( - istate) && - (new_entry->ce_flags & CE_FSMONITOR_VALID); + int fsmonitor_valid = + (new_entry->ce_flags & CE_FSMONITOR_VALID) && + ((flags & REFRESH_IN_PROOF_EPOCH) || + clean_status_fsmonitor_semantic_baseline_pending( + istate)); replace_index_entry(istate, i, new_entry); - if (baseline_valid) + if (fsmonitor_valid) mark_fsmonitor_valid(istate, istate->cache[i]); } @@ -2022,6 +2023,7 @@ static void post_read_index_from(struct index_state *istate) check_ce_order(istate); tweak_untracked_cache(istate); tweak_split_index(istate); + clean_status_restore_external_history(istate); prepare_fsmonitor_untracked(istate); clean_status_prepare_fsmonitor_config(istate); tweak_fsmonitor(istate); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 90228af9d007d2..c4cbd1adfb382b 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -69,6 +69,29 @@ test_expect_success 'FSUC parser fails closed' ' test-tool read-cache --test-fsuc-parser ' +test_expect_success !SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'unsupported identity preserves an ordinary provider token' ' + test_when_finished "rm -rf unsupported-provider-token" && + test_create_repo unsupported-provider-token && + ( + cd unsupported-provider-token && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "^fsmonitor last update builtin:test:" \ + .git/fsmonitor && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/status.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'FSCF survives index I/O and generic rewrites' ' test_when_finished "rm -rf fscf-round-trip" && diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index 0a62f4dfeede5e..c13fbc03123299 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -222,6 +222,129 @@ void test_clean_status_index__pins_null_checksum_source_identity(void) assert_pins_null_checksum_source(&hash_algos[GIT_HASH_SHA256]); } +static int retain_null_checksum_source( + struct index_fixture *fixture, struct repository *repo, + struct index_state *istate) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_state *state; + struct stat source_st; + int source_fd; + + repo->hash_algo = algo; + repo->index_file = fixture->path; + istate->version = 4; + istate->cache_nr = 7; + oidcpy(&istate->oid, &fixture->checksum); + state = clean_status_get_state(istate); + source_fd = git_open_cloexec(fixture->path, O_RDONLY); + cl_assert(source_fd >= 0); +#if defined(F_GETFD) && defined(FD_CLOEXEC) + cl_assert(fcntl(source_fd, F_GETFD) & FD_CLOEXEC); +#endif + cl_assert_equal_i(fstat(source_fd, &source_st), 0); + cl_assert(!clean_status_retain_source_index_fd( + istate, source_fd, &source_st)); + cl_assert_equal_i(fstat(source_fd, &source_st), 0); + state->config_enforced = 1; + cl_assert(clean_status_retain_source_index_fd( + istate, source_fd, &source_st)); + return source_fd; +} + +void test_clean_status_index__pins_null_checksum_epoch_to_source_fd(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct stat source_st; + int source_fd; + + if (!fstat_is_reliable()) + return; + fixture_init(&fixture, algo); + fixture_clear_checksum(&fixture, algo); + source_fd = retain_null_checksum_source(&fixture, &repo, &istate); + + /* The held source is an exception only for proof epochs. */ + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + cl_assert_equal_i(clean_status_index_snapshot_pin_proof_epoch( + &snapshot, &istate), 0); + cl_assert(clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, &istate)); + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + clean_status_index_snapshot_release(&snapshot); + + release_index(&istate); + errno = 0; + cl_assert_equal_i(fstat(source_fd, &source_st), -1); + cl_assert_equal_i(errno, EBADF); + fixture_release(&fixture); +} + +static void assert_changed_null_checksum_source_is_rejected(int replace) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture, replacement; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); +#ifndef GIT_WINDOWS_NATIVE + char *moved = NULL; +#endif + + fixture_init(&fixture, algo); + fixture_clear_checksum(&fixture, algo); + if (replace) { + fixture_init(&replacement, algo); + fixture_clear_checksum(&replacement, algo); + } + retain_null_checksum_source(&fixture, &repo, &istate); + cl_assert_equal_i(clean_status_index_snapshot_pin_proof_epoch( + &snapshot, &istate), 0); + + if (replace) { +#ifndef GIT_WINDOWS_NATIVE + moved = xstrfmt("%s.old", fixture.path); + cl_assert_equal_i(rename(fixture.path, moved), 0); + cl_assert_equal_i(rename(replacement.path, fixture.path), 0); +#endif + } else { + write_at(fixture.fd, "\0", 1, fixture.st.st_size); + } + cl_assert(!clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, &istate)); + clean_status_index_snapshot_release(&snapshot); + cl_assert_equal_i(clean_status_index_snapshot_pin_proof_epoch( + &snapshot, &istate), -1); + if (replace) { +#ifndef GIT_WINDOWS_NATIVE + cl_assert_equal_i(rename(fixture.path, replacement.path), 0); + cl_assert_equal_i(rename(moved, fixture.path), 0); + free(moved); +#endif + } + + release_index(&istate); + fixture_release(&fixture); + if (replace) + fixture_release(&replacement); +} + +void test_clean_status_index__rejects_changed_null_checksum_epoch_source(void) +{ + if (!fstat_is_reliable()) + return; + assert_changed_null_checksum_source_is_rejected(0); +#ifndef GIT_WINDOWS_NATIVE + assert_changed_null_checksum_source_is_rejected(1); +#endif +} + void test_clean_status_index__pins_named_index_identity(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -404,3 +527,73 @@ void test_clean_status_index__digests_only_persistent_logical_entries(void) release_index(&istate); } + +void test_clean_status_index__limits_full_status_bookkeeping_exception(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct cache_entry *ce; + unsigned char baseline[GIT_MAX_RAWSZ]; + unsigned char changed[GIT_MAX_RAWSZ]; + static const char path[] = "tracked"; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + ce = make_empty_cache_entry(&istate, sizeof(path) - 1); + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = sizeof(path) - 1; + memcpy(ce->name, path, sizeof(path)); + memset(ce->oid.hash, 1, repo.hash_algo->rawsz); + oid_set_algo(&ce->oid, repo.hash_algo); + istate.cache[0] = ce; + repo.index = &istate; + istate.cache_changed = CE_ENTRY_CHANGED; + clean_status_enable_external_history(&repo); + + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, baseline), 0); + ce->ce_flags = CE_UPDATE_IN_BASE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(!memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_mode = S_IFREG | 0755; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_mode = S_IFREG | 0644; + ce->oid.hash[0] = 2; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->oid.hash[0] = 1; + ce->name[0] = 'T'; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->name[0] = 't'; + ce->ce_flags = CE_UPDATE_IN_BASE | CE_VALID; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_flags = CE_UPDATE_IN_BASE | CE_CONTENT_CHECK_REQUIRED; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + ce->ce_flags = CE_UPDATE_IN_BASE | CE_WT_REMOVE; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + ce->ce_flags = CE_UPDATE_IN_BASE; + istate.cache_changed |= CACHE_TREE_CHANGED; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + istate.cache_changed = CE_ENTRY_CHANGED; + repo.index = NULL; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + clean_status_enable_external_history(NULL); + + release_index(&istate); +} diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c index fc17fd8ec0dbb8..41c565330e5cde 100644 --- a/t/unit-tests/u-clean-status-manifest.c +++ b/t/unit-tests/u-clean-status-manifest.c @@ -1,5 +1,7 @@ #include "unit-test.h" #include "attr-manifest.h" +#include "clean-status.h" +#include "clean-status-internal.h" #include "clean-status-manifest.h" #include "dir.h" #include "fsmonitor-clean-proof.h" @@ -73,6 +75,51 @@ void test_clean_status_manifest__rejects_invalid_history(void) clean_status_manifest_release(&state); strbuf_release(&manifest); } + +void test_clean_status_manifest__requires_complete_full_index(void) +{ + const char *current_token = "builtin:1:2"; + const char *closed_token = "builtin:1:3"; + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct clean_status_state *state = clean_status_get_state(&istate); + + istate.fsmonitor_last_update = xstrdup(current_token); + istate.fsmonitor_token_valid = 1; + state->current_config_valid = 1; + state->current_semantic_valid = 1; + state->current_attr_valid = 1; + state->config_enforced = 1; + state->manifest.current_valid = 1; + state->manifest.checked = 1; + state->manifest.current_flags = + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE; + + clean_status_mark_fsmonitor_config_valid(&istate, closed_token); + cl_assert(!state->config_revalidated); + cl_assert(!state->config_revalidated_token); + cl_assert(!clean_status_should_write_fsmonitor_config(&istate)); + cl_assert_equal_i(state->manifest.current_flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE); + + state->manifest.current_flags |= FSMONITOR_CLEAN_PROOF_FULL_INDEX; + clean_status_mark_fsmonitor_config_valid(&istate, closed_token); + cl_assert(state->config_revalidated); + cl_assert_equal_s(state->config_revalidated_token, closed_token); + cl_assert(!clean_status_revalidated_token_matches(&istate)); + cl_assert(!clean_status_should_write_fsmonitor_config(&istate)); + + FREE_AND_NULL(istate.fsmonitor_last_update); + istate.fsmonitor_last_update = xstrdup(closed_token); + cl_assert(clean_status_revalidated_token_matches(&istate)); + cl_assert(clean_status_should_write_fsmonitor_config(&istate)); + cl_assert_equal_i(state->manifest.current_flags, + FSMONITOR_CLEAN_PROOF_ALL); + + clean_status_release(&istate); + FREE_AND_NULL(istate.fsmonitor_last_update); +} + #if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN static char *create_worktree(void) { diff --git a/wt-status.c b/wt-status.c index 7146f3e42f1760..ec1eab6f2eb255 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1015,45 +1015,104 @@ static void wt_status_publish_staged_untracked( closure->staged_untracked_ready = 0; } +static int fsmonitor_token_requires_rescan(enum fsmonitor_token_result result) +{ + return result == FSMONITOR_TOKEN_CHANGED || + result == FSMONITOR_TOKEN_TRIVIAL; +} + +static void wt_status_refresh_for_token( + struct wt_status *s, unsigned int refresh_flags, + struct clean_status_proof_epoch **epoch, int *refresh_result) +{ + struct index_state *istate = s->repo->index; + + clean_status_release_proof_epoch(*epoch); + *epoch = clean_status_capture_proof_epoch( + istate, s->attr_source_snapshot, 0); + if (*epoch) { + *refresh_result |= refresh_index( + istate, refresh_flags | REFRESH_IN_PROOF_EPOCH, + &s->pathspec, NULL, NULL); + } +} + static int wt_status_close_ordinary_fsmonitor_token( struct wt_status_token_closure *closure, int refreshed_before_closure) { struct wt_status *s = closure->status; struct index_state *istate = s->repo->index; + struct clean_status_proof_epoch *scan_epoch = NULL; + int reliable_stat = fstat_is_reliable(); - if (!refreshed_before_closure) - closure->refresh_result = refresh_index( + /* + * A pending token must close a refresh begun after its epoch was + * captured. A refresh performed before entering token closure cannot + * be validated by capturing its inputs afterward. + */ + if (reliable_stat) { + wt_status_refresh_for_token( + s, closure->refresh_flags, &scan_epoch, + &closure->refresh_result); + if (!scan_epoch) + return 0; + } else if (!refreshed_before_closure) { + closure->refresh_result |= refresh_index( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); + } if (!closure->untracked_ready && closure->can_prime) closure->untracked_ready = wt_status_stage_untracked(closure); while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { - enum fsmonitor_token_result result = - fsmonitor_query_pending_token( - istate, closure->untracked_ready); + enum fsmonitor_token_result result; + if (reliable_stat && + !clean_status_proof_epoch_start_token_matches( + istate, scan_epoch)) + break; closure->queries++; + result = fsmonitor_query_pending_token( + istate, closure->untracked_ready); if (result == FSMONITOR_TOKEN_CLEAN) { + if (reliable_stat && + !clean_status_proof_epoch_matches( + istate, scan_epoch)) + break; if (closure->untracked_ready) { + if (reliable_stat) + clean_status_mark_fsmonitor_config_valid( + istate, + istate->fsmonitor_last_update_pending); + clean_status_release_proof_epoch(scan_epoch); fsmonitor_accept_pending_token(istate); return 1; } break; } - if (result == FSMONITOR_TOKEN_ERROR || - result == FSMONITOR_TOKEN_NOT_PENDING) + clean_status_release_proof_epoch(scan_epoch); + scan_epoch = NULL; + if (!fsmonitor_token_requires_rescan(result)) break; /* Rescan invalidations returned by the closure query. */ - closure->refresh_result |= refresh_index( - istate, closure->refresh_flags, &s->pathspec, - NULL, NULL); + if (reliable_stat) { + wt_status_refresh_for_token( + s, closure->refresh_flags, &scan_epoch, + &closure->refresh_result); + if (!scan_epoch) + break; + } else { + closure->refresh_result |= refresh_index( + istate, closure->refresh_flags, + &s->pathspec, NULL, NULL); + } if (closure->can_prime) closure->untracked_ready = wt_status_stage_untracked(closure); } + clean_status_release_proof_epoch(scan_epoch); return 0; } @@ -1095,6 +1154,11 @@ static int wt_status_close_fsmonitor_token( /* Keep the last valid token and fall back to complete scans. */ wt_status_discard_staged_untracked(&closure); fsmonitor_reject_pending_token(istate); + if (fstat_is_reliable()) { + if (closure.can_prime) + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); + } closure.refresh_result |= refresh_index( istate, refresh_flags, &s->pathspec, NULL, NULL); accepted: From 89c8142a6796df1c39756c2066ab587bf5e8fc49 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:42:44 -0500 Subject: [PATCH 035/403] fsmonitor: reopen semantic proofs after attribute events A provider event can change a tracked .gitattributes file after status captures its conversion inputs. Accepting the resulting token against the previous manifest can incorrectly reuse tracked validity when a complete status would report a content change. Record when provider invalidation expires the current manifest and semantic proof. Before retrying token closure, rebuild that manifest and recapture external attribute sources when their content changes. Keep the response token pending until the new scan and current attribute epoch are both closed. Preserve ordinary provider handling when file identity is unreliable. Preserve reusable manifest history across ordinary index rewrites without retaining expired bindings. Extend the history and manifest unit cases and the index round-trip helper. Add a scripted regression for tracked attribute changes. Manifest or snapshot failure still forces a complete scan. Signed-off-by: Taylor Blau --- attr-fingerprint.c | 15 +++++ attr-fingerprint.h | 3 + clean-status-manifest.c | 4 ++ clean-status-manifest.h | 1 + clean-status.c | 20 ++++++- clean-status.h | 4 +- fsmonitor.c | 44 +++++++++++++-- t/unit-tests/u-clean-status-history.c | 22 ++++++++ t/unit-tests/u-clean-status-manifest.c | 4 ++ t/unit-tests/u-fsmonitor-attributes.c | 27 +++++++++ wt-status.c | 78 ++++++++++++++++++++++---- 11 files changed, 205 insertions(+), 17 deletions(-) diff --git a/attr-fingerprint.c b/attr-fingerprint.c index d7fdc1870dd2c3..d0152d6fe23963 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -213,6 +213,21 @@ int attr_source_snapshot_repository(struct repository *repo, return 0; } +int attr_source_snapshot_matches_repository( + struct repository *repo, + const struct attr_source_snapshot *snapshot) +{ + struct attr_fingerprint current; + + return snapshot && + !attr_fingerprint_repository(repo, ¤t) && + current.sources_present == + snapshot->fingerprint.sources_present && + !memcmp(current.content_hash, + snapshot->fingerprint.content_hash, + repo->hash_algo->rawsz); +} + const struct attr_fingerprint *attr_source_snapshot_fingerprint( const struct attr_source_snapshot *snapshot) { diff --git a/attr-fingerprint.h b/attr-fingerprint.h index 6d15646fcd1975..518a5b31b4e485 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -32,6 +32,9 @@ int attr_fingerprint_repository(struct repository *repo, struct attr_fingerprint *result); int attr_source_snapshot_repository(struct repository *repo, struct attr_source_snapshot **result); +int attr_source_snapshot_matches_repository( + struct repository *repo, + const struct attr_source_snapshot *snapshot); const struct attr_fingerprint *attr_source_snapshot_fingerprint( const struct attr_source_snapshot *snapshot); int attr_source_snapshot_read( diff --git a/clean-status-manifest.c b/clean-status-manifest.c index b2c8aaec97e71e..2750ddeb7280a9 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -93,6 +93,7 @@ void clean_status_manifest_adopt_disk( state->current_flags = state->disk_flags; state->current_valid = 1; state->checked = 1; + state->current_invalidated = 0; } static int invalidate_manifest_path(const struct attr_manifest_entry *entry, @@ -155,6 +156,7 @@ int clean_status_manifest_refresh(struct index_state *istate, state->current_valid = 1; state->current_flags = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | FSMONITOR_CLEAN_PROOF_FULL_INDEX; + state->current_invalidated = 0; trace2_data_intmax("fsmonitor", istate->repo, "semantic/manifest-candidates", stats.candidates); trace2_data_intmax("fsmonitor", istate->repo, @@ -180,6 +182,8 @@ int clean_status_manifest_refresh(struct index_state *istate, void clean_status_manifest_invalidate( struct clean_status_manifest_state *state) { + if (state->current_valid) + state->current_invalidated = 1; state->current_valid = 0; state->current_flags = 0; } diff --git a/clean-status-manifest.h b/clean-status-manifest.h index 04a56d56abe65c..394fe25a888c0d 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -19,6 +19,7 @@ struct clean_status_manifest_state { unsigned checked : 1; unsigned changed : 1; unsigned global_fallback : 1; + unsigned current_invalidated : 1; }; void clean_status_manifest_init(struct clean_status_manifest_state *state); diff --git a/clean-status.c b/clean-status.c index 5d8858ce3fdc3f..374738e81cbdb0 100644 --- a/clean-status.c +++ b/clean-status.c @@ -182,7 +182,8 @@ int clean_status_fsmonitor_config_mismatch(const struct index_state *istate) { return istate->clean_status && istate->clean_status->current_config_valid && - istate->clean_status->config_mismatch; + (istate->clean_status->config_mismatch || + !istate->clean_status->config_revalidated); } int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) @@ -205,6 +206,23 @@ int clean_status_manifest_global_fallback(const struct index_state *istate) istate->clean_status->manifest.global_fallback; } +int clean_status_worktree_manifest_needs_refresh( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->config_enforced && + state->manifest.current_invalidated; +} + +void clean_status_invalidate_current_manifest(struct index_state *istate) +{ + if (!istate->clean_status) + return; + clean_status_manifest_invalidate(&istate->clean_status->manifest); + clean_status_invalidate_current_proof(istate); +} + void clean_status_mark_fsmonitor_config_valid(struct index_state *istate, const char *closed_token) { diff --git a/clean-status.h b/clean-status.h index bd518740eaaeb9..cfe6a2db889f38 100644 --- a/clean-status.h +++ b/clean-status.h @@ -59,7 +59,9 @@ void clean_status_begin_fsmonitor_semantic_baseline( int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); - +int clean_status_worktree_manifest_needs_refresh( + const struct index_state *istate); +void clean_status_invalidate_current_manifest(struct index_state *istate); void clean_status_mark_fsmonitor_config_valid( struct index_state *istate, const char *closed_token); diff --git a/fsmonitor.c b/fsmonitor.c index 2e046b15a4a66c..e65567feaa0cb4 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -671,7 +671,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) if (!strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE)) { unsigned int i; - clean_status_invalidate_current_proof(istate); + clean_status_invalidate_current_manifest(istate); git_attr_invalidate_all(); untracked_cache_invalidate_all(istate); for (i = 0; i < istate->cache_nr; i++) @@ -708,7 +708,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) } } if (attributes_may_have_changed) - clean_status_invalidate_current_proof(istate); + clean_status_invalidate_current_manifest(istate); if (nr_in_cone) trace_printf_key(&trace_fsmonitor, @@ -939,14 +939,22 @@ static void invalidate_all_fsmonitor_for_baseline( static void invalidate_all_fsmonitor_strong(struct index_state *istate) { unsigned int i; + int provider_disabled = + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_DISABLED; invalidate_all_fsmonitor(istate); - for (i = 0; i < istate->cache_nr; i++) - fsmonitor_invalidate_cache_entry(istate->cache[i]); + for (i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + + if (provider_disabled && ce_skip_worktree(ce)) + continue; + fsmonitor_invalidate_cache_entry(ce); + } } void fsmonitor_invalidate_semantics(struct index_state *istate) { + clean_status_invalidate_current_proof(istate); git_attr_invalidate_all(); invalidate_all_fsmonitor_strong(istate); istate->cache_changed |= FSMONITOR_CHANGED; @@ -1189,11 +1197,37 @@ void refresh_fsmonitor(struct index_state *istate) } } - if (tracked_requires_bootstrap) + /* + * Applying a provider event may expire semantic history after + * the initial bootstrap decision. Keep the new token pending + * until status has rescanned against rebuilt inputs. + */ + if (fstat_is_reliable() && !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_config_mismatch(istate)) + tracked_requires_bootstrap = 1; + + if (tracked_requires_bootstrap) { + /* + * Provider paths can invalidate the manifest or + * semantic inputs after our pre-query snapshot. + * Recheck before choosing the narrow baseline lane. + */ + semantic_adoption_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_adoption_needed( + istate); + semantic_baseline_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_baseline_needed( + istate); invalidate_fsmonitor_for_bootstrap( istate, fsm_mode, semantic_adoption_needed, semantic_baseline_needed, !istate->fsmonitor_token_valid); + } /* Now mark the untracked cache for fsmonitor usage */ if (istate->untracked) diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index feb813c185c624..3d196c73c8236e 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -277,6 +277,28 @@ void test_clean_status_history__advances_only_current_proofs(void) fixture_release(&fixture); } +void test_clean_status_history__expires_invalidated_proofs(void) +{ + struct history_fixture fixture; + struct clean_status_state *state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA1]); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(state->manifest.current_valid); + cl_assert(state->config_revalidated); + cl_assert(state->initial_coherent); + + clean_status_invalidate_current_manifest(&fixture.istate); + cl_assert(!state->manifest.current_valid); + cl_assert(!state->config_revalidated); + cl_assert(!state->initial_coherent); + cl_assert(clean_status_fsmonitor_config_mismatch(&fixture.istate)); + fixture_release(&fixture); +} + void test_clean_status_history__copies_validated_history(void) { struct history_fixture fixture; diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c index 41c565330e5cde..971b4bea973c0c 100644 --- a/t/unit-tests/u-clean-status-manifest.c +++ b/t/unit-tests/u-clean-status-manifest.c @@ -42,6 +42,7 @@ void test_clean_status_manifest__loads_and_adopts_valid_history(void) cl_assert(!memcmp(state.current.buf, manifest.buf, manifest.len)); clean_status_manifest_invalidate(&state); cl_assert(!state.current_valid); + cl_assert(state.current_invalidated); cl_assert_equal_i(state.current_flags, 0); clean_status_manifest_release(&state); strbuf_release(&manifest); @@ -183,6 +184,7 @@ void test_clean_status_manifest__invalidates_only_changed_scopes(void) } cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); cl_assert(state.changed); + cl_assert(!state.current_invalidated); cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); @@ -195,6 +197,7 @@ void test_clean_status_manifest__invalidates_only_changed_scopes(void) strbuf_reset(&old); strbuf_addbuf(&old, &state.current); clean_status_manifest_invalidate(&state); + cl_assert(state.current_invalidated); istate.cache[0]->ce_flags = create_ce_flags(1); cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), -1); cl_assert(!state.current_valid); @@ -209,6 +212,7 @@ void test_clean_status_manifest__invalidates_only_changed_scopes(void) } cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); cl_assert(state.changed); + cl_assert(!state.current_invalidated); cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); diff --git a/t/unit-tests/u-fsmonitor-attributes.c b/t/unit-tests/u-fsmonitor-attributes.c index 5a2b7a25f137b3..3eedefca7aad0b 100644 --- a/t/unit-tests/u-fsmonitor-attributes.c +++ b/t/unit-tests/u-fsmonitor-attributes.c @@ -1,5 +1,7 @@ #include "unit-test.h" +#include "fsmonitor.h" #include "fsmonitor-ll.h" +#include "fsmonitor-settings.h" #include "read-cache-ll.h" #include "repository.h" @@ -70,3 +72,28 @@ void test_fsmonitor_attributes__root_source_invalidates_every_entry(void) } release_index(&istate); } + +void test_fsmonitor_attributes__disabled_provider_preserves_skipped_stat(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + fsm_settings__set_disabled(&repo); + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_entry(&istate, 0, "skipped"); + add_entry(&istate, 1, "tracked"); + istate.cache[0]->ce_flags |= CE_SKIP_WORKTREE; + + fsmonitor_invalidate_semantics(&istate); + + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(!(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED)); + cl_assert(!stat_data_is_zero(istate.cache[0])); + cl_assert(!(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[1]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(stat_data_is_zero(istate.cache[1])); + + release_index(&istate); + FREE_AND_NULL(repo.settings.fsmonitor); +} diff --git a/wt-status.c b/wt-status.c index ec1eab6f2eb255..f7769464cc2907 100644 --- a/wt-status.c +++ b/wt-status.c @@ -820,10 +820,10 @@ static int wt_status_begin_attr_snapshot(struct wt_status *s) int hook_provider = fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_HOOK; - if (s->attr_source_snapshot) - return 0; if (s->attr_snapshot_failed) return -1; + if (s->attr_source_snapshot) + return 0; ret = clean_status_capture_attr_snapshot( s->repo->index, &s->attr_source_snapshot); if (ret < 0) { @@ -1021,6 +1021,35 @@ static int fsmonitor_token_requires_rescan(enum fsmonitor_token_result result) result == FSMONITOR_TOKEN_TRIVIAL; } +static void wt_status_release_attr_snapshot(struct wt_status *s); + +static int wt_status_attr_snapshot_matches(struct wt_status *s) +{ + if (s->attr_snapshot_failed) + return 0; + return !s->attr_source_snapshot || + attr_source_snapshot_matches_repository( + s->repo, s->attr_source_snapshot); +} + +static int wt_status_refresh_invalidated_manifest(struct wt_status *s) +{ + if (!clean_status_worktree_manifest_needs_refresh(s->repo->index)) + return 0; + return clean_status_refresh_worktree_manifest(s->repo->index) < 0 ? + -1 : 0; +} + +static void wt_status_reset_attr_snapshot_if_changed(struct wt_status *s) +{ + if (wt_status_attr_snapshot_matches(s)) + return; + wt_status_release_attr_snapshot(s); + wt_status_begin_attr_snapshot(s); + trace2_data_intmax("status", s->repo, + "semantic/attribute-epoch-rejected", 1); +} + static void wt_status_refresh_for_token( struct wt_status *s, unsigned int refresh_flags, struct clean_status_proof_epoch **epoch, int *refresh_result) @@ -1078,8 +1107,10 @@ static int wt_status_close_ordinary_fsmonitor_token( if (result == FSMONITOR_TOKEN_CLEAN) { if (reliable_stat && !clean_status_proof_epoch_matches( - istate, scan_epoch)) + istate, scan_epoch)) { + wt_status_reset_attr_snapshot_if_changed(s); break; + } if (closure->untracked_ready) { if (reliable_stat) clean_status_mark_fsmonitor_config_valid( @@ -1097,6 +1128,9 @@ static int wt_status_close_ordinary_fsmonitor_token( break; /* Rescan invalidations returned by the closure query. */ + wt_status_reset_attr_snapshot_if_changed(s); + if (wt_status_refresh_invalidated_manifest(s)) + break; if (reliable_stat) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, @@ -1131,10 +1165,24 @@ static int wt_status_close_fsmonitor_token( refresh_fsmonitor(istate); if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) { - if (!refreshed_before_closure) - closure.refresh_result = refresh_index( + int attr_inputs_match = + wt_status_attr_snapshot_matches(s) && + !clean_status_worktree_manifest_needs_refresh(istate); + + if (!refreshed_before_closure && attr_inputs_match) + return refresh_index( istate, refresh_flags, &s->pathspec, NULL, NULL); + if (refreshed_before_closure && attr_inputs_match) + return closure.refresh_result; + + wt_status_reset_attr_snapshot_if_changed(s); + if (fsmonitor_has_pending_token(istate)) + fsmonitor_reject_pending_token(istate); + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); + closure.refresh_result |= refresh_index( + istate, refresh_flags, &s->pathspec, NULL, NULL); return closure.refresh_result; } @@ -1147,11 +1195,15 @@ static int wt_status_close_fsmonitor_token( !closure.untracked_ready) BUG("cannot close required untracked scan"); trace2_region_enter("status", "fsmonitor_token_closure", s->repo); + wt_status_reset_attr_snapshot_if_changed(s); + if (wt_status_refresh_invalidated_manifest(s)) + goto fallback; if (wt_status_close_ordinary_fsmonitor_token( &closure, refreshed_before_closure)) goto accepted; /* Keep the last valid token and fall back to complete scans. */ +fallback: wt_status_discard_staged_untracked(&closure); fsmonitor_reject_pending_token(istate); if (fstat_is_reliable()) { @@ -1172,10 +1224,20 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked) { + wt_status_begin_attr_snapshot(s); return wt_status_close_fsmonitor_token( s, refresh_flags, require_untracked, 0); } +static void wt_status_release_attr_snapshot(struct wt_status *s) +{ + if (s->attr_source_snapshot) + git_attr_source_snapshot_end(s->attr_source_snapshot); + attr_source_snapshot_free(s->attr_source_snapshot); + s->attr_source_snapshot = NULL; + s->attr_snapshot_failed = 0; +} + static int has_unmerged(struct wt_status *s) { int i; @@ -1239,11 +1301,7 @@ void wt_status_collect_free_buffers(struct wt_status *s) { untracked_cache_preload_release(s->untracked_cache_preload); s->untracked_cache_preload = NULL; - if (s->attr_source_snapshot) - git_attr_source_snapshot_end(s->attr_source_snapshot); - attr_source_snapshot_free(s->attr_source_snapshot); - s->attr_source_snapshot = NULL; - s->attr_snapshot_failed = 0; + wt_status_release_attr_snapshot(s); wt_status_state_free_buffers(&s->state); } From 66b05b3b56f6c0502dffc0d0c89c55bae3bb1069 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:44:44 -0500 Subject: [PATCH 036/403] status: adopt tracked history only after closing its proof An IPC provider cannot safely adopt missing semantic history merely because tracked entries are marked fsmonitor-valid. Minimal stat checks can conceal a content rewrite, and a clean token cannot retroactively certify workers started under different attributes. Capture the complete proof epoch before preparing semantic workers. Prime each worker's attribute frames and verify the starting token and complete epoch before hashing. After a clean closing query, apply the proof only if the pinned index, configuration, attribute content, manifest, worktree identity, and token remain consistent. Permit attribute-namespace bookkeeping to change only after its source bytes and initial namespace were verified. Accept tracked validity independently of untracked validity. Keep a query pending when the untracked scan has not run. Leave collapsed sparse indexes, pathspecs, ignored-file requests, unreliable file identity, non-IPC providers, and failed proofs on ordinary closure or complete refresh. Add scripted regressions for adopting missing tracked history without hiding a same-size rewrite and for preserving a collapsed sparse index on the ordinary closure path. Signed-off-by: Taylor Blau --- clean-status-epoch.c | 37 ++++++++- clean-status.h | 6 ++ fsmonitor-ll.h | 3 +- fsmonitor.c | 22 ++++-- semantic-verify-internal.h | 4 + semantic-verify-worker.c | 6 +- semantic-verify.c | 59 ++++++++++++++- semantic-verify.h | 6 ++ wt-status.c | 149 +++++++++++++++++++++++++++++++++++-- 9 files changed, 272 insertions(+), 20 deletions(-) diff --git a/clean-status-epoch.c b/clean-status-epoch.c index b5760649cb3c10..f78bf8fb6bd78f 100644 --- a/clean-status-epoch.c +++ b/clean-status-epoch.c @@ -138,9 +138,10 @@ int clean_status_proof_epoch_start_token_matches( istate->fsmonitor_last_update_pending); } -int clean_status_proof_epoch_matches( +static int proof_epoch_matches( struct index_state *istate, - const struct clean_status_proof_epoch *epoch) + const struct clean_status_proof_epoch *epoch, + int check_attr_namespace) { struct clean_status_state *state; struct attr_fingerprint attrs; @@ -166,8 +167,9 @@ int clean_status_proof_epoch_matches( goto done; if (attr_fingerprint_repository(istate->repo, &attrs) || memcmp(attrs.content_hash, epoch->attr_hash, algo->rawsz) || - memcmp(attrs.namespace_hash, epoch->attr_namespace_hash, - algo->rawsz) || + (check_attr_namespace && + memcmp(attrs.namespace_hash, epoch->attr_namespace_hash, + algo->rawsz)) || attrs.sources_present != epoch->attr_sources_present || memcmp(state->manifest.current_hash, epoch->manifest_hash, algo->rawsz) || @@ -182,6 +184,33 @@ int clean_status_proof_epoch_matches( return matched; } +int clean_status_proof_epoch_prime_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + int matched = + clean_status_proof_epoch_start_token_matches(istate, epoch) && + proof_epoch_matches(istate, epoch, 1); + + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/proof-epoch-primed", matched); + return matched; +} + +int clean_status_proof_epoch_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + return proof_epoch_matches(istate, epoch, 1); +} + +int clean_status_proof_epoch_content_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + return proof_epoch_matches(istate, epoch, 0); +} + void clean_status_release_proof_epoch( struct clean_status_proof_epoch *epoch) { diff --git a/clean-status.h b/clean-status.h index cfe6a2db889f38..1cbfd1e0329456 100644 --- a/clean-status.h +++ b/clean-status.h @@ -35,9 +35,15 @@ struct clean_status_proof_epoch *clean_status_capture_proof_epoch( int clean_status_proof_epoch_start_token_matches( struct index_state *istate, const struct clean_status_proof_epoch *epoch); +int clean_status_proof_epoch_prime_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); int clean_status_proof_epoch_matches( struct index_state *istate, const struct clean_status_proof_epoch *epoch); +int clean_status_proof_epoch_content_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); void clean_status_release_proof_epoch( struct clean_status_proof_epoch *epoch); diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index d7522222fc09cb..dc6998d5789a75 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -71,7 +71,8 @@ int fsmonitor_has_pending_token(const struct index_state *istate); int fsmonitor_pending_token_from_provider(const struct index_state *istate); enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready); -void fsmonitor_accept_pending_token(struct index_state *istate); +void fsmonitor_accept_pending_token(struct index_state *istate, + int untracked_ready); void fsmonitor_reject_pending_token(struct index_state *istate); void fsmonitor_mark_untracked_cache_valid(struct index_state *istate); diff --git a/fsmonitor.c b/fsmonitor.c index e65567feaa0cb4..69ed171b040fd1 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1369,7 +1369,8 @@ enum fsmonitor_token_result fsmonitor_query_pending_token( return ret; } -void fsmonitor_accept_pending_token(struct index_state *istate) +void fsmonitor_accept_pending_token(struct index_state *istate, + int untracked_ready) { if (!fsmonitor_pending_token_from_provider(istate)) return; @@ -1378,13 +1379,24 @@ void fsmonitor_accept_pending_token(struct index_state *istate) istate->fsmonitor_last_update_pending = NULL; istate->fsmonitor_pending_token_from_provider = 0; istate->fsmonitor_token_valid = 1; - istate->fsmonitor_untracked_valid = 1; + istate->fsmonitor_untracked_valid = !!untracked_ready; if (istate->untracked) - istate->untracked->use_fsmonitor = 1; + istate->untracked->use_fsmonitor = !!untracked_ready; istate->cache_changed |= FSMONITOR_CHANGED; FREE_AND_NULL(istate->fsmonitor_untracked_token); - istate->fsmonitor_untracked_token = - xstrdup(istate->fsmonitor_last_update); + if (untracked_ready) + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + else { + /* + * Keep a query anchored at the accepted tracked token. A + * later in-process status may need to close work done after + * this point before validating its untracked cache. + */ + istate->fsmonitor_last_update_pending = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_pending_token_from_provider = 1; + } trace2_data_intmax("fsmonitor", istate->repo, "token_closure/accepted", 1); } diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index 70f253ba2885ed..b35af3ff93b271 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -30,6 +30,7 @@ struct attr_check; struct repository; +struct clean_status_proof_epoch; struct cache_entry; struct git_hash_algo; struct index_state; @@ -106,6 +107,7 @@ struct semantic_verify_worker { struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; + struct attr_check *check; size_t start; size_t end; struct semantic_verify_stat_update *updates; @@ -130,6 +132,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker); struct semantic_verify_proof { struct index_state *istate; struct semantic_verify_root *root; + struct clean_status_proof_epoch *epoch; struct semantic_verify_result *results; struct semantic_verify_entry_identity *entry_identities; struct semantic_verify_stat_update *stat_updates; @@ -146,6 +149,7 @@ struct semantic_verify_proof { size_t hardlinks; size_t active_filters; unsigned int namespace_unstable; + unsigned int epoch_required; unsigned int filter_scope_checked; }; diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index b0f00099577b0d..591da8aa70703d 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -56,10 +56,14 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) { struct semantic_verify_path *path = semantic_verify_path_new(worker->root); - struct attr_check *check = convert_attrs_check_alloc(); + struct attr_check *check = worker->check; void *buffer = xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); size_t unstable_from = SIZE_MAX; + worker->check = NULL; + if (!check) + check = convert_attrs_check_alloc(); + for (size_t i = worker->start; i < worker->end; i++) { struct cache_entry *ce = worker->istate->cache[i]; struct semantic_verify_result *result = &worker->results[i]; diff --git a/semantic-verify.c b/semantic-verify.c index 52582793760ce2..277b71f7e6d38f 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -1,6 +1,8 @@ #define USE_THE_REPOSITORY_VARIABLE #include "git-compat-util.h" +#include "attr.h" +#include "clean-status.h" #include "convert.h" #include "fsmonitor.h" #include "object.h" @@ -89,6 +91,7 @@ int semantic_verify_prepare(struct index_state *istate, (uintmax_t)sizeof(struct semantic_verify_result)); CALLOC_ARRAY(proof, 1); proof->istate = istate; + proof->epoch_required = options && options->require_proof_epoch; proof->filter_scope_checked = options && options->validate_filter_scope; proof->cache_nr = istate->cache_nr; @@ -108,7 +111,7 @@ int semantic_verify_prepare(struct index_state *istate, identity->flags = ce->ce_flags & SEMANTIC_VERIFY_ENTRY_FLAGS; } *proof_out = proof; - if (!proof->cache_nr) + if (!proof->cache_nr && !proof->epoch_required) return 0; if (istate->sparse_index != INDEX_EXPANDED) { for (size_t i = 0; i < proof->cache_nr; i++) { @@ -128,11 +131,49 @@ int semantic_verify_prepare(struct index_state *istate, proof->errors = proof->cache_nr; return -1; } + if (proof->epoch_required) { + proof->epoch = clean_status_capture_proof_epoch( + istate, options->attr_snapshot, + proof->filter_scope_checked); + if (!proof->epoch) { + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_ERROR; + proof->results[i].error = EAGAIN; + } + proof->errors = proof->cache_nr; + return -1; + } + } + if (!proof->cache_nr) + return 0; /* Initialize conversion config and default attribute state serially. */ convert_attrs_prepare(istate); nr_threads = select_thread_count(proof->cache_nr, options); CALLOC_ARRAY(workers, nr_threads); + if (proof->epoch_required) { + /* + * Load each worker's system, global, root, and info + * attribute frames before closing the proof epoch. + */ + for (unsigned int i = 0; i < nr_threads; i++) { + workers[i].check = convert_attrs_check_alloc(); + git_check_attr(istate, "", workers[i].check); + } + if (!clean_status_proof_epoch_prime_matches( + istate, proof->epoch)) { + for (unsigned int i = 0; i < nr_threads; i++) + attr_check_free(workers[i].check); + free(workers); + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_ERROR; + proof->results[i].error = EAGAIN; + } + proof->errors = proof->cache_nr; + git_attr_invalidate_all(); + return -1; + } + } trace2_region_enter("semantic_verify", "prepare", istate->repo); trace2_data_intmax("semantic_verify", istate->repo, "threads", nr_threads); @@ -208,6 +249,16 @@ int semantic_verify_root_is_stable(const struct semantic_verify_proof *proof) return proof && semantic_verify_root_stable(proof->root); } +int semantic_verify_start_token_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + return proof && proof->istate == istate && + (!proof->epoch_required || + clean_status_proof_epoch_start_token_matches( + istate, proof->epoch)); +} + void semantic_verify_get_stats(const struct semantic_verify_proof *proof, struct semantic_verify_stats *stats) { @@ -248,7 +299,10 @@ int semantic_verify_apply_after_closure( if (!istate || !proof || proof->istate != istate || proof->cache_nr != istate->cache_nr || proof->namespace_unstable || - !semantic_verify_root_is_stable(proof)) + !semantic_verify_root_is_stable(proof) || + (proof->epoch_required && + !clean_status_proof_epoch_content_matches( + istate, proof->epoch))) return -1; if (proof->active_filters) { trace2_data_intmax("semantic_verify", istate->repo, @@ -349,6 +403,7 @@ void semantic_verify_proof_clear(struct semantic_verify_proof *proof) { if (!proof) return; + clean_status_release_proof_epoch(proof->epoch); semantic_verify_root_clear(proof->root); for (size_t i = 0; i < proof->cache_nr; i++) free(proof->entry_identities[i].name); diff --git a/semantic-verify.h b/semantic-verify.h index 87692a3e88a424..68aecff252b864 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -2,10 +2,13 @@ #define SEMANTIC_VERIFY_H struct index_state; +struct attr_source_snapshot; struct semantic_verify_proof; struct semantic_verify_options { unsigned int nr_threads; + const struct attr_source_snapshot *attr_snapshot; + unsigned int require_proof_epoch : 1; unsigned int validate_filter_scope : 1; }; @@ -63,6 +66,9 @@ int semantic_verify_apply_after_closure( const struct semantic_verify_proof *proof); int semantic_verify_root_is_stable( const struct semantic_verify_proof *proof); +int semantic_verify_start_token_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof); void semantic_verify_proof_clear(struct semantic_verify_proof *proof); /* Introspection used by the semantic verifier test helper. */ diff --git a/wt-status.c b/wt-status.c index f7769464cc2907..affbd91f7afb32 100644 --- a/wt-status.c +++ b/wt-status.c @@ -29,6 +29,7 @@ #include "column.h" #include "read-cache.h" #include "setup.h" +#include "semantic-verify.h" #include "strbuf.h" #include "trace.h" #include "trace2.h" @@ -956,6 +957,41 @@ static int wt_status_collect_untracked_1( return used_untracked_cache; } +static struct semantic_verify_proof *wt_status_prepare_semantic_verify( + struct wt_status *s, int require_untracked) +{ + struct index_state *istate = s->repo->index; + struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; + struct semantic_verify_proof *proof = NULL; + int ret; + + if (!fstat_is_reliable() || istate->split_index || + require_untracked || + s->show_untracked_files != SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode || s->pathspec.nr || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || + istate->sparse_index != INDEX_EXPANDED || + !fsmonitor_has_pending_token(istate) || + !fsmonitor_pending_token_from_provider(istate) || + !clean_status_fsmonitor_semantic_adoption_needed(istate)) + return NULL; + + options.require_proof_epoch = 1; + options.validate_filter_scope = + clean_status_filter_scope_needs_validation(istate); + options.attr_snapshot = s->attr_source_snapshot; + trace2_region_enter("status", "semantic_verify", s->repo); + ret = semantic_verify_prepare(istate, &options, &proof); + trace2_data_intmax("status", s->repo, + "semantic_verify/prepared", !ret); + trace2_region_leave("status", "semantic_verify", s->repo); + if (ret) { + semantic_verify_proof_clear(proof); + return NULL; + } + return proof; +} + static int wt_status_collect_untracked(struct wt_status *s) { if (s->untracked_from_token_closure && !s->show_ignored_mode) @@ -969,6 +1005,7 @@ static int wt_status_collect_untracked(struct wt_status *s) struct wt_status_token_closure { struct wt_status *status; unsigned int refresh_flags; + int require_untracked; int can_prime; int untracked_ready; struct string_list staged_untracked; @@ -1050,6 +1087,19 @@ static void wt_status_reset_attr_snapshot_if_changed(struct wt_status *s) "semantic/attribute-epoch-rejected", 1); } +static void wt_status_discard_semantic_verify( + struct wt_status *s, struct semantic_verify_proof **proof, + const char *reason) +{ + if (!*proof) + return; + trace2_data_string("status", s->repo, "semantic_verify/discard", + reason); + semantic_verify_proof_clear(*proof); + *proof = NULL; + git_attr_invalidate_all(); +} + static void wt_status_refresh_for_token( struct wt_status *s, unsigned int refresh_flags, struct clean_status_proof_epoch **epoch, int *refresh_result) @@ -1111,13 +1161,15 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); break; } - if (closure->untracked_ready) { + if (closure->untracked_ready || + !closure->require_untracked) { if (reliable_stat) clean_status_mark_fsmonitor_config_valid( istate, istate->fsmonitor_last_update_pending); clean_status_release_proof_epoch(scan_epoch); - fsmonitor_accept_pending_token(istate); + fsmonitor_accept_pending_token( + istate, closure->untracked_ready); return 1; } break; @@ -1150,17 +1202,80 @@ static int wt_status_close_ordinary_fsmonitor_token( return 0; } +enum wt_status_token_closure_result { + WT_STATUS_TOKEN_CLOSURE_FALLBACK = -1, + WT_STATUS_TOKEN_CLOSURE_RETRY, + WT_STATUS_TOKEN_CLOSURE_ACCEPTED, +}; + +static enum wt_status_token_closure_result +wt_status_close_semantic_fsmonitor_token( + struct wt_status_token_closure *closure, + struct semantic_verify_proof **proof) +{ + struct wt_status *s = closure->status; + struct index_state *istate = s->repo->index; + enum fsmonitor_token_result result; + int applied; + + if (!semantic_verify_start_token_is_current(istate, *proof)) { + wt_status_discard_semantic_verify( + s, proof, "start-token-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + closure->queries++; + result = fsmonitor_query_pending_token( + istate, closure->untracked_ready); + if (result != FSMONITOR_TOKEN_CLEAN) { + wt_status_discard_semantic_verify( + s, proof, "token-reset"); + if (fsmonitor_token_requires_rescan(result)) + return WT_STATUS_TOKEN_CLOSURE_RETRY; + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + applied = semantic_verify_apply_after_closure(istate, *proof); + if (applied < 0) { + wt_status_reset_attr_snapshot_if_changed(s); + wt_status_discard_semantic_verify( + s, proof, "closure-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + closure->refresh_result |= refresh_index( + istate, closure->refresh_flags, &s->pathspec, NULL, NULL); + trace2_data_intmax("status", s->repo, + "fsmonitor_token/semantic-closed", 1); + if (!wt_status_attr_snapshot_matches(s) || + clean_status_worktree_manifest_needs_refresh(istate)) { + wt_status_reset_attr_snapshot_if_changed(s); + wt_status_discard_semantic_verify( + s, proof, "attribute-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + clean_status_mark_fsmonitor_config_valid( + istate, istate->fsmonitor_last_update_pending); + semantic_verify_proof_clear(*proof); + *proof = NULL; + fsmonitor_accept_pending_token(istate, closure->untracked_ready); + return WT_STATUS_TOKEN_CLOSURE_ACCEPTED; +} + static int wt_status_close_fsmonitor_token( - struct wt_status *s, unsigned int refresh_flags, - int require_untracked, int refreshed_before_closure) + struct wt_status *s, struct semantic_verify_proof *proof, + unsigned int refresh_flags, int require_untracked, + int refreshed_before_closure) { struct index_state *istate = s->repo->index; struct wt_status_token_closure closure = { .status = s, .refresh_flags = refresh_flags, + .require_untracked = require_untracked, .staged_untracked = STRING_LIST_INIT_DUP, .staged_ignored = STRING_LIST_INIT_DUP, }; + enum wt_status_token_closure_result result; refresh_fsmonitor(istate); if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || @@ -1169,6 +1284,8 @@ static int wt_status_close_fsmonitor_token( wt_status_attr_snapshot_matches(s) && !clean_status_worktree_manifest_needs_refresh(istate); + wt_status_discard_semantic_verify( + s, &proof, "provider-unavailable"); if (!refreshed_before_closure && attr_inputs_match) return refresh_index( istate, refresh_flags, &s->pathspec, @@ -1198,12 +1315,26 @@ static int wt_status_close_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); if (wt_status_refresh_invalidated_manifest(s)) goto fallback; + + if (proof) { + result = wt_status_close_semantic_fsmonitor_token( + &closure, &proof); + if (result == WT_STATUS_TOKEN_CLOSURE_ACCEPTED) + goto accepted; + if (result == WT_STATUS_TOKEN_CLOSURE_FALLBACK) + goto fallback; + wt_status_reset_attr_snapshot_if_changed(s); + if (wt_status_refresh_invalidated_manifest(s)) + goto fallback; + } + if (wt_status_close_ordinary_fsmonitor_token( &closure, refreshed_before_closure)) goto accepted; /* Keep the last valid token and fall back to complete scans. */ fallback: + wt_status_discard_semantic_verify(s, &proof, "fallback"); wt_status_discard_staged_untracked(&closure); fsmonitor_reject_pending_token(istate); if (fstat_is_reliable()) { @@ -1224,9 +1355,13 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked) { + struct semantic_verify_proof *proof; + wt_status_begin_attr_snapshot(s); + refresh_fsmonitor(s->repo->index); + proof = wt_status_prepare_semantic_verify(s, require_untracked); return wt_status_close_fsmonitor_token( - s, refresh_flags, require_untracked, 0); + s, proof, refresh_flags, require_untracked, 0); } static void wt_status_release_attr_snapshot(struct wt_status *s) @@ -1259,7 +1394,7 @@ void wt_status_collect(struct wt_status *s) wt_status_finish_untracked_cache_preload(s); wt_status_begin_attr_snapshot(s); wt_status_close_fsmonitor_token( - s, REFRESH_QUIET | REFRESH_UNMERGED, + s, NULL, REFRESH_QUIET | REFRESH_UNMERGED, s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode, 1); @@ -1287,7 +1422,7 @@ void wt_status_collect(struct wt_status *s) (used_untracked_cache || !s->repo->index->untracked || !s->repo->index->untracked->root)) { if (fsmonitor_pending_token_from_provider(s->repo->index)) - fsmonitor_accept_pending_token(s->repo->index); + fsmonitor_accept_pending_token(s->repo->index, 1); else fsmonitor_reject_pending_token(s->repo->index); } From 1808e38c6e3fecb8154c6231c24ea5b593e8d202 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:46:57 -0500 Subject: [PATCH 037/403] wt-status: close tracked proofs before scanning untracked paths An untracked-cache preload can inspect cached excludes and directory state before tracked semantic adoption restores verified stat data. One provider response also cannot certify an untracked traversal performed after the tracked scan that response closes. Defer provider-backed untracked validation until the tracked proof has been applied and its first query has closed. Prime the untracked cache afterward, issue a second closing query, and recheck the full tracked proof before accepting either result. If the later query reports a change, invalidate both results, reprime during ordinary closure, and retry within the existing query bound. Factor the existing proof-current checks into the predicate used by proof application and deferred closure. Preserve automatic untracked preload when no provider is enabled or file identity is unreliable. Fall back to a complete scan if untracked validation or token closure fails. Add prerequisite-guarded scripted cases for successful deferred scans, failed untracked closure, and a change reported by the second closing query. Signed-off-by: Taylor Blau --- dir.c | 3 +- semantic-verify.c | 34 +++++++++++---- semantic-verify.h | 6 +++ t/t7519-status-fsmonitor.sh | 75 +++++++++++++++++++++++++++++---- wt-status.c | 82 ++++++++++++++++++++++++++++++++----- 5 files changed, 175 insertions(+), 25 deletions(-) diff --git a/dir.c b/dir.c index b2a4e4b2e5adc4..7e8b55638bdc8d 100644 --- a/dir.c +++ b/dir.c @@ -312,7 +312,8 @@ static void preload_fsmonitor_excludes_from_index( goto next; ce = preload->istate->cache[pos]; if (!S_ISREG(ce->ce_mode) || - !(ce->ce_flags & CE_FSMONITOR_VALID) || + (!(ce->ce_flags & CE_FSMONITOR_VALID) && + fstat_is_reliable()) || ce_skip_worktree(ce) || (ce->ce_flags & CE_REMOVE) || ce_intent_to_add(ce)) diff --git a/semantic-verify.c b/semantic-verify.c index 277b71f7e6d38f..cdd179fdef4215 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -259,6 +259,19 @@ int semantic_verify_start_token_is_current( istate, proof->epoch)); } +int semantic_verify_proof_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + return istate && proof && proof->istate == istate && + proof->cache_nr == istate->cache_nr && + !proof->namespace_unstable && + semantic_verify_root_is_stable(proof) && + (!proof->epoch_required || + clean_status_proof_epoch_content_matches( + istate, proof->epoch)); +} + void semantic_verify_get_stats(const struct semantic_verify_proof *proof, struct semantic_verify_stats *stats) { @@ -296,13 +309,7 @@ int semantic_verify_apply_after_closure( int poisoned = 0; size_t validated_updates = 0; - if (!istate || !proof || proof->istate != istate || - proof->cache_nr != istate->cache_nr || - proof->namespace_unstable || - !semantic_verify_root_is_stable(proof) || - (proof->epoch_required && - !clean_status_proof_epoch_content_matches( - istate, proof->epoch))) + if (!semantic_verify_proof_is_current(istate, proof)) return -1; if (proof->active_filters) { trace2_data_intmax("semantic_verify", istate->repo, @@ -399,6 +406,19 @@ int semantic_verify_apply_after_closure( return applied; } +int semantic_verify_accept_filter_scope( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + if (!proof || !proof->filter_scope_checked) + return 0; + if (!proof->epoch_required || proof->active_filters || + !semantic_verify_proof_is_current(istate, proof)) + return -1; + clean_status_mark_filter_scope_valid(istate); + return 1; +} + void semantic_verify_proof_clear(struct semantic_verify_proof *proof) { if (!proof) diff --git a/semantic-verify.h b/semantic-verify.h index 68aecff252b864..1895a384e28f2d 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -64,11 +64,17 @@ int semantic_verify_prepare(struct index_state *istate, int semantic_verify_apply_after_closure( struct index_state *istate, const struct semantic_verify_proof *proof); +int semantic_verify_accept_filter_scope( + struct index_state *istate, + const struct semantic_verify_proof *proof); int semantic_verify_root_is_stable( const struct semantic_verify_proof *proof); int semantic_verify_start_token_is_current( struct index_state *istate, const struct semantic_verify_proof *proof); +int semantic_verify_proof_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof); void semantic_verify_proof_clear(struct semantic_verify_proof *proof); /* Introspection used by the semantic verifier test helper. */ diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index c4cbd1adfb382b..002d5f1a83c1fc 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -594,7 +594,8 @@ prepare_builtin_closure_repo () { ) } -test_expect_success UNTRACKED_CACHE 'builtin clean closure publishes its proof' ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin clean closure publishes its proof' ' test_when_finished "rm -rf builtin-closure-clean" && prepare_builtin_closure_repo builtin-closure-clean untracked && ( @@ -616,7 +617,8 @@ test_expect_success UNTRACKED_CACHE 'builtin clean closure publishes its proof' ) ' -test_expect_success 'builtin changed closure rescans before acceptance' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin changed closure rescans before acceptance' ' test_when_finished "rm -rf builtin-closure-changed" && prepare_builtin_closure_repo builtin-closure-changed && ( @@ -638,7 +640,8 @@ test_expect_success 'builtin changed closure rescans before acceptance' ' ) ' -test_expect_success 'builtin initial trivial response anchors a closure' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin initial trivial response anchors a closure' ' test_when_finished "rm -rf builtin-initial-trivial" && prepare_builtin_closure_repo builtin-initial-trivial && ( @@ -661,7 +664,8 @@ test_expect_success 'builtin initial trivial response anchors a closure' ' ) ' -test_expect_success 'builtin trivial closure can rescan and accept' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin trivial closure can rescan and accept' ' test_when_finished "rm -rf builtin-closure-trivial" && prepare_builtin_closure_repo builtin-closure-trivial && ( @@ -681,7 +685,8 @@ test_expect_success 'builtin trivial closure can rescan and accept' ' ) ' -test_expect_success 'builtin closure rejects three intervening changes' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin closure rejects three intervening changes' ' test_when_finished "rm -rf builtin-closure-exhausted" && prepare_builtin_closure_repo builtin-closure-exhausted && ( @@ -704,14 +709,15 @@ test_expect_success 'builtin closure rejects three intervening changes' ' ) ' -test_expect_success 'builtin closure query errors fall back completely' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin closure query errors fall back completely' ' test_when_finished "rm -rf builtin-closure-error" && prepare_builtin_closure_repo builtin-closure-error untracked && ( cd builtin-closure-error && sane_unset GIT_TEST_SPLIT_INDEX && test_write_lines visible >visible && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCE \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && test_grep "^? visible$" .git/actual && @@ -1419,4 +1425,59 @@ test_expect_success HARDLINKS,!MINGW,!CYGWIN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'second closing-query change reprimes untracked cache' ' + test_when_finished "rm -rf second-query-changed" && + test_create_repo second-query-changed && + ( + cd second-query-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + printf "aaaa\n" >cached/tracked && + test_write_lines ignored >cached/junk.ignored && + git add .gitignore cached/.gitignore cached/tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + test-tool chmtime =-60 cached/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get cached/tracked) && + printf "bbbb\n" >cached/tracked && + test-tool chmtime =$mtime cached/tracked && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid cached/tracked && + test_grep ! FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_line_count = 1 .git/actual && + test_grep "^1 \.M .* cached/tracked$" .git/actual && + test_trace2_data status fsmonitor_token/semantic-closed 1 \ + <.git/status.trace && + test_trace2_data status \ + fsmonitor_token/untracked-after-semantic 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/apply_count 1 \ + <.git/status.trace && + test_trace2_data status \ + fsmonitor_token/untracked-after-retry 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index + ) +' + test_done diff --git a/wt-status.c b/wt-status.c index affbd91f7afb32..af6fbdbccd40e6 100644 --- a/wt-status.c +++ b/wt-status.c @@ -865,18 +865,36 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); wt_status_begin_attr_snapshot(s); + /* Record the provider token before either filesystem traversal. */ + refresh_fsmonitor(istate); if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) return; + dir_flags = wt_status_untracked_dir_flags(s); - if (has_fsmonitor) { + if (has_fsmonitor && + (!fsmonitor_has_pending_token(istate) || + !fstat_is_reliable())) { s->untracked_cache_preload = untracked_cache_preload_start_fsmonitor_excludes( istate, dir_flags); return; } + /* Restore verified stats before cached excludes inspect them. */ + if (fstat_is_reliable() && !istate->split_index && + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && + fsmonitor_pending_token_from_provider(istate) && + clean_status_fsmonitor_semantic_adoption_needed(istate) && + istate->untracked && istate->untracked->root) { + trace2_data_intmax("status", s->repo, + "fsmonitor_token/untracked-deferred", 1); + return; + } + if (has_fsmonitor) + return; + s->untracked_cache_preload = untracked_cache_preload_start_ordinary(istate, dir_flags); } @@ -958,7 +976,7 @@ static int wt_status_collect_untracked_1( } static struct semantic_verify_proof *wt_status_prepare_semantic_verify( - struct wt_status *s, int require_untracked) + struct wt_status *s) { struct index_state *istate = s->repo->index; struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; @@ -966,8 +984,6 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( int ret; if (!fstat_is_reliable() || istate->split_index || - require_untracked || - s->show_untracked_files != SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode || s->pathspec.nr || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || istate->sparse_index != INDEX_EXPANDED || @@ -1141,8 +1157,14 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); } - if (!closure->untracked_ready && closure->can_prime) + if (!closure->untracked_ready && closure->can_prime) { closure->untracked_ready = wt_status_stage_untracked(closure); + if (closure->queries) + trace2_data_intmax( + "status", s->repo, + "fsmonitor_token/untracked-after-retry", + closure->untracked_ready); + } while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { enum fsmonitor_token_result result; @@ -1216,6 +1238,8 @@ wt_status_close_semantic_fsmonitor_token( struct wt_status *s = closure->status; struct index_state *istate = s->repo->index; enum fsmonitor_token_result result; + int defer_untracked = + closure->can_prime && !closure->untracked_ready; int applied; if (!semantic_verify_start_token_is_current(istate, *proof)) { @@ -1224,9 +1248,10 @@ wt_status_close_semantic_fsmonitor_token( return WT_STATUS_TOKEN_CLOSURE_FALLBACK; } + /* The first query closes the tracked scan and its semantic proof. */ closure->queries++; result = fsmonitor_query_pending_token( - istate, closure->untracked_ready); + istate, defer_untracked ? 0 : closure->untracked_ready); if (result != FSMONITOR_TOKEN_CLEAN) { wt_status_discard_semantic_verify( s, proof, "token-reset"); @@ -1246,11 +1271,47 @@ wt_status_close_semantic_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); trace2_data_intmax("status", s->repo, "fsmonitor_token/semantic-closed", 1); - if (!wt_status_attr_snapshot_matches(s) || - clean_status_worktree_manifest_needs_refresh(istate)) { + if (!semantic_verify_proof_is_current(istate, *proof)) { wt_status_reset_attr_snapshot_if_changed(s); wt_status_discard_semantic_verify( - s, proof, "attribute-drift"); + s, proof, "closure-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + if (defer_untracked) { + closure->untracked_ready = wt_status_stage_untracked(closure); + trace2_data_intmax( + "status", s->repo, + "fsmonitor_token/untracked-after-semantic", + closure->untracked_ready); + if (!closure->untracked_ready || + closure->queries >= FSMONITOR_TOKEN_MAX_QUERIES) + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + + /* A second query closes the subsequent untracked scan. */ + closure->queries++; + result = fsmonitor_query_pending_token( + istate, closure->untracked_ready); + if (result != FSMONITOR_TOKEN_CLEAN) { + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); + closure->untracked_ready = 0; + wt_status_discard_semantic_verify( + s, proof, "token-reset"); + if (fsmonitor_token_requires_rescan(result)) + return WT_STATUS_TOKEN_CLOSURE_RETRY; + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + if (!semantic_verify_proof_is_current(istate, *proof)) { + wt_status_reset_attr_snapshot_if_changed(s); + wt_status_discard_semantic_verify( + s, proof, "closure-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + } + if (semantic_verify_accept_filter_scope(istate, *proof) < 0) { + wt_status_discard_semantic_verify( + s, proof, "filter-scope-drift"); return WT_STATUS_TOKEN_CLOSURE_FALLBACK; } @@ -1304,6 +1365,7 @@ static int wt_status_close_fsmonitor_token( } closure.can_prime = require_untracked && + istate->untracked && istate->untracked->root && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode; closure.untracked_ready = !istate->untracked || @@ -1359,7 +1421,7 @@ int wt_status_refresh_index(struct wt_status *s, wt_status_begin_attr_snapshot(s); refresh_fsmonitor(s->repo->index); - proof = wt_status_prepare_semantic_verify(s, require_untracked); + proof = wt_status_prepare_semantic_verify(s); return wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); } From 3501231acadef0ba5f02deba514ee2c4206692c7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 15:24:55 -0500 Subject: [PATCH 038/403] dir: reject oversized pattern files before allocation add_patterns() rejects pattern files larger than 100 MiB only after allocating and reading their complete contents. An oversized filesystem input can therefore exhaust the memory the limit is meant to protect, or terminate Git when GIT_ALLOC_LIMIT rejects the allocation. Check the size obtained from fstat() before allocating a filesystem pattern buffer. Preserve the existing warning, close the descriptor, and return the existing failure result. Keep the later size check for index-backed fallback data, whose size is unavailable before it is read. Strengthen the existing EXPENSIVE regression by reading its 101 MiB .gitignore under GIT_ALLOC_LIMIT=1m. The old ordering dies in xmallocz(); the early rejection preserves the expected warning without attempting the oversized allocation. Signed-off-by: Taylor Blau --- dir.c | 6 ++++++ t/t0008-ignores.sh | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dir.c b/dir.c index 95d8a1cce90f77..8b8beb1281bf2f 100644 --- a/dir.c +++ b/dir.c @@ -1175,6 +1175,12 @@ static int add_patterns(const char *fname, const char *base, int baselen, return r; } else { size = xsize_t(st.st_size); + if (size > PATTERN_MAX_FILE_SIZE) { + warning("ignoring excessively large pattern file: %s", + fname); + close(fd); + return -1; + } if (size == 0) { if (oid_stat) { fill_stat_data(&oid_stat->stat, &st); diff --git a/t/t0008-ignores.sh b/t/t0008-ignores.sh index ed95faf3272e60..949897c36aa9b2 100755 --- a/t/t0008-ignores.sh +++ b/t/t0008-ignores.sh @@ -959,7 +959,7 @@ test_expect_success EXPENSIVE 'large exclude file ignored in tree' ' test_when_finished "rm .gitignore" && find . -name .gitignore -exec rm "{}" ";" && dd if=/dev/zero of=.gitignore bs=101M count=1 && - git ls-files -o --exclude-standard 2>err && + GIT_ALLOC_LIMIT=1m git ls-files -o --exclude-standard 2>err && echo "warning: ignoring excessively large pattern file: .gitignore" >expect && test_cmp expect err ' From cfca3fda450d2fc42c019aad126460d9fedfd75c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:46:20 -0500 Subject: [PATCH 039/403] commit: close fsmonitor tokens across pre-commit hooks An as-is commit refreshes its index before running the pre-commit hook. If the hook rewrites a tracked path without changing its size or mtime, the later in-process status must not certify the earlier refresh as though it covered the hook. For a nonsplit index using an IPC provider, perform the initial refresh through status token closure. After an invoked hook, release the saved attribute snapshot and reopen the last accepted provider token before status runs again. Reject unavailable token state and invalidate the manifest, tracked semantics, and untracked cache before falling back to a complete refresh. Pin the post-hook named index before persisting strong invalidation. Write refreshed state only while its held descriptor, pathname, stored trailer checksum, and in-memory index still match. Preserve a hook-replaced index and the existing reread. Split indexes, platforms without reliable file identity, and non-IPC providers retain their original initial refresh. Add prerequisite-guarded scripted cases for successful post-hook closure without an untracked cache, failed closure with complete worktree refresh, and a hook that updates the index itself. Signed-off-by: Taylor Blau --- builtin/commit.c | 61 ++++++++++++++++++++++++++++++++++++++++++------ fsmonitor-ll.h | 2 ++ fsmonitor.c | 15 ++++++++++++ wt-status.c | 15 ++++++++++++ wt-status.h | 1 + 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 29f339f89a2254..685959875418c2 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -14,12 +14,14 @@ #include "lockfile.h" #include "cache-tree.h" #include "clean-status.h" +#include "clean-status-index.h" #include "color.h" #include "dir.h" #include "editor.h" #include "environment.h" #include "diff.h" #include "commit.h" +#include "fsmonitor-settings.h" #include "add-interactive.h" #include "gettext.h" #include "revision.h" @@ -373,7 +375,8 @@ static void refresh_cache_or_die(int refresh_flags) } static const char *prepare_index(const char **argv, const char *prefix, - const struct commit *current_head, int is_status) + const struct commit *current_head, int is_status, + struct wt_status *s) { struct string_list partial = STRING_LIST_INIT_DUP; struct pathspec pathspec; @@ -506,7 +509,14 @@ static const char *prepare_index(const char **argv, const char *prefix, if (!only && !pathspec.nr) { repo_hold_locked_index(the_repository, &index_lock, LOCK_DIE_ON_ERROR); - refresh_cache_or_die(refresh_flags); + if (!fstat_is_reliable() || + the_repository->index->split_index || + fsm_settings__get_mode(the_repository) != + FSMONITOR_MODE_IPC) + refresh_cache_or_die(refresh_flags); + else if (wt_status_refresh_index( + s, refresh_flags | REFRESH_IN_PORCELAIN, 0)) + die_resolve_conflict("commit"); if (the_repository->index->cache_changed || !cache_tree_fully_valid(the_repository->index->cache_tree)) cache_tree_update(the_repository->index, WRITE_TREE_SILENT); @@ -797,13 +807,29 @@ static int prepare_to_commit(const char *index_file, const char *prefix, int clean_message_contents = (cleanup_mode != COMMIT_MSG_CLEANUP_NONE); int old_display_comment_prefix; int invoked_hook; + int hook_index_matches = 0; + struct clean_status_index_snapshot hook_index = { .fd = -1 }; /* This checks and barfs if author is badly specified */ determine_author_info(author_ident); - if (!no_verify && run_commit_hook(use_editor, index_file, &invoked_hook, - "pre-commit", NULL)) - return 0; + if (!no_verify) { + int hook_failed = run_commit_hook( + use_editor, index_file, &invoked_hook, + "pre-commit", NULL); + + if (invoked_hook && fstat_is_reliable()) + wt_status_invalidate_refresh(s); + if (invoked_hook && fstat_is_reliable() && + commit_style == COMMIT_AS_IS) + hook_index_matches = + !clean_status_index_snapshot_pin( + &hook_index, the_repository->index); + if (hook_failed) { + clean_status_index_snapshot_release(&hook_index); + return 0; + } + } if (squash_message) { /* @@ -1119,10 +1145,29 @@ static int prepare_to_commit(const char *index_file, const char *prefix, else fputs(_(empty_rebase_pick_advice), stderr); } + clean_status_index_snapshot_release(&hook_index); return 0; } if (!no_verify && invoked_hook) { + struct lock_file refresh_lock = LOCK_INIT; + + /* + * Preserve any strong invalidation recorded while status + * closed the post-hook token. The pinned source prevents this + * write from replacing an index updated by the hook. + */ + if (hook_index_matches && + repo_hold_locked_index(the_repository, &refresh_lock, 0) >= 0) { + if (clean_status_index_snapshot_still_matches( + &hook_index, the_repository->index)) + repo_update_index_if_able( + the_repository, &refresh_lock); + else + rollback_lock_file(&refresh_lock); + } + clean_status_index_snapshot_release(&hook_index); + /* * Re-read the index as the pre-commit-commit hook was invoked * and could have updated it. We must do this before we invoke @@ -1455,7 +1500,7 @@ static int dry_run_commit(const char **argv, const char *prefix, int committable; const char *index_file; - index_file = prepare_index(argv, prefix, current_head, 1); + index_file = prepare_index(argv, prefix, current_head, 1, s); committable = run_status(stdout, index_file, prefix, 0, s); rollback_index_files(); @@ -1871,7 +1916,7 @@ int cmd_commit(int argc, if (dry_run) return dry_run_commit(argv, prefix, current_head, &s); - index_file = prepare_index(argv, prefix, current_head, 0); + index_file = prepare_index(argv, prefix, current_head, 0, &s); /* Set up everything for writing the commit object. This includes running hooks, writing the trees, and interacting with the user. */ @@ -1881,6 +1926,7 @@ int cmd_commit(int argc, rollback_index_files(); goto cleanup; } + wt_status_collect_free_buffers(&s); /* Determine parents */ reflog_msg = getenv("GIT_REFLOG_ACTION"); @@ -2019,6 +2065,7 @@ int cmd_commit(int argc, NULL, NULL, NULL, NULL); cleanup: + wt_status_collect_free_buffers(&s); free_commit_extra_headers(extra); commit_list_free(parents); strbuf_release(&author_ident); diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index dc6998d5789a75..9e64d7d8571b87 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -69,6 +69,8 @@ int fsmonitor_invalidate_attributes_path(struct index_state *istate, /* Close a provider token which was obtained before a required scan. */ int fsmonitor_has_pending_token(const struct index_state *istate); int fsmonitor_pending_token_from_provider(const struct index_state *istate); +/* Reopen the last accepted IPC token after an in-process operation. */ +int fsmonitor_reopen_token(struct index_state *istate); enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready); void fsmonitor_accept_pending_token(struct index_state *istate, diff --git a/fsmonitor.c b/fsmonitor.c index 69ed171b040fd1..dd3e529db468f7 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1326,6 +1326,21 @@ int fsmonitor_pending_token_from_provider(const struct index_state *istate) istate->fsmonitor_pending_token_from_provider; } +int fsmonitor_reopen_token(struct index_state *istate) +{ + if (!fstat_is_reliable() || istate->split_index || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) + return 0; + if (istate->fsmonitor_last_update_pending) + return istate->fsmonitor_pending_token_from_provider; + if (!istate->fsmonitor_token_valid || !istate->fsmonitor_last_update) + return 0; + istate->fsmonitor_last_update_pending = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_pending_token_from_provider = 1; + return 1; +} + enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready) { diff --git a/wt-status.c b/wt-status.c index af6fbdbccd40e6..1e49bb55210e70 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1435,6 +1435,21 @@ static void wt_status_release_attr_snapshot(struct wt_status *s) s->attr_snapshot_failed = 0; } +void wt_status_invalidate_refresh(struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + + wt_status_release_attr_snapshot(s); + if (!s->pathspec.nr && !istate->split_index && + fsmonitor_reopen_token(istate)) + return; + if (fsmonitor_has_pending_token(istate)) + fsmonitor_reject_pending_token(istate); + clean_status_invalidate_current_manifest(istate); + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); +} + static int has_unmerged(struct wt_status *s) { int i; diff --git a/wt-status.h b/wt-status.h index 74798dd593aacb..0f7104b4c6ac5f 100644 --- a/wt-status.h +++ b/wt-status.h @@ -168,6 +168,7 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s); int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked); +void wt_status_invalidate_refresh(struct wt_status *s); /* * Collect all changes between the two trees. Changes will be displayed as if From 7ffb384b97d97a958f6e2bc46bb83b986712c7bd Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:03 -0500 Subject: [PATCH 040/403] exclude: prove the contents of observed ignore sources A bulk untracked scan cannot reuse its result merely because an ignore file has familiar stat data. A file or its parent may be replaced while the scan runs, an absent source may appear, and repeated reads of the same source may observe different patterns. Record each source beneath its nearest available anchored parent, along with its path, symlink policy, presence, size, and blob identity. Check descriptor and parent identities while capturing an observation, then resolve the current parent again and compare the actual source bytes at validation. Coalesce equivalent observations and invalidate the proof immediately when observations conflict. Validation uses nonblocking opens, so replacing a source with a FIFO cannot hang. Equal contents remain acceptable even if the source or its parent has a different identity. This also preserves an empty /dev/null and an equivalent empty FIFO; changed or missing contents, unavailable anchored primitives, and failed parent callbacks invalidate the proof. Register the implementation and focused unit suite in both Make and Meson. The tests cover source and parent replacement, stable absence, repeated and conflicting observations, missing buffers, no-follow policy, /dev/null, FIFO replacement, and parent-opener failure. Signed-off-by: Taylor Blau --- Makefile | 2 + exclude-source-proof.c | 422 ++++++++++++++++++++++++++ exclude-source-proof.h | 37 +++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-exclude-source-proof.c | 404 ++++++++++++++++++++++++ 6 files changed, 867 insertions(+) create mode 100644 exclude-source-proof.c create mode 100644 exclude-source-proof.h create mode 100644 t/unit-tests/u-exclude-source-proof.c diff --git a/Makefile b/Makefile index 777cd9b05445df..9b6f8b5e518fe9 100644 --- a/Makefile +++ b/Makefile @@ -1181,6 +1181,7 @@ LIB_OBJS += ewah/bitmap.o LIB_OBJS += ewah/ewah_bitmap.o LIB_OBJS += ewah/ewah_io.o LIB_OBJS += ewah/ewah_rlw.o +LIB_OBJS += exclude-source-proof.o LIB_OBJS += exec-cmd.o LIB_OBJS += fetch-negotiator.o LIB_OBJS += fetch-object-info.o @@ -1578,6 +1579,7 @@ CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate +CLAR_TEST_SUITES += u-exclude-source-proof CLAR_TEST_SUITES += u-fsmonitor-attributes CLAR_TEST_SUITES += u-fsmonitor-clean-proof CLAR_TEST_SUITES += u-fsmonitor-response diff --git a/exclude-source-proof.c b/exclude-source-proof.c new file mode 100644 index 00000000000000..ec5194a1fa355e --- /dev/null +++ b/exclude-source-proof.c @@ -0,0 +1,422 @@ +#include "git-compat-util.h" +#include "exclude-source-proof.h" +#include "object-file.h" +#include "path-namespace.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strmap.h" +#include "trace2.h" + +/* + * Each entry describes one path/policy observation. Filesystem identities + * are used only to make capture and validation coherent; the durable + * observation is the source's existence and bytes. + */ +struct exclude_source_proof_entry { + char *path; + size_t size; + struct object_id oid; + unsigned exists : 1; + unsigned nofollow : 1; +}; + +struct exclude_source_proof { + struct index_state *istate; + void *open_data; + exclude_source_open_parent_fn open_parent; + struct exclude_source_proof_entry *entries; + struct strintmap entries_by_path[2]; + size_t nr; + size_t alloc; + unsigned invalid : 1; +}; + +struct exclude_source_capture { + struct exclude_source_proof *proof; + char *path; + char *parent; + char *relative; + int parent_fd; + struct stat parent_stat; + unsigned nofollow : 1; +}; + +static char *source_parent(const char *path) +{ + const char *slash = strrchr(path, '/'); + + if (!slash) + return xstrdup("."); + if (slash == path) + return xstrdup("/"); + return xmemdupz(path, slash - path); +} + +static char *source_relative(const char *path, const char *parent) +{ + const char *relative; + size_t len; + + if (!strcmp(parent, ".")) + return xstrdup(path); + if (!strcmp(parent, "/")) { + relative = path + 1; + } else { + len = strlen(parent); + if (strncmp(path, parent, len) || path[len] != '/') + BUG("exclude source is not below its parent"); + relative = path + len + 1; + } + return xstrdup(*relative ? relative : "."); +} + +static int parent_up(char *parent) +{ + char *slash; + + if (!strcmp(parent, ".") || !strcmp(parent, "/")) + return 0; + slash = strrchr(parent, '/'); + if (!slash) { + parent[0] = '.'; + parent[1] = '\0'; + } else if (slash == parent) { + parent[1] = '\0'; + } else { + *slash = '\0'; + } + return 1; +} + +static int open_source_at(int parent_fd, const char *relative, int nofollow, + int nonblocking) +{ +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + int flags = O_RDONLY | O_CLOEXEC; + + if (nofollow) + flags |= O_NOFOLLOW; + if (nonblocking) + flags |= O_NONBLOCK; + return openat(parent_fd, relative, flags); +#else + (void)parent_fd; + (void)relative; + (void)nofollow; + (void)nonblocking; + errno = ENOSYS; + return -1; +#endif +} + +static int parent_identity_stable( + struct exclude_source_proof *proof, const char *parent, + int held_fd, const struct stat *expected) +{ + struct stat held, reopened; + int fd = proof->open_parent(proof->open_data, parent); + int stable = !fstat(held_fd, &held) && + fd >= 0 && !fstat(fd, &reopened) && + path_namespace_stat_equal(expected, &held) && + path_namespace_stat_equal(expected, &reopened); + + if (fd >= 0) + close(fd); + return stable; +} + +static int parent_stable(struct exclude_source_capture *capture) +{ + return parent_identity_stable( + capture->proof, capture->parent, capture->parent_fd, + &capture->parent_stat); +} + +static void capture_free(struct exclude_source_capture *capture) +{ + if (!capture) + return; + if (capture->parent_fd >= 0) + close(capture->parent_fd); + free(capture->path); + free(capture->parent); + free(capture->relative); + free(capture); +} + +static struct exclude_source_capture *capture_begin( + struct exclude_source_proof *proof, const char *path, + int nofollow, int invalidate) +{ + struct exclude_source_capture *capture; + + if (!proof || proof->invalid) + return NULL; + if (!path) { + if (invalidate) + proof->invalid = 1; + return NULL; + } + CALLOC_ARRAY(capture, 1); + capture->proof = proof; + capture->nofollow = nofollow; + capture->parent_fd = -1; + capture->path = xstrdup(path); + capture->parent = source_parent(path); + for (;;) { + capture->parent_fd = proof->open_parent(proof->open_data, + capture->parent); + if (capture->parent_fd >= 0) + break; + if (!is_missing_file_error(errno) || + !parent_up(capture->parent)) + break; + } + if (capture->parent_fd < 0 || + fstat(capture->parent_fd, &capture->parent_stat) || + !S_ISDIR(capture->parent_stat.st_mode)) { + if (invalidate) + proof->invalid = 1; + capture_free(capture); + return NULL; + } + capture->relative = source_relative(path, capture->parent); + return capture; +} + +struct exclude_source_proof *exclude_source_proof_create( + struct index_state *istate, void *open_data, + exclude_source_open_parent_fn open_parent) +{ + struct exclude_source_proof *proof; + + CALLOC_ARRAY(proof, 1); + proof->istate = istate; + proof->open_data = open_data; + proof->open_parent = open_parent; + strintmap_init_with_options(&proof->entries_by_path[0], -1, + NULL, 0); + strintmap_init_with_options(&proof->entries_by_path[1], -1, + NULL, 0); + if (!EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN || + !istate || !istate->repo || !istate->repo->hash_algo || + !open_parent) + proof->invalid = 1; + return proof; +} + +struct exclude_source_capture *exclude_source_capture_begin( + struct exclude_source_proof *proof, const char *path, + int nofollow) +{ + return capture_begin(proof, path, nofollow, 1); +} + +int exclude_source_capture_open(struct exclude_source_capture *capture) +{ + if (!capture) { + errno = EINVAL; + return -1; + } + return open_source_at(capture->parent_fd, capture->relative, + capture->nofollow, 0); +} + +int exclude_source_capture_absent(struct exclude_source_capture *capture) +{ +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + struct stat st; + + if (!capture) + return 0; + if (!fstatat(capture->parent_fd, capture->relative, &st, + AT_SYMLINK_NOFOLLOW)) + return 0; + return is_missing_file_error(errno); +#else + (void)capture; + return 0; +#endif +} + +static int source_matches(struct exclude_source_capture *capture, + const struct stat *expected) +{ + struct stat st; + int fd = open_source_at(capture->parent_fd, capture->relative, + capture->nofollow, 1); + int ret = fd >= 0 && !fstat(fd, &st) && + path_namespace_stat_equal(expected, &st); + + if (fd >= 0) + close(fd); + return ret; +} + +static int same_observation( + const struct exclude_source_proof_entry *entry, + int exists, size_t size, const struct object_id *oid) +{ + return entry->exists == exists && + (!exists || + (entry->size == size && oideq(&entry->oid, oid))); +} + +static void record_observation( + struct exclude_source_capture *capture, int exists, + size_t size, const struct object_id *oid) +{ + struct exclude_source_proof *proof = capture->proof; + struct strintmap *map = + &proof->entries_by_path[!!capture->nofollow]; + struct exclude_source_proof_entry *entry; + int index = strintmap_get(map, capture->path); + + if (index >= 0) { + if (!same_observation(&proof->entries[index], + exists, size, oid)) + proof->invalid = 1; + return; + } + + ALLOC_GROW(proof->entries, proof->nr + 1, proof->alloc); + entry = &proof->entries[proof->nr]; + memset(entry, 0, sizeof(*entry)); + entry->path = xstrdup(capture->path); + entry->nofollow = capture->nofollow; + entry->exists = exists; + if (exists) { + entry->size = size; + oidcpy(&entry->oid, oid); + } + strintmap_set(map, entry->path, proof->nr); + proof->nr++; +} + +void exclude_source_capture_record( + struct exclude_source_capture *capture, + int source_fd, + const struct stat *source_stat, + const void *buf, size_t size) +{ + struct exclude_source_proof *proof; + struct object_id oid; + struct stat final; + + if (!capture) + return; + proof = capture->proof; + if (proof->invalid) + return; + + if (!source_stat) { + if (!exclude_source_capture_absent(capture) || + !parent_stable(capture) || + !exclude_source_capture_absent(capture)) { + proof->invalid = 1; + return; + } + record_observation(capture, 0, 0, NULL); + return; + } + + if (source_fd < 0 || source_stat->st_size < 0 || + (!buf && size) || + xsize_t(source_stat->st_size) != size || + fstat(source_fd, &final) || + !path_namespace_stat_equal(source_stat, &final) || + !source_matches(capture, &final) || + !parent_stable(capture)) { + proof->invalid = 1; + return; + } + hash_object_file(proof->istate->repo->hash_algo, buf, size, + OBJ_BLOB, &oid); + record_observation(capture, 1, size, &oid); +} + +void exclude_source_capture_release(struct exclude_source_capture *capture) +{ + capture_free(capture); +} + +static int proof_entry_matches( + struct exclude_source_proof *proof, + const struct exclude_source_proof_entry *entry) +{ + struct exclude_source_capture *capture = + capture_begin(proof, entry->path, entry->nofollow, 0); + struct object_id oid; + struct stat before, after, final; + char *buf = NULL; + size_t size; + int fd = -1; + int ret = 0; + + if (!capture) + goto done; + if (!entry->exists) { + ret = exclude_source_capture_absent(capture) && + parent_stable(capture) && + exclude_source_capture_absent(capture); + goto done; + } + + fd = open_source_at(capture->parent_fd, capture->relative, + entry->nofollow, 1); + if (fd < 0 || fstat(fd, &before) || before.st_size < 0 || + xsize_t(before.st_size) != entry->size) + goto done; + size = entry->size; + buf = xmalloc(size ? size : 1); + if ((size_t)read_in_full(fd, buf, size) != size || + fstat(fd, &after) || + !path_namespace_stat_equal(&before, &after) || + !source_matches(capture, &after)) + goto done; + hash_object_file(proof->istate->repo->hash_algo, buf, size, + OBJ_BLOB, &oid); + if (!oideq(&oid, &entry->oid) || + !parent_stable(capture) || + fstat(fd, &final) || + !path_namespace_stat_equal(&after, &final) || + !source_matches(capture, &final)) + goto done; + ret = 1; +done: + free(buf); + if (fd >= 0) + close(fd); + capture_free(capture); + return ret; +} + +int exclude_source_proof_validate(struct exclude_source_proof *proof) +{ + int valid; + + if (!proof) + return 0; + valid = !proof->invalid; + for (size_t i = 0; valid && i < proof->nr; i++) + valid = proof_entry_matches(proof, &proof->entries[i]); + if (proof->istate && proof->istate->repo) { + trace2_data_intmax("exclude", proof->istate->repo, + "proof_entries", proof->nr); + trace2_data_intmax("exclude", proof->istate->repo, + "proof_valid", valid); + } + return valid; +} + +void exclude_source_proof_release(struct exclude_source_proof *proof) +{ + if (!proof) + return; + strintmap_clear(&proof->entries_by_path[0]); + strintmap_clear(&proof->entries_by_path[1]); + for (size_t i = 0; i < proof->nr; i++) + free(proof->entries[i].path); + free(proof->entries); + free(proof); +} diff --git a/exclude-source-proof.h b/exclude-source-proof.h new file mode 100644 index 00000000000000..1949afd3929a1c --- /dev/null +++ b/exclude-source-proof.h @@ -0,0 +1,37 @@ +#ifndef EXCLUDE_SOURCE_PROOF_H +#define EXCLUDE_SOURCE_PROOF_H + +#if (defined(__APPLE__) || defined(__linux__)) && \ + defined(O_CLOEXEC) && defined(O_NONBLOCK) && \ + defined(O_NOFOLLOW) && defined(O_DIRECTORY) && \ + defined(AT_SYMLINK_NOFOLLOW) +#define EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN 1 +#else +#define EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN 0 +#endif + +struct exclude_source_capture; +struct exclude_source_proof; +struct index_state; +struct stat; + +typedef int (*exclude_source_open_parent_fn)(void *data, const char *path); + +struct exclude_source_proof *exclude_source_proof_create( + struct index_state *istate, void *open_data, + exclude_source_open_parent_fn open_parent); +struct exclude_source_capture *exclude_source_capture_begin( + struct exclude_source_proof *proof, const char *path, + int nofollow); +int exclude_source_capture_open(struct exclude_source_capture *capture); +int exclude_source_capture_absent(struct exclude_source_capture *capture); +void exclude_source_capture_record( + struct exclude_source_capture *capture, + int source_fd, + const struct stat *source_stat, + const void *buf, size_t size); +void exclude_source_capture_release(struct exclude_source_capture *capture); +int exclude_source_proof_validate(struct exclude_source_proof *proof); +void exclude_source_proof_release(struct exclude_source_proof *proof); + +#endif /* EXCLUDE_SOURCE_PROOF_H */ diff --git a/meson.build b/meson.build index f878fbda16b44c..d4dc3b71f91841 100644 --- a/meson.build +++ b/meson.build @@ -378,6 +378,7 @@ libgit_sources = [ 'editor.c', 'entry.c', 'environment.c', + 'exclude-source-proof.c', 'ewah/bitmap.c', 'ewah/ewah_bitmap.c', 'ewah/ewah_io.c', diff --git a/t/meson.build b/t/meson.build index 1be1bcd7be8075..de510614fad57b 100644 --- a/t/meson.build +++ b/t/meson.build @@ -10,6 +10,7 @@ clar_test_suites = [ 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', + 'unit-tests/u-exclude-source-proof.c', 'unit-tests/u-fsmonitor-attributes.c', 'unit-tests/u-fsmonitor-clean-proof.c', 'unit-tests/u-fsmonitor-response.c', diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c new file mode 100644 index 00000000000000..26d5f16e6f2e9c --- /dev/null +++ b/t/unit-tests/u-exclude-source-proof.c @@ -0,0 +1,404 @@ +#include "unit-test.h" + +#include "dir.h" +#include "exclude-source-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "wrapper.h" + +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + +static struct repository repo = { + .hash_algo = &hash_algos[GIT_HASH_SHA1], +}; +static struct index_state istate = { + .repo = &repo, +}; +static char *trash; +static int fail_open_parent; + +static int open_parent(void *data UNUSED, const char *path) +{ + if (fail_open_parent) { + errno = EACCES; + return -1; + } + return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); +} + +static struct exclude_source_proof *new_proof(void) +{ + return exclude_source_proof_create( + &istate, NULL, open_parent); +} + +static char *make_path(const char *name) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addf(&path, "%s/%s", trash, name); + return strbuf_detach(&path, NULL); +} + +static void record_file(struct exclude_source_proof *proof, const char *path) +{ + struct exclude_source_capture *capture = + exclude_source_capture_begin(proof, path, 0); + struct stat before, after; + char *buf; + size_t size; + ssize_t read_size; + int fd; + + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &before)); + cl_assert(before.st_size >= 0); + size = xsize_t(before.st_size); + buf = xmalloc(size ? size : 1); + read_size = read_in_full(fd, buf, size); + cl_assert(read_size >= 0 && (size_t)read_size == size); + cl_must_pass(fstat(fd, &after)); + exclude_source_capture_record(capture, fd, &after, buf, size); + exclude_source_capture_release(capture); + free(buf); + cl_must_pass(close(fd)); +} + +static void record_absence(struct exclude_source_proof *proof, + const char *path) +{ + struct exclude_source_capture *capture = + exclude_source_capture_begin(proof, path, 0); + + cl_assert(capture != NULL); + cl_assert(exclude_source_capture_absent(capture)); + exclude_source_capture_record(capture, -1, NULL, NULL, 0); + exclude_source_capture_release(capture); +} + +void test_exclude_source_proof__initialize(void) +{ + char template[] = "/tmp/exclude-source-proof-XXXXXX"; + + fail_open_parent = 0; + cl_assert(mkdtemp(template) != NULL); + trash = xstrdup(template); +} + +void test_exclude_source_proof__cleanup(void) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addstr(&path, trash); + cl_must_pass(remove_dir_recursively( + &path, REMOVE_DIR_PURGE_ORIGINAL_CWD)); + strbuf_release(&path); + FREE_AND_NULL(trash); +} + +void test_exclude_source_proof__accepts_same_content_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(unlink(source)); + write_file_buf(source, "content", 7); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_different_content_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(unlink(source)); + cl_assert(!exclude_source_proof_validate(proof)); + write_file_buf(source, "changed", 7); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__accepts_repeated_observation(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_missing_source_buffer(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + struct stat st; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + exclude_source_capture_record(capture, fd, &st, NULL, 7); + cl_assert(!exclude_source_proof_validate(proof)); + + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_conflicting_observations(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + write_file_buf(source, "changed", 7); + record_file(proof, source); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_open_failure(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + fail_open_parent = 1; + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__fails_closed_without_parent_opener(void) +{ + struct exclude_source_proof *proof = + exclude_source_proof_create(&istate, NULL, NULL); + + cl_assert(!exclude_source_capture_begin(proof, "/dev/null", 0)); + cl_assert(!exclude_source_proof_validate(proof)); + exclude_source_proof_release(proof); +} + +void test_exclude_source_proof__honors_nofollow(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + char *target = make_path("parent/target"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(target, "content", 7); + cl_must_pass(symlink("target", source)); + + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + + capture = exclude_source_capture_begin(proof, source, 1); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_assert(fd < 0 && errno == ELOOP); + exclude_source_capture_release(capture); + + exclude_source_proof_release(proof); + free(target); + free(source); + free(parent); +} + +void test_exclude_source_proof__opens_directory_sources(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + struct stat st; + char *parent = make_path("parent"); + char *source = make_path("parent/source/"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + cl_must_pass(mkdir(source, 0700)); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + cl_assert(S_ISDIR(st.st_mode)); + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + + capture = exclude_source_capture_begin(proof, "/", 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + cl_assert(S_ISDIR(st.st_mode)); + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__accepts_same_content_parent_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *old_parent = make_path("old-parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_must_pass(rename(parent, old_parent)); + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(old_parent); + free(parent); +} + +void test_exclude_source_proof__reresolves_absent_source_parent(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *missing = make_path("parent/missing"); + char *source = make_path("parent/missing/source"); + + cl_must_pass(mkdir(parent, 0700)); + cl_must_pass(mkdir(missing, 0700)); + record_absence(proof, source); + cl_must_pass(rmdir(missing)); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(mkdir(missing, 0700)); + cl_assert(exclude_source_proof_validate(proof)); + write_file_buf(source, "content", 7); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(missing); + free(parent); +} + +void test_exclude_source_proof__accepts_dev_null(void) +{ + struct exclude_source_proof *proof = new_proof(); + + record_file(proof, "/dev/null"); + cl_assert(exclude_source_proof_validate(proof)); + exclude_source_proof_release(proof); +} + +void test_exclude_source_proof__accepts_empty_fifo_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "", 0); + record_file(proof, source); + cl_must_pass(unlink(source)); + cl_must_pass(mkfifo(source, 0600)); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_nonempty_fifo_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_must_pass(unlink(source)); + cl_must_pass(mkfifo(source, 0600)); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +#else + +#define EMPTY_TEST(name) void name(void) {} +#define SKIP_TEST(name) void name(void) { cl_skip(); } + +EMPTY_TEST(test_exclude_source_proof__initialize) +EMPTY_TEST(test_exclude_source_proof__cleanup) +SKIP_TEST(test_exclude_source_proof__accepts_same_content_replacement) +SKIP_TEST(test_exclude_source_proof__rejects_different_content_replacement) +SKIP_TEST(test_exclude_source_proof__accepts_repeated_observation) +SKIP_TEST(test_exclude_source_proof__rejects_missing_source_buffer) +SKIP_TEST(test_exclude_source_proof__rejects_conflicting_observations) +SKIP_TEST(test_exclude_source_proof__rejects_open_failure) +SKIP_TEST(test_exclude_source_proof__fails_closed_without_parent_opener) +SKIP_TEST(test_exclude_source_proof__honors_nofollow) +SKIP_TEST(test_exclude_source_proof__opens_directory_sources) +SKIP_TEST(test_exclude_source_proof__accepts_same_content_parent_replacement) +SKIP_TEST(test_exclude_source_proof__reresolves_absent_source_parent) +SKIP_TEST(test_exclude_source_proof__accepts_dev_null) +SKIP_TEST(test_exclude_source_proof__accepts_empty_fifo_replacement) +SKIP_TEST(test_exclude_source_proof__rejects_nonempty_fifo_replacement) + +#endif From 217941b343c26c44fb80e44d0cba53664a2702b9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:31 -0500 Subject: [PATCH 041/403] dir: capture ignore sources beneath anchored parents The ordinary exclude reader opens configured, repository, and per-directory ignore files by pathname. That is sufficient for a one-off walk, but a concurrent replacement or newly created ignore file makes a retained bulk result unsafe. Attach the optional source proof from S13/P01 to dir_struct and capture the exact bytes or stable absence observed by add_patterns(). Preserve symlink-following for standard excludes and the existing no-follow policy for per-directory .gitignore files. Visit configured and repository sources even when absent so their later appearance invalidates the proof. Mark failed, oversized, short, and index-backed reads unprovable rather than treating their results as stable filesystem observations. Existing callers without a proof retain their original opens, error handling, pattern parsing, and oversized-source guard. Signed-off-by: Taylor Blau --- dir.c | 72 +++++++++++++++++++++++++++++++++--------- dir.h | 7 ++++ exclude-source-proof.c | 6 ++++ exclude-source-proof.h | 1 + 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/dir.c b/dir.c index 8b4d4cab260ed3..4e2e474e083871 100644 --- a/dir.c +++ b/dir.c @@ -15,6 +15,7 @@ #include "convert.h" #include "dir.h" #include "environment.h" +#include "exclude-source-proof.h" #include "gettext.h" #include "name-hash.h" #include "object-file.h" @@ -2017,40 +2018,63 @@ static void invalidate_directory(struct untracked_cache *uc, */ static int add_patterns(const char *fname, const char *base, int baselen, struct pattern_list *pl, struct index_state *istate, - unsigned flags, struct oid_stat *oid_stat) + unsigned flags, struct oid_stat *oid_stat, + struct exclude_source_proof *source_proof) { + struct exclude_source_capture *capture = + exclude_source_capture_begin(source_proof, fname, + !!(flags & PATTERN_NOFOLLOW)); struct stat st; int r; int fd; size_t size = 0; char *buf; - if (flags & PATTERN_NOFOLLOW) + if (capture) + fd = exclude_source_capture_open(capture); + else if (flags & PATTERN_NOFOLLOW) fd = open_nofollow(fname, O_RDONLY); else fd = open(fname, O_RDONLY); if (fd < 0 || fstat(fd, &st) < 0) { - if (fd < 0) + if (fd < 0) { warn_on_fopen_errors(fname); - else + if (capture && exclude_source_capture_absent(capture)) + exclude_source_capture_record(capture, -1, NULL, + NULL, 0); + else + exclude_source_capture_error(capture); + } else { + exclude_source_capture_error(capture); close(fd); - if (!istate) + } + if (!istate) { + exclude_source_capture_release(capture); return -1; + } r = read_skip_worktree_file_from_index(istate, fname, &size, &buf, oid_stat); + if (r == 1) + exclude_source_capture_error(capture); + exclude_source_capture_release(capture); + capture = NULL; if (r != 1) return r; } else { size = xsize_t(st.st_size); if (size > PATTERN_MAX_FILE_SIZE) { + exclude_source_capture_error(capture); + exclude_source_capture_release(capture); warning("ignoring excessively large pattern file: %s", fname); close(fd); return -1; } if (size == 0) { + exclude_source_capture_record(capture, fd, &st, NULL, 0); + exclude_source_capture_release(capture); if (oid_stat) { fill_stat_data(&oid_stat->stat, &st); oidcpy(&oid_stat->oid, the_hash_algo->empty_blob); @@ -2061,10 +2085,15 @@ static int add_patterns(const char *fname, const char *base, int baselen, } buf = xmallocz(size); if (read_in_full(fd, buf, size) != size) { + exclude_source_capture_error(capture); + exclude_source_capture_release(capture); free(buf); close(fd); return -1; } + exclude_source_capture_record(capture, fd, &st, buf, size); + exclude_source_capture_release(capture); + capture = NULL; buf[size++] = '\n'; close(fd); if (oid_stat) { @@ -2137,7 +2166,8 @@ int add_patterns_from_file_to_list(const char *fname, const char *base, struct index_state *istate, unsigned flags) { - return add_patterns(fname, base, baselen, pl, istate, flags, NULL); + return add_patterns(fname, base, baselen, pl, istate, flags, NULL, + NULL); } int add_patterns_from_blob_to_list( @@ -2182,10 +2212,11 @@ struct pattern_list *add_pattern_list(struct dir_struct *dir, /* * Used to set up core.excludesfile and .git/info/exclude lists. */ -static void add_patterns_from_file_1(struct dir_struct *dir, const char *fname, - struct oid_stat *oid_stat) +static int add_patterns_from_file_1(struct dir_struct *dir, const char *fname, + struct oid_stat *oid_stat, int gentle) { struct pattern_list *pl; + int ret; /* * catch setup_standard_excludes() that's called before * dir->untracked is assigned. That function behaves @@ -2194,14 +2225,17 @@ static void add_patterns_from_file_1(struct dir_struct *dir, const char *fname, if (!dir->untracked) dir->internal.unmanaged_exclude_files++; pl = add_pattern_list(dir, EXC_FILE, fname); - if (add_patterns(fname, "", 0, pl, NULL, 0, oid_stat) < 0) + ret = add_patterns(fname, "", 0, pl, NULL, 0, oid_stat, + dir->internal.exclude_source_proof); + if (ret < 0 && !gentle) die(_("cannot use %s as an exclude file"), fname); + return ret; } void add_patterns_from_file(struct dir_struct *dir, const char *fname) { dir->internal.unmanaged_exclude_files++; /* see validate_untracked_cache() */ - add_patterns_from_file_1(dir, fname, NULL); + add_patterns_from_file_1(dir, fname, NULL, 0); } int match_basename(const char *basename, int basenamelen, @@ -2651,7 +2685,8 @@ static void prep_exclude(struct dir_struct *dir, pl->src = strbuf_detach(&sb, NULL); if (add_patterns(pl->src, pl->src, stk->baselen, pl, istate, PATTERN_NOFOLLOW, - untracked ? &oid_stat : NULL) < 0 && + untracked ? &oid_stat : NULL, + dir->internal.exclude_source_proof) < 0 && untracked && is_null_oid(&oid_stat.oid)) { struct stat st; @@ -4453,16 +4488,23 @@ void setup_standard_excludes(struct dir_struct *dir) dir->exclude_per_dir = ".gitignore"; /* core.excludesfile defaulting to $XDG_CONFIG_HOME/git/ignore */ - if (excludes_file && !access_or_warn(excludes_file, R_OK, 0)) + if (excludes_file && + (dir->internal.exclude_source_proof || + !access_or_warn(excludes_file, R_OK, 0))) add_patterns_from_file_1(dir, excludes_file, - dir->untracked ? &dir->internal.ss_excludes_file : NULL); + dir->untracked ? + &dir->internal.ss_excludes_file : NULL, + !!dir->internal.exclude_source_proof); /* per repository user preference */ if (startup_info->have_repository) { const char *path = git_path_info_exclude(); - if (!access_or_warn(path, R_OK, 0)) + if (dir->internal.exclude_source_proof || + !access_or_warn(path, R_OK, 0)) add_patterns_from_file_1(dir, path, - dir->untracked ? &dir->internal.ss_info_exclude : NULL); + dir->untracked ? + &dir->internal.ss_info_exclude : NULL, + !!dir->internal.exclude_source_proof); } } diff --git a/dir.h b/dir.h index f6df0b54d271e9..23eed870a0e235 100644 --- a/dir.h +++ b/dir.h @@ -7,6 +7,7 @@ #include "statinfo.h" #include "strbuf.h" +struct exclude_source_proof; struct repository; /** @@ -364,6 +365,12 @@ struct dir_struct { unsigned visited_paths; unsigned visited_directories; unsigned untracked_cache_preloaded : 1; + + /* + * Optional borrowed proof that covers every exclusion source + * consulted by this traversal. + */ + struct exclude_source_proof *exclude_source_proof; } internal; }; diff --git a/exclude-source-proof.c b/exclude-source-proof.c index ec5194a1fa355e..022aa5a7ba1d5a 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -335,6 +335,12 @@ void exclude_source_capture_record( record_observation(capture, 1, size, &oid); } +void exclude_source_capture_error(struct exclude_source_capture *capture) +{ + if (capture) + capture->proof->invalid = 1; +} + void exclude_source_capture_release(struct exclude_source_capture *capture) { capture_free(capture); diff --git a/exclude-source-proof.h b/exclude-source-proof.h index 1949afd3929a1c..e2932f535fb7a4 100644 --- a/exclude-source-proof.h +++ b/exclude-source-proof.h @@ -30,6 +30,7 @@ void exclude_source_capture_record( int source_fd, const struct stat *source_stat, const void *buf, size_t size); +void exclude_source_capture_error(struct exclude_source_capture *capture); void exclude_source_capture_release(struct exclude_source_capture *capture); int exclude_source_proof_validate(struct exclude_source_proof *proof); void exclude_source_proof_release(struct exclude_source_proof *proof); From 9278f7cd215cd3a78331653025a1e73b0a43666e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:14:28 -0500 Subject: [PATCH 042/403] preload-index: stage proven untracked bulk results A tracked-file bulk preload can already walk directories that ordinary status later scans for untracked files. Sharing those observations requires a complete, independently validated result; a partial list must never suppress the conventional untracked traversal. Add an explicit backend capability and optional borrowed destination for visible paths. Serialize the existing ignore matcher across scan workers, collapse an untracked directory after its first visible descendant, and sort the provisional results. Publish them only after the directory scan and the anchored ignore-source proof both complete. Reject duplicate paths and discard incomplete or conflicting untracked results without discarding independently valid tracked observations. Report completeness, visible-path count, and fallback reason through Trace2, and release all temporary path and proof state. No existing backend advertises the new capability and no status caller requests it at this boundary. Ordinary tracked and untracked behavior therefore remains unchanged. Signed-off-by: Taylor Blau --- preload-index-bulk-thread.c | 2 + preload-index-bulk.c | 197 ++++++++++++++++++++++++++++++++++++ preload-index-bulk.h | 33 ++++++ preload-index.c | 17 ++++ read-cache-ll.h | 3 + 5 files changed, 252 insertions(+) diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index b1a8d430d8e21e..61702a5a5ba07a 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -244,6 +244,8 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, result->malformed += worker->malformed; } result->threads = started_threads; + result->untracked_complete = + scan->collect_untracked && !scan->queue.untracked_invalid; failed = scan->queue.failed || result->malformed || result->changed_dirs; diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 41b2398d3913f3..cd53761358af4f 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -1,8 +1,23 @@ #include "git-compat-util.h" +#include "abspath.h" +#include "dir.h" +#include "exclude-source-proof.h" #include "name-hash.h" #include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" +#include "trace2.h" + +struct preload_bulk_untracked_root { + struct preload_bulk_untracked_root *next; + /* + * Normal-mode status reports an untracked directory after finding + * one visible descendant. Share that decision among workers below + * the directory. + */ + unsigned visible : 1; + char path[FLEX_ARRAY]; +}; static int backend_available(const struct preload_bulk_backend *backend) { @@ -11,6 +26,15 @@ static int backend_available(const struct preload_bulk_backend *backend) backend->scan_directory; } +static int open_exclude_parent(void *data, const char *path) +{ + struct preload_bulk_scan *scan = data; + + if (is_absolute_path(path)) + return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + return scan->backend->open_proof_parent(scan, path); +} + int preload_bulk_available(void) { return backend_available(preload_bulk_platform_backend()); @@ -35,9 +59,120 @@ int preload_bulk_test_barrier(struct preload_bulk_scan *scan, return result; } +int preload_bulk_path_is_excluded(struct preload_bulk_worker *worker, + const char *path, int dtype) +{ + struct preload_bulk_scan *scan = worker->scan; + int result; + + if (!scan->exclude_dir) + BUG("bulk preload has no exclude state"); + pthread_mutex_lock(&scan->exclude_mutex); + result = is_excluded(scan->exclude_dir, scan->istate, path, &dtype); + pthread_mutex_unlock(&scan->exclude_mutex); + return result; +} + +void preload_bulk_invalidate_untracked( + struct preload_bulk_worker *worker) +{ + struct preload_bulk_queue *queue = &worker->scan->queue; + + pthread_mutex_lock(&queue->mutex); + queue->untracked_invalid = 1; + pthread_mutex_unlock(&queue->mutex); +} + +int preload_bulk_untracked_is_invalid( + struct preload_bulk_worker *worker) +{ + struct preload_bulk_queue *queue = &worker->scan->queue; + int invalid; + + pthread_mutex_lock(&queue->mutex); + invalid = queue->untracked_invalid; + pthread_mutex_unlock(&queue->mutex); + return invalid; +} + +struct preload_bulk_untracked_root *preload_bulk_untracked_root_new( + struct preload_bulk_worker *worker, const char *path, + size_t path_len) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_untracked_root *root; + + FLEX_ALLOC_MEM(root, path, path, path_len + 1); + root->path[path_len] = '/'; + root->path[path_len + 1] = '\0'; + + pthread_mutex_lock(&scan->queue.mutex); + root->next = scan->untracked_roots; + scan->untracked_roots = root; + pthread_mutex_unlock(&scan->queue.mutex); + return root; +} + +int preload_bulk_untracked_root_is_visible( + struct preload_bulk_worker *worker MAYBE_UNUSED, + const struct preload_bulk_untracked_root *root) +{ + int visible; + + if (!root) + return 0; + pthread_mutex_lock(&worker->scan->queue.mutex); + visible = root->visible; + pthread_mutex_unlock(&worker->scan->queue.mutex); + return visible; +} + +void preload_bulk_record_untracked( + struct preload_bulk_worker *worker, + struct preload_bulk_untracked_root *root, + const char *path) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_queue *queue = &scan->queue; + int record = 1; + + pthread_mutex_lock(&queue->mutex); + if (queue->untracked_invalid) + record = 0; + else if (root) { + if (root->visible) + record = 0; + else + root->visible = 1; + } + if (record) + string_list_append(&scan->untracked, + root ? root->path : path); + pthread_mutex_unlock(&queue->mutex); +} + +static int collect_untracked_paths(struct preload_bulk_scan *scan, + struct preload_bulk_result *result) +{ + /* + * Do not publish provisional output until all closing validations + * have succeeded. + */ + string_list_sort(&scan->untracked); + for (size_t i = 1; i < scan->untracked.nr; i++) + if (!strcmp(scan->untracked.items[i - 1].string, + scan->untracked.items[i].string)) + return -1; + result->untracked = scan->untracked; + scan->untracked = (struct string_list)STRING_LIST_INIT_DUP; + return 0; +} + int preload_bulk_collect(struct index_state *istate, int threads, struct preload_bulk_result *result) { + struct dir_struct exclude_dir = DIR_INIT; + struct exclude_source_proof *exclude_proof = NULL; const struct preload_bulk_backend *backend = preload_bulk_platform_backend(); struct preload_bulk_scan scan = { @@ -46,13 +181,16 @@ int preload_bulk_collect(struct index_state *istate, int threads, .backend = backend, .root_fd = -1, .threads = threads, + .untracked = STRING_LIST_INIT_DUP, }; struct preload_bulk_run_result run_result = { 0 }; const char *start_error, *finish_error = NULL; + const char *untracked_reason = NULL; int scan_error = -1; int clean; memset(result, 0, sizeof(*result)); + result->untracked.strdup_strings = 1; result->outcome = "start-fallback"; result->reason = "backend-unavailable"; if (!backend_available(backend)) @@ -69,6 +207,20 @@ int preload_bulk_collect(struct index_state *istate, int threads, scan.case_insensitive = prepare_index_casefolding(istate); scan.can_skip_unseen_preload = 1; } + scan.collect_untracked = + !!istate->preload_untracked && + backend->collects_untracked && + backend->open_proof_parent; + if (istate->preload_untracked && !scan.collect_untracked) + untracked_reason = "backend-unsupported"; + if (scan.collect_untracked) { + scan.exclude_dir = &exclude_dir; +#if HAVE_THREADS + if (pthread_mutex_init(&scan.exclude_mutex, NULL)) { + return -1; + } +#endif + } if (git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) { scan.test_barrier_path = getenv( @@ -82,16 +234,44 @@ int preload_bulk_collect(struct index_state *istate, int threads, CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); if (!start_error) { + if (scan.collect_untracked) { + exclude_proof = exclude_source_proof_create( + istate, &scan, open_exclude_parent); + exclude_dir.internal.exclude_source_proof = + exclude_proof; + setup_standard_excludes(&exclude_dir); + } scan_error = preload_bulk_run_scan(&scan, &run_result); if (!scan_error) scan_error = preload_bulk_test_barrier(&scan, ""); finish_error = backend->finish(&scan); + if (!scan_error && !finish_error && + run_result.untracked_complete) { + int exclude_proof_valid; + + trace2_region_enter( + "index", "preload/bulk_excludes", istate->repo); + exclude_proof_valid = + exclude_source_proof_validate(exclude_proof); + if (!exclude_proof_valid) { + run_result.untracked_complete = 0; + untracked_reason = "exclude-race"; + } + trace2_region_leave( + "index", "preload/bulk_excludes", istate->repo); + } } clean = !start_error && !scan_error && !finish_error && !run_result.changed_dirs && !run_result.malformed; + if (clean && run_result.untracked_complete && + collect_untracked_paths(&scan, result)) { + run_result.untracked_complete = 0; + untracked_reason = "duplicate-path"; + } result->run = run_result; + result->untracked_reason = untracked_reason; if (start_error) { result->outcome = "start-fallback"; result->reason = start_error; @@ -116,10 +296,26 @@ int preload_bulk_collect(struct index_state *istate, int threads, result->nr = istate->cache_nr; result->can_skip_unseen_preload = scan.can_skip_unseen_preload; + result->untracked_complete = run_result.untracked_complete; scan.tracked_state = NULL; } backend->release(&scan); + while (scan.untracked_roots) { + struct preload_bulk_untracked_root *next = + scan.untracked_roots->next; + + free(scan.untracked_roots); + scan.untracked_roots = next; + } + string_list_clear(&scan.untracked, 0); + if (scan.exclude_dir) { +#if HAVE_THREADS + pthread_mutex_destroy(&scan.exclude_mutex); +#endif + dir_clear(&exclude_dir); + exclude_source_proof_release(exclude_proof); + } free(scan.tracked_state); return clean ? 0 : -1; } @@ -127,5 +323,6 @@ int preload_bulk_collect(struct index_state *istate, int threads, void preload_bulk_result_release(struct preload_bulk_result *result) { FREE_AND_NULL(result->tracked_state); + string_list_clear(&result->untracked, 0); memset(result, 0, sizeof(*result)); } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index fff436d23f8d4b..317a9cb244275d 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -4,8 +4,12 @@ #include "git-compat-util.h" #include "preload-index.h" #include "strbuf.h" +#include "string-list.h" #include "thread-utils.h" +struct dir_struct; +struct preload_bulk_untracked_root; + struct preload_bulk_dir_identity { struct stat stat; unsigned complete : 1; @@ -34,6 +38,7 @@ struct preload_bulk_queue { size_t open_fds; size_t open_fd_limit; int failed; + unsigned untracked_invalid : 1; }; struct preload_bulk_scan; @@ -52,9 +57,12 @@ struct preload_bulk_worker { }; struct preload_bulk_backend { + unsigned collects_untracked : 1; const char *(*start)(struct preload_bulk_scan *scan); const char *(*finish)(struct preload_bulk_scan *scan); void (*release)(struct preload_bulk_scan *scan); + int (*open_proof_parent)(struct preload_bulk_scan *scan, + const char *path); int (*open_dir_at)(struct preload_bulk_worker *worker, int parent_fd, const char *name); /* @@ -76,8 +84,13 @@ struct preload_bulk_scan { struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; + struct dir_struct *exclude_dir; + pthread_mutex_t exclude_mutex; + struct preload_bulk_untracked_root *untracked_roots; + struct string_list untracked; int root_fd; int threads; + unsigned collect_untracked : 1; unsigned case_insensitive : 1; unsigned can_skip_unseen_preload : 1; }; @@ -89,6 +102,7 @@ struct preload_bulk_run_result { uint64_t changed_dirs; uint64_t malformed; int threads; + unsigned untracked_complete : 1; }; struct preload_bulk_result { @@ -96,8 +110,11 @@ struct preload_bulk_result { size_t nr; const char *outcome; const char *reason; + const char *untracked_reason; struct preload_bulk_run_result run; unsigned can_skip_unseen_preload : 1; + struct string_list untracked; + unsigned untracked_complete : 1; }; void preload_bulk_schedule_directory( @@ -120,6 +137,22 @@ void preload_bulk_record_tracked_descendants_fallback( int preload_bulk_record_tracked_alias_fallback( struct preload_bulk_worker *worker, const char *path, size_t path_len); +int preload_bulk_path_is_excluded(struct preload_bulk_worker *worker, + const char *path, int dtype); +void preload_bulk_invalidate_untracked( + struct preload_bulk_worker *worker); +int preload_bulk_untracked_is_invalid( + struct preload_bulk_worker *worker); +struct preload_bulk_untracked_root *preload_bulk_untracked_root_new( + struct preload_bulk_worker *worker, const char *path, + size_t path_len); +int preload_bulk_untracked_root_is_visible( + struct preload_bulk_worker *worker, + const struct preload_bulk_untracked_root *root); +void preload_bulk_record_untracked( + struct preload_bulk_worker *worker, + struct preload_bulk_untracked_root *root, + const char *path); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); const struct preload_bulk_backend *preload_bulk_platform_backend(void); diff --git a/preload-index.c b/preload-index.c index 72d37ae93e67d0..fbe83f8e7a1a89 100644 --- a/preload-index.c +++ b/preload-index.c @@ -251,6 +251,10 @@ static void preload_bulk_trace_result( if (result->reason) trace2_data_string("index", index->repo, "preload/bulk_reason", result->reason); + if (result->untracked_reason) + trace2_data_string("index", index->repo, + "preload/bulk_untracked_reason", + result->untracked_reason); trace2_data_intmax("index", index->repo, "preload/bulk_applied", applied); trace2_data_intmax("index", index->repo, "preload/bulk_dirs", @@ -271,6 +275,12 @@ static void preload_bulk_trace_result( "preload/bulk_content_check", content_check); trace2_data_intmax("index", index->repo, "preload/bulk_fallback", fallback); + trace2_data_intmax("index", index->repo, + "preload/bulk_untracked_complete", + result->untracked_complete); + trace2_data_intmax("index", index->repo, + "preload/bulk_untracked_count", + result->untracked.nr); } static unsigned char *preload_bulk_try(struct index_state *index) @@ -315,6 +325,11 @@ static unsigned char *preload_bulk_try(struct index_state *index) tracked_state = result.tracked_state; result.tracked_state = NULL; } + if (result.untracked_complete && index->preload_untracked) { + *index->preload_untracked = result.untracked; + result.untracked = + (struct string_list)STRING_LIST_INIT_DUP; + } trace2_region_leave("index", "preload/bulk", index->repo); preload_bulk_result_release(&result); return tracked_state; @@ -353,6 +368,8 @@ void preload_index(struct index_state *index, int core_preload_index = 1; preload_index_bulk_result_clear(index); + if (index->preload_untracked) + string_list_clear(index->preload_untracked, 0); repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); if (!core_preload_index) diff --git a/read-cache-ll.h b/read-cache-ll.h index 312b1f9a83e957..85881551735987 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -144,6 +144,7 @@ static inline unsigned create_ce_flags(unsigned stage) struct split_index; struct clean_status_state; struct untracked_cache; +struct string_list; struct progress; struct pattern_list; @@ -197,6 +198,8 @@ struct index_state { struct untracked_cache *untracked; unsigned char *preload_bulk_tracked_state; size_t preload_bulk_tracked_nr; + /* Borrowed for the duration of preload_index(). */ + struct string_list *preload_untracked; char *fsmonitor_last_update; char *fsmonitor_last_update_pending; char *fsmonitor_untracked_token; From aa4044391675b6aeaf3c4fe5a46b846254165571 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:26:44 -0500 Subject: [PATCH 043/403] preload-index: collect visible paths during the APFS walk The APFS tracked preload encounters untracked entries but ordinarily discards them. Repeating the entire directory walk to rediscover those entries costs work even when the existing scan can establish their visibility. Teach the APFS backend to advertise visible-path collection and supply its root-anchored exclude-parent opener. Classify regular files and symlinks with the normal exclusion machinery, and follow untracked directories only until their first visible descendant establishes the single directory entry that normal-mode status reports. Keep the top-level Git directory and tracked gitlinks out of the untracked result. Case aliases, embedded repositories, and foreign mounts invalidate provisional untracked observations while retaining separately valid tracked results. A replaced directory increments changed_dirs, so scan-wide validation discards the entire bulk result. Carry the collapsed-directory root through queued workers. Ordinary status remains unchanged until a caller explicitly requests the new backend capability. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 106 ++++++++++++++++++++++++++--- preload-index-bulk-index.c | 7 ++ preload-index-bulk-thread.c | 4 ++ preload-index-bulk.h | 4 ++ 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 882cc64a41f64b..7e65c5a24c78eb 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -6,6 +6,7 @@ #include "compat/precompose_utf8.h" #include "compat/preload-index/bulk-darwin.h" +#include "dir.h" #include "path-namespace.h" #include "preload-index-bulk.h" @@ -329,6 +330,10 @@ static int enumerate_directory(struct preload_bulk_worker *worker, size_t remaining; int pos; + if (scan->collect_untracked && + preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) + return 0; remaining = buf + PRELOAD_INDEX_BULK_BUFFER_SIZE - record; if (decode_entry(record, remaining, &entry)) goto malformed; @@ -344,6 +349,17 @@ static int enumerate_directory(struct preload_bulk_worker *worker, strbuf_addstr(&worker->path, path_name); if (path_name != entry.name) free((char *)path_name); + if (scan->collect_untracked) { + if (!strcmp(task->path, ".") && + !fspathcmp(worker->path.buf, ".git")) + goto next_record; + if (strcmp(task->path, ".") && + !fspathcmp(entry.name, ".git")) { + preload_bulk_invalidate_untracked( + worker); + goto next_record; + } + } pos = preload_bulk_index_position(scan, worker->path.buf, worker->path.len); @@ -358,20 +374,51 @@ static int enumerate_directory(struct preload_bulk_worker *worker, .st_ctimespec = entry.ctime, }, }; + struct preload_bulk_untracked_root *untracked_root = + task->untracked_root; + int has_tracked_descendants; if (pos >= 0) { + if (scan->collect_untracked && + preload_bulk_index_entry_is_gitlink( + scan, pos)) + goto next_record; preload_bulk_record_tracked_fallback( worker, pos); goto next_record; } - if (!preload_bulk_index_pos_has_tracked_descendants( - scan, worker->path.buf, - worker->path.len, pos)) { - preload_bulk_record_tracked_alias_fallback( - worker, worker->path.buf, - worker->path.len); + has_tracked_descendants = + preload_bulk_index_pos_has_tracked_descendants( + scan, worker->path.buf, + worker->path.len, pos); + if (!has_tracked_descendants && + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len)) { + if (scan->collect_untracked) + preload_bulk_invalidate_untracked( + worker); goto next_record; } + if (!has_tracked_descendants) { + if (!scan->collect_untracked || + preload_bulk_untracked_is_invalid( + worker)) + goto next_record; + if (preload_bulk_untracked_root_is_visible( + worker, untracked_root)) + goto next_record; + if (preload_bulk_path_is_excluded( + worker, worker->path.buf, + DT_DIR)) + goto next_record; + if (!untracked_root) + untracked_root = + preload_bulk_untracked_root_new( + worker, + worker->path.buf, + worker->path.len); + } if (((entry.access & S_IFMT) && (entry.access & S_IFMT) != S_IFDIR) || (entry.access & ~(S_IFMT | 07777))) @@ -382,20 +429,50 @@ static int enumerate_directory(struct preload_bulk_worker *worker, preload_bulk_record_tracked_descendants_fallback( worker, worker->path.buf, worker->path.len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked( + worker); goto next_record; } preload_bulk_schedule_directory( worker, fd, parent_identity, - &child_identity, entry.name, + &child_identity, untracked_root, + entry.name, worker->path.buf, worker->path.len); goto next_record; } if (pos < 0) { - preload_bulk_record_tracked_alias_fallback( - worker, worker->path.buf, - worker->path.len); + int found_alias = + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); + + if (scan->collect_untracked && + !preload_bulk_untracked_is_invalid( + worker)) { + int dtype; + + if (found_alias) { + preload_bulk_invalidate_untracked( + worker); + goto next_record; + } + if (entry.type == VREG) + dtype = DT_REG; + else if (entry.type == VLNK) + dtype = DT_LNK; + else + goto next_record; + if (!preload_bulk_path_is_excluded( + worker, worker->path.buf, + dtype)) + preload_bulk_record_untracked( + worker, + task->untracked_root, + worker->path.buf); + } goto next_record; } if (entry.dev != data->root_stat.st_dev) { @@ -457,6 +534,8 @@ static int scan_directory(struct preload_bulk_worker *worker, path_len = strlen(task->path); preload_bulk_record_tracked_descendants_fallback( worker, task->path, path_len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); ret = 0; goto out; } @@ -471,7 +550,10 @@ static int scan_directory(struct preload_bulk_worker *worker, goto out; } before_identity = directory_identity(&before); - if (enumerate_directory(worker, task, fd, &before_identity)) + if ((!scan->collect_untracked || + !preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) && + enumerate_directory(worker, task, fd, &before_identity)) goto out; if (fstat(fd, &after)) goto out; @@ -518,9 +600,11 @@ static const char *finish_scan(struct preload_bulk_scan *scan) } static const struct preload_bulk_backend darwin_backend = { + .collects_untracked = 1, .start = start_scan, .finish = finish_scan, .release = preload_bulk_darwin_release, + .open_proof_parent = preload_bulk_darwin_open_relative, .open_dir_at = preload_bulk_darwin_open_dir_at, .scan_directory = scan_directory, }; diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index bd26b72ccb2986..b09b69582397a8 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "name-hash.h" +#include "object.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -108,6 +109,12 @@ static int size_change_is_definitive(const struct cache_entry *ce, DATA_CHANGED); } +int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, + int pos) +{ + return pos >= 0 && S_ISGITLINK(scan->istate->cache[pos]->ce_mode); +} + void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st) { diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 61702a5a5ba07a..793bb79f6a670a 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -53,6 +53,7 @@ void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, const struct preload_bulk_dir_identity *child_identity, + struct preload_bulk_untracked_root *untracked_root, const char *name, const char *path, size_t path_len) { struct preload_bulk_scan *scan = worker->scan; @@ -67,6 +68,7 @@ void preload_bulk_schedule_directory( task->child_identity = *child_identity; task->has_child_identity = 1; } + task->untracked_root = untracked_root; task->fd = -1; if (reserve_open_fd(&scan->queue)) { task->reserved_fd = 1; @@ -79,6 +81,8 @@ void preload_bulk_schedule_directory( if (saved_errno == EXDEV) { preload_bulk_record_tracked_descendants_fallback( worker, path, path_len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); free(task); return; } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 317a9cb244275d..2934d31b8674c1 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -19,6 +19,7 @@ struct preload_bulk_task { struct preload_bulk_task *next; struct preload_bulk_dir_identity parent_identity; struct preload_bulk_dir_identity child_identity; + struct preload_bulk_untracked_root *untracked_root; int fd; unsigned reserved_fd : 1; unsigned has_parent_identity : 1; @@ -121,12 +122,15 @@ void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, const struct preload_bulk_dir_identity *child_identity, + struct preload_bulk_untracked_root *untracked_root, const char *name, const char *path, size_t path_len); int preload_bulk_index_position(struct preload_bulk_scan *scan, const char *path, size_t path_len); int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos); +int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, + int pos); void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st); void preload_bulk_record_tracked_fallback( From 7d5f5920390103bbeb519f1387ed764eab83e37c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:27:01 -0500 Subject: [PATCH 044/403] status: reuse complete APFS untracked preload results Normal-mode status walks the worktree for untracked paths even after the opted-in APFS preloader has visited the same directories. Reusing that walk is incorrect if status requests different reporting semantics or the bulk scan cannot prove that its exclusion sources remain valid. Request visible paths only for an expanded index in normal untracked mode without a pathspec, ignored output, or an untracked cache. The bulk path requires core.preloadIndex, core.preloadIndexBulk, and a disabled fsmonitor. Transfer paths only after the bulk scan, worktree-namespace checks, and anchored exclusion-source proof finish successfully. Complete the ordinary tracked refresh, clear the borrowed index destination, and skip the second directory walk only for a complete untracked result. Retain ordinary traversal for unsupported backends, incomplete proofs, case aliases, embedded repositories, changed exclusion sources, and ineligible reporting modes. A failed untracked proof preserves independently valid tracked results; a changed directory instead invalidates the entire bulk scan. Extend the APFS tests to compare output with ordinary status. Cover collapsed directories, ignored-only and empty directories, special files, activation guards, case aliases, nested repositories, tracked submodules, separate Git directories, changed configured and repository exclusions, a newly appearing configured exclusion, bidirectional per-directory changes, hard-linked exclusions, and tracked-file replacement. Signed-off-by: Taylor Blau --- preload-index.c | 2 + read-cache-ll.h | 3 +- t/t7529-preload-index-apfs.sh | 264 ++++++++++++++++++++++++++++++++++ wt-status.c | 21 ++- wt-status.h | 2 + 5 files changed, 289 insertions(+), 3 deletions(-) diff --git a/preload-index.c b/preload-index.c index fbe83f8e7a1a89..d7c7f99896c28e 100644 --- a/preload-index.c +++ b/preload-index.c @@ -327,6 +327,7 @@ static unsigned char *preload_bulk_try(struct index_state *index) } if (result.untracked_complete && index->preload_untracked) { *index->preload_untracked = result.untracked; + index->preload_untracked_complete = 1; result.untracked = (struct string_list)STRING_LIST_INIT_DUP; } @@ -368,6 +369,7 @@ void preload_index(struct index_state *index, int core_preload_index = 1; preload_index_bulk_result_clear(index); + index->preload_untracked_complete = 0; if (index->preload_untracked) string_list_clear(index->preload_untracked, 0); repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); diff --git a/read-cache-ll.h b/read-cache-ll.h index 85881551735987..32496bade97a6f 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -190,7 +190,8 @@ struct index_state { fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, - fsmonitor_pending_token_from_provider : 1; + fsmonitor_pending_token_from_provider : 1, + preload_untracked_complete : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index c7c399045f2e1e..b726b7559b9b3d 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -74,6 +74,23 @@ compare_status () { test_cmp expect actual } +compare_fallback_status () { + repo=$1 && + fallback_trace=$TRASH_DIRECTORY/$2 && + shift 2 && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$repo" -c core.preloadIndex=false \ + status --porcelain=v2 "$@" >expect && + rm -f "$fallback_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$fallback_trace" \ + git -C "$repo" status --porcelain=v2 "$@" >actual && + test_cmp expect actual && + test_grep "\"category\":\"read_directory\"" "$fallback_trace" +} + configured_bulk_status () { repo=$1 && output=$2 && @@ -182,11 +199,23 @@ finish_raced_status () { test_trace2_data index preload/bulk_applied 0 <"$race_trace" } +finish_raced_untracked_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= && + ordinary_status "$1" expect && + test_cmp expect actual && + test_trace2_data index preload/bulk_applied "$2" <"$race_trace" +} + test_expect_success 'clean entries are published without lstat' ' setup_repo clean && bulk_status clean actual clean.trace && test_must_be_empty actual && check_data clean.trace preload/bulk_applied 8 && + check_data clean.trace preload/bulk_untracked_complete 1 && + check_data clean.trace preload/bulk_untracked_count 0 && check_lstat_data clean.trace 0 ' @@ -257,6 +286,229 @@ test_expect_success CASE_INSENSITIVE_FS \ } ' +test_expect_success 'visible paths are returned by the bulk walk' ' + setup_repo visible-output && + test_write_lines "*.ignored" >visible-output/.gitignore && + git -C visible-output add .gitignore && + git -C visible-output commit -m ignore && + test_write_lines root >visible-output/root-untracked && + test_write_lines nested >visible-output/nested/untracked && + mkdir -p visible-output/collapsed/deep \ + visible-output/ignored-only/deep \ + visible-output/empty && + test_write_lines collapsed >visible-output/collapsed/deep/file && + test_write_lines ignored >visible-output/ignored-only/deep/file.ignored && + compare_status visible-output visible-output.trace && + test_grep "^? root-untracked$" actual && + test_grep "^? nested/untracked$" actual && + test_grep "^? collapsed/$" actual && + test_grep ! "ignored-only" actual && + test_grep ! "empty" actual && + check_data visible-output.trace preload/bulk_untracked_complete 1 && + check_data visible-output.trace preload/bulk_untracked_count 3 && + test_grep ! "\"category\":\"read_directory\"" \ + visible-output.trace +' + +test_expect_success PIPE 'special files are ignored' ' + setup_repo special-file && + mkfifo special-file/fifo && + compare_status special-file special-file.trace && + test_must_be_empty actual && + check_data special-file.trace preload/bulk_untracked_complete 1 && + check_data special-file.trace preload/bulk_untracked_count 0 +' + +test_expect_success 'activation guards retain ordinary traversal' ' + setup_repo activation-guards && + test_write_lines "*.ignored" >activation-guards/.gitignore && + git -C activation-guards add .gitignore && + git -C activation-guards commit -m ignore && + test_write_lines visible >activation-guards/visible && + test_write_lines ignored >activation-guards/file.ignored && + compare_fallback_status activation-guards all.trace \ + --untracked-files=all && + compare_fallback_status activation-guards ignored.trace \ + --ignored && + compare_fallback_status activation-guards pathspec.trace \ + -- nested && + git -C activation-guards update-index --untracked-cache && + compare_fallback_status activation-guards untracked-cache.trace +' + +test_expect_success CASE_INSENSITIVE_FS 'case aliases fall back' ' + setup_repo untracked-case-alias && + mv untracked-case-alias/root untracked-case-alias/ROOT && + compare_status untracked-case-alias untracked-case-alias.trace && + check_data untracked-case-alias.trace preload/bulk_untracked_complete 0 && + check_data untracked-case-alias.trace preload/bulk_untracked_count 0 +' + +test_expect_success 'nested repositories fall back' ' + setup_repo nested-repo && + test_write_lines "embedded/**" >nested-repo/.gitignore && + git -C nested-repo add .gitignore && + git -C nested-repo commit -m ignore && + mkdir nested-repo/embedded && + git -C nested-repo/embedded init && + test_write_lines ignored >nested-repo/embedded/file && + compare_status nested-repo nested-repo.trace && + test_grep "^? embedded/$" actual && + check_data nested-repo.trace preload/bulk_untracked_complete 0 +' + +test_expect_success 'tracked submodules retain collected paths' ' + git init submodule-child && + git -C submodule-child commit --allow-empty -m base && + setup_repo submodule-parent && + git -C submodule-parent -c protocol.file.allow=always \ + submodule add ../submodule-child embedded && + git -C submodule-parent commit -m submodule && + git -C submodule-parent update-index --refresh && + test_write_lines visible >submodule-parent/visible && + compare_status submodule-parent submodule-parent.trace && + test_grep "^? visible$" actual && + check_data submodule-parent.trace preload/bulk_untracked_complete 1 +' + +test_expect_success 'exclude changes discard collected paths' ' + setup_repo exclude-race && + exclude=$TRASH_DIRECTORY/exclude-race.patterns && + test_write_lines visible >"$exclude" && + git -C exclude-race config core.excludesFile "$exclude" && + test_write_lines visible >exclude-race/visible && + test_when_finished cleanup_race && + start_raced_status exclude-race "" && + >"$exclude" && + finish_raced_untracked_status exclude-race 8 && + test_grep "^? visible$" actual && + check_data exclude-race.trace preload/bulk_untracked_complete 0 && + check_data exclude-race.trace preload/bulk_untracked_reason exclude-race +' + +test_expect_success 'info exclude changes discard collected paths' ' + setup_repo info-exclude && + test_write_lines visible >info-exclude/.git/info/exclude && + test_write_lines visible >info-exclude/visible && + test_when_finished cleanup_race && + start_raced_status info-exclude "" && + >info-exclude/.git/info/exclude && + finish_raced_untracked_status info-exclude 8 && + test_grep "^? visible$" actual && + check_data info-exclude.trace preload/bulk_untracked_complete 0 && + check_data info-exclude.trace \ + preload/bulk_untracked_reason exclude-race +' + +test_expect_success 'new exclusion source discards collected paths' ' + setup_repo absent-exclude && + exclude_dir=$TRASH_DIRECTORY/absent-exclude-config && + exclude=$exclude_dir/ignore && + rm -rf "$exclude_dir" && + git -C absent-exclude config core.excludesFile "$exclude" && + test_write_lines visible >absent-exclude/visible && + test_when_finished cleanup_race && + test_when_finished "rm -rf \"$exclude_dir\"" && + start_raced_status absent-exclude "" && + mkdir "$exclude_dir" && + test_write_lines visible >"$exclude" && + finish_raced_untracked_status absent-exclude 8 && + test_must_be_empty actual && + check_data absent-exclude.trace preload/bulk_untracked_complete 0 && + check_data absent-exclude.trace \ + preload/bulk_untracked_reason exclude-race +' + +test_expect_success 'separate-git-dir excludes are proven' ' + setup_repo separate-info && + mv separate-info/.git separate-info.git && + printf "gitdir: ../separate-info.git\n" >separate-info/.git && + test_write_lines visible >separate-info.git/info/exclude && + test_write_lines visible >separate-info/visible && + compare_status separate-info separate-info.trace && + test_must_be_empty actual && + check_data separate-info.trace preload/bulk_untracked_complete 1 +' + +test_expect_success 'nested per-directory excludes are closed both ways' ' + test_when_finished cleanup_race && + for direction in visible-to-ignored ignored-to-visible + do + repo=per-dir-$direction && + setup_repo "$repo" && + git -C "$repo" config core.trustctime false && + case "$direction" in + visible-to-ignored) + initial=nomatch && + updated=visible + ;; + ignored-to-visible) + initial=visible && + updated=nomatch + ;; + esac && + test_write_lines "$initial" >"$repo/nested/.gitignore" && + git -C "$repo" add nested/.gitignore && + git -C "$repo" commit -m ignore && + git -C "$repo" update-index --assume-unchanged \ + nested/.gitignore && + test_write_lines visible >"$repo/nested/visible" && + mtime=$(test-tool chmtime --get \ + "$repo/nested/.gitignore") && + start_raced_status "$repo" "" && + test_write_lines "$updated" >"$repo/nested/.gitignore" && + test-tool chmtime "=$mtime" "$repo/nested/.gitignore" && + finish_raced_untracked_status "$repo" 8 && + check_data "$repo.trace" \ + preload/bulk_untracked_complete 0 && + check_data "$repo.trace" \ + preload/bulk_untracked_reason exclude-race && + case "$direction" in + visible-to-ignored) + test_must_be_empty actual + ;; + ignored-to-visible) + test_grep "^? nested/visible$" actual + ;; + esac || + return 1 + done +' + +test_expect_success 'multiply-linked per-directory excludes are proven' ' + setup_repo linked-exclude && + test_write_lines visible >linked-exclude/.gitignore && + git -C linked-exclude add .gitignore && + git -C linked-exclude commit -m ignore && + test_write_lines visible >linked-exclude/visible && + ln linked-exclude/.gitignore linked-exclude-alias && + test_when_finished "rm -f linked-exclude-alias" && + compare_status linked-exclude linked-exclude.trace && + test_must_be_empty actual && + check_data linked-exclude.trace preload/bulk_untracked_complete 1 && + check_data linked-exclude.trace preload/bulk_applied 8 && + check_data linked-exclude.trace preload/bulk_untracked_count 0 +' + +test_expect_success 'multiply-linked exclude changes discard paths' ' + setup_repo linked-exclude-race && + test_write_lines visible >linked-exclude-race/.gitignore && + git -C linked-exclude-race add .gitignore && + git -C linked-exclude-race commit -m ignore && + test_write_lines visible >linked-exclude-race/visible && + ln linked-exclude-race/.gitignore linked-exclude-race-alias && + test_when_finished "rm -f linked-exclude-race-alias" && + test_when_finished cleanup_race && + start_raced_status linked-exclude-race "" && + test_write_lines nomatch >linked-exclude-race-alias && + finish_raced_untracked_status linked-exclude-race 8 && + test_grep "^? visible$" actual && + check_data linked-exclude-race.trace \ + preload/bulk_untracked_complete 0 && + check_data linked-exclude-race.trace \ + preload/bulk_untracked_reason exclude-race +' + test_expect_success ULIMIT_FILE_DESCRIPTORS \ 'bulk preload reopens directories under a low descriptor limit' ' git init low-fd && @@ -314,6 +566,18 @@ test_expect_success SYMLINKS \ test_file_not_empty actual ' +test_expect_success 'tracked-file replacement directories are pruned' ' + setup_repo replacement-dir && + rm replacement-dir/root && + mkdir -p replacement-dir/root/deep/embedded && + test_write_lines hidden >replacement-dir/root/deep/untracked && + git -C replacement-dir/root/deep/embedded init && + compare_status replacement-dir replacement-dir.trace && + test_line_count = 1 actual && + check_data replacement-dir.trace preload/bulk_fallback 1 && + check_data replacement-dir.trace preload/bulk_untracked_complete 1 +' + test_expect_success 'staged and unmerged entries agree' ' setup_repo index-states && test_write_lines staged >index-states/root && diff --git a/wt-status.c b/wt-status.c index b9e9e97d1cc5e4..38a2743b9db34d 100644 --- a/wt-status.c +++ b/wt-status.c @@ -946,6 +946,10 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) return; } + if (s->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !istate->untracked && + istate->sparse_index == INDEX_EXPANDED) + istate->preload_untracked = &s->untracked; /* Restore verified stats before cached excludes inspect them. */ if (fstat_is_reliable() && !istate->split_index && fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && @@ -996,6 +1000,10 @@ static int wt_status_collect_untracked_1( if (!s->show_untracked_files) return 0; + if (s->untracked_from_preload && + !istate->untracked && + !s->show_ignored_mode) + return 0; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -1482,13 +1490,22 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked) { + struct index_state *istate = s->repo->index; struct semantic_verify_proof *proof; + int ret; wt_status_begin_attr_snapshot(s); - refresh_fsmonitor(s->repo->index); + refresh_fsmonitor(istate); proof = wt_status_prepare_semantic_verify(s); - return wt_status_close_fsmonitor_token( + ret = wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); + if (istate->preload_untracked == &s->untracked) { + s->untracked_from_preload = + istate->preload_untracked_complete; + istate->preload_untracked = NULL; + istate->preload_untracked_complete = 0; + } + return ret; } static void wt_status_release_attr_snapshot(struct wt_status *s) diff --git a/wt-status.h b/wt-status.h index 0f7104b4c6ac5f..99b005cb8b5cea 100644 --- a/wt-status.h +++ b/wt-status.h @@ -141,6 +141,8 @@ struct wt_status { int committable; int workdir_dirty; unsigned untracked_from_token_closure : 1; + unsigned untracked_from_preload : 1; + unsigned bulk_update_index_stat : 1; const char *index_file; FILE *fp; const char *prefix; From 36bdcc4825f235d3363e2122d405030dba59d071 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:32:42 -0500 Subject: [PATCH 045/403] preload-index: translate complete Linux statx observations A Linux directory scan cannot substitute its results for lstat() when file identity, timestamps, or mount membership are missing. Depending on libc's statx declarations would also tie the implementation to the age of the installed Linux headers. Define the required statx syscall ABI locally and request complete basic statistics and a mount identifier. Reject invalid nanosecond fields, foreign mounts, and device, inode, link-count, owner, size, or timestamp values that cannot be represented in struct stat. Register the metadata module in the Make, CMake, and Meson Linux builds. The native Linux boundary build compiles it with DEVELOPER=1, but this patch does not select a backend or change the fallback. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-stat.c | 140 +++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 73 +++++++++++++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 6 +- meson.build | 5 +- 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 compat/preload-index/bulk-linux-stat.c create mode 100644 compat/preload-index/bulk-linux.h diff --git a/compat/preload-index/bulk-linux-stat.c b/compat/preload-index/bulk-linux-stat.c new file mode 100644 index 00000000000000..a8594d7230f9d0 --- /dev/null +++ b/compat/preload-index/bulk-linux-stat.c @@ -0,0 +1,140 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +int preload_bulk_linux_statx_raw(int dirfd, const char *path, int flags, + struct preload_linux_statx *stx) +{ + memset(stx, 0, sizeof(*stx)); + return syscall(SYS_statx, dirfd, path, flags, + PRELOAD_STATX_BASIC_STATS | PRELOAD_STATX_MNT_ID, stx); +} + +int preload_bulk_linux_statx_complete( + const struct preload_linux_statx *stx) +{ + return (stx->mask & + (PRELOAD_STATX_BASIC_STATS | PRELOAD_STATX_MNT_ID)) == + (PRELOAD_STATX_BASIC_STATS | PRELOAD_STATX_MNT_ID) && + stx->mtime.tv_nsec < 1000000000 && + stx->ctime.tv_nsec < 1000000000; +} + +int preload_bulk_linux_statx_same(const struct preload_linux_statx *a, + const struct preload_linux_statx *b) +{ + return preload_bulk_linux_statx_complete(a) && + preload_bulk_linux_statx_complete(b) && + a->mnt_id == b->mnt_id && + a->dev_major == b->dev_major && + a->dev_minor == b->dev_minor && + a->ino == b->ino && a->mode == b->mode && + a->nlink == b->nlink && a->uid == b->uid && + a->gid == b->gid && a->size == b->size && + a->mtime.tv_sec == b->mtime.tv_sec && + a->mtime.tv_nsec == b->mtime.tv_nsec && + a->ctime.tv_sec == b->ctime.tv_sec && + a->ctime.tv_nsec == b->ctime.tv_nsec; +} + +static int statx_to_stat(const struct preload_linux_statx *stx, + struct stat *st) +{ + dev_t dev; + + if (!preload_bulk_linux_statx_complete(stx)) + return -1; + memset(st, 0, sizeof(*st)); + dev = makedev(stx->dev_major, stx->dev_minor); + if (major(dev) != stx->dev_major || minor(dev) != stx->dev_minor) + return -1; + st->st_dev = dev; + st->st_ino = stx->ino; + if ((uint64_t)st->st_ino != stx->ino) + return -1; + st->st_mode = stx->mode; + st->st_nlink = stx->nlink; + if ((uint64_t)st->st_nlink != stx->nlink) + return -1; + st->st_uid = stx->uid; + st->st_gid = stx->gid; + if ((uint64_t)st->st_uid != stx->uid || + (uint64_t)st->st_gid != stx->gid) + return -1; + st->st_size = stx->size; + if (st->st_size < 0 || (uint64_t)st->st_size != stx->size) + return -1; + st->st_mtim.tv_sec = stx->mtime.tv_sec; + st->st_mtim.tv_nsec = stx->mtime.tv_nsec; + st->st_ctim.tv_sec = stx->ctime.tv_sec; + st->st_ctim.tv_nsec = stx->ctime.tv_nsec; + if ((int64_t)st->st_mtim.tv_sec != stx->mtime.tv_sec || + (int64_t)st->st_ctim.tv_sec != stx->ctime.tv_sec) + return -1; + return 0; +} + +int preload_bulk_linux_entry_stat(struct preload_bulk_worker *worker, + int dirfd, const char *name, + struct preload_linux_statx *stx, + struct stat *st) +{ + struct preload_bulk_linux_data *data = + worker->scan->platform_data; + + if (preload_bulk_linux_statx_raw( + dirfd, name, + PRELOAD_AT_SYMLINK_NOFOLLOW | PRELOAD_AT_NO_AUTOMOUNT, + stx)) + return -1; + if (!preload_bulk_linux_statx_complete(stx)) { + errno = EOPNOTSUPP; + return -1; + } + if (stx->mnt_id != data->root_mnt_id) { + errno = EXDEV; + return -1; + } + if (statx_to_stat(stx, st)) { + errno = EOVERFLOW; + return -1; + } + return 0; +} + +int preload_bulk_linux_fd_stat(struct preload_bulk_worker *worker, int fd, + struct preload_linux_statx *stx, + struct stat *st) +{ + struct preload_bulk_linux_data *data = + worker->scan->platform_data; + + if (preload_bulk_linux_statx_raw(fd, "", PRELOAD_AT_EMPTY_PATH, + stx)) + return -1; + if (!preload_bulk_linux_statx_complete(stx)) { + errno = EOPNOTSUPP; + return -1; + } + if (stx->mnt_id != data->root_mnt_id) { + errno = EXDEV; + return -1; + } + if (statx_to_stat(stx, st)) { + errno = EOVERFLOW; + return -1; + } + return 0; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h new file mode 100644 index 00000000000000..e4530ac0fda8e6 --- /dev/null +++ b/compat/preload-index/bulk-linux.h @@ -0,0 +1,73 @@ +#ifndef PRELOAD_INDEX_BULK_LINUX_H +#define PRELOAD_INDEX_BULK_LINUX_H + +#ifdef __linux__ + +#include + +#define PRELOAD_AT_NO_AUTOMOUNT 0x800 +#define PRELOAD_AT_EMPTY_PATH 0x1000 +#define PRELOAD_AT_SYMLINK_NOFOLLOW 0x100 +#define PRELOAD_STATX_BASIC_STATS 0x000007ffU +#define PRELOAD_STATX_MNT_ID 0x00001000U + +struct preload_linux_statx_timestamp { + int64_t tv_sec; + uint32_t tv_nsec; + int32_t reserved; +}; + +struct preload_linux_statx { + uint32_t mask; + uint32_t blksize; + uint64_t attributes; + uint32_t nlink; + uint32_t uid; + uint32_t gid; + uint16_t mode; + uint16_t spare0; + uint64_t ino; + uint64_t size; + uint64_t blocks; + uint64_t attributes_mask; + struct preload_linux_statx_timestamp atime; + struct preload_linux_statx_timestamp btime; + struct preload_linux_statx_timestamp ctime; + struct preload_linux_statx_timestamp mtime; + uint32_t rdev_major; + uint32_t rdev_minor; + uint32_t dev_major; + uint32_t dev_minor; + uint64_t mnt_id; + uint32_t dio_mem_align; + uint32_t dio_offset_align; + uint64_t spare3[12]; +}; + +struct preload_bulk_linux_data { + uint64_t root_mnt_id; +}; + +struct preload_bulk_worker; + +#if defined(SYS_getdents64) && defined(SYS_statx) + +int preload_bulk_linux_statx_raw(int dirfd, const char *path, int flags, + struct preload_linux_statx *stx); +int preload_bulk_linux_statx_complete( + const struct preload_linux_statx *stx); +int preload_bulk_linux_statx_same(const struct preload_linux_statx *a, + const struct preload_linux_statx *b); +int preload_bulk_linux_entry_stat(struct preload_bulk_worker *worker, + int dirfd, const char *name, + struct preload_linux_statx *stx, + struct stat *st); +int preload_bulk_linux_fd_stat(struct preload_bulk_worker *worker, int fd, + struct preload_linux_statx *stx, + struct stat *st); + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ + +#endif /* PRELOAD_INDEX_BULK_LINUX_H */ diff --git a/config.mak.uname b/config.mak.uname index 31f4524d1158ab..f59b4bfa4aea4a 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,6 +63,7 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o + COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. ifneq ($(findstring .el7.,$(uname_R)),) diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 87152a27fe7cb1..aec2421a64da6b 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -273,7 +273,11 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) - list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) + list(APPEND compat_SOURCES + unix-socket.c + unix-stream-server.c + compat/linux/procinfo.c + compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE USE_ST_TIMESPEC) diff --git a/meson.build b/meson.build index d4dc3b71f91841..4f34fdf9ab7985 100644 --- a/meson.build +++ b/meson.build @@ -1360,7 +1360,10 @@ elif host_machine.system() == 'windows' endif if host_machine.system() == 'linux' - compat_sources += 'compat/linux/procinfo.c' + compat_sources += [ + 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux-stat.c', + ] elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' From fe5a1a85a0c6127d9b153a97f3d0141a3df8391f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:03:23 -0700 Subject: [PATCH 046/403] dir: compare all saved metadata for UNTR directories valid_cached_dir() used match_stat_data_racy(), whose treatment of ctime and other fields follows core.trustCtime and core.checkStat. Tracked entries can correct a false stat match with a later content comparison, but cached directories have no equivalent check. Renaming a child and restoring its parent's mtime can therefore hide untracked paths under weak stat settings. Compare every field persisted in directory stat_data regardless of those tracked-file settings, and retain the existing racy-timestamp check. The untracked-cache status test renames a child, restores the directory mtime, and verifies that cached and uncached status agree. Signed-off-by: Taylor Blau --- dir.c | 39 ++++++++++++++++++++++++++++++- t/t7063-status-untracked-cache.sh | 26 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/dir.c b/dir.c index 95d8a1cce90f77..f55c9377df6942 100644 --- a/dir.c +++ b/dir.c @@ -71,6 +71,42 @@ struct cached_dir { struct untracked_cache_dir *ucd; }; +/* + * Unlike cache entries, an untracked-cache directory has no later content + * check to correct a false stat match. Compare every field saved in UNTR, + * regardless of core.checkStat or core.trustCtime. + */ +static int match_untracked_dir_stat(const struct stat_data *sd, + const struct stat *st) +{ + return !S_ISDIR(st->st_mode) || + sd->sd_ctime.sec != (unsigned int)st->st_ctime || + sd->sd_ctime.nsec != ST_CTIME_NSEC(*st) || + sd->sd_mtime.sec != (unsigned int)st->st_mtime || + sd->sd_mtime.nsec != ST_MTIME_NSEC(*st) || + sd->sd_dev != (unsigned int)st->st_dev || + sd->sd_ino != (unsigned int)st->st_ino || + sd->sd_uid != (unsigned int)st->st_uid || + sd->sd_gid != (unsigned int)st->st_gid || + sd->sd_size != (unsigned int)st->st_size; +} + +static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, + const struct stat_data *sd, + const struct stat *st) +{ + if (timestamp->sec && +#ifdef USE_NSEC + (timestamp->sec < sd->sd_mtime.sec || + (timestamp->sec == sd->sd_mtime.sec && + timestamp->nsec <= sd->sd_mtime.nsec))) +#else + timestamp->sec <= sd->sd_mtime.sec) +#endif + return MTIME_CHANGED; + return match_untracked_dir_stat(sd, st); +} + static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct index_state *istate, const char *path, int len, struct untracked_cache_dir *untracked, @@ -2541,7 +2577,8 @@ static int valid_cached_dir(struct dir_struct *dir, return 0; } if (!untracked->valid || - match_stat_data_racy(istate, &untracked->stat_data, &st)) { + match_untracked_dir_stat_racy( + &istate->timestamp, &untracked->stat_data, &st)) { fill_stat_data(&untracked->stat_data, &st); return 0; } diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 8929ef481f926c..4ab20cc0693dc4 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -991,4 +991,30 @@ test_expect_success 'empty repo (no index) and core.untrackedCache' ' git -C emptyrepo -c core.untrackedCache=true write-tree ' +test_expect_success 'directory snapshots ignore weak file-stat configuration' ' + test_create_repo weak-dir && + ( + cd weak-dir && + mkdir nested && + echo tracked >tracked && + echo one >nested/one && + git add tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + git config core.trustCtime false && + git config core.checkStat minimal && + avoid_racy && + git status --porcelain -uall >/dev/null && + git status --porcelain -uall >/dev/null && + dir_mtime=$(test-tool chmtime --get nested) && + mv nested/one nested/two && + test-tool chmtime =$dir_mtime nested && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain -uall >.git/expect && + git status --porcelain -uall >.git/actual && + test_cmp .git/expect .git/actual + ) +' + test_done From dcf54f3f39122547e136d14484b275c24f8085f7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:33:21 -0500 Subject: [PATCH 047/403] preload-index: anchor Linux directory opens to the worktree A directory name observed during enumeration may resolve outside the original worktree after a rename, symlink replacement, magic-link traversal, or mount change. Path-based reopening would then inspect an unverified namespace. Introduce descriptor-relative Linux directory-open helpers. Prefer openat2() with beneath-root resolution and reject symlinks, magic links, and mount crossings when that syscall is available. Otherwise reject empty, absolute, dot-dot, and malformed paths. Open root-relative paths one component at a time and verify each mount. Keep direct child opens descriptor-relative; directory scanning verifies their mounts before enumeration. Register the opening module with Make, CMake, and Meson. The native Linux boundary build compiles it with DEVELOPER=1, but backend selection remains unchanged. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-open.c | 149 +++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 20 ++++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + 5 files changed, 172 insertions(+) create mode 100644 compat/preload-index/bulk-linux-open.c diff --git a/compat/preload-index/bulk-linux-open.c b/compat/preload-index/bulk-linux-open.c new file mode 100644 index 00000000000000..02cd5b55b87f43 --- /dev/null +++ b/compat/preload-index/bulk-linux-open.c @@ -0,0 +1,149 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static int valid_component(const char *component, size_t len) +{ + return len && + !(len == 1 && component[0] == '.') && + !(len == 2 && component[0] == '.' && component[1] == '.'); +} + +static int verify_mount(struct preload_bulk_scan *scan, int fd) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + struct preload_linux_statx stx; + + if (preload_bulk_linux_statx_raw(fd, "", PRELOAD_AT_EMPTY_PATH, + &stx)) + return -1; + if (!preload_bulk_linux_statx_complete(&stx)) { + errno = EOPNOTSUPP; + return -1; + } + if (stx.mnt_id != data->root_mnt_id) { + errno = EXDEV; + return -1; + } + return 0; +} + +#ifdef SYS_openat2 +int preload_bulk_linux_openat2_raw(int dirfd, const char *path) +{ + struct preload_linux_open_how how = { + .flags = O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, + .resolve = PRELOAD_RESOLVE_BENEATH | + PRELOAD_RESOLVE_NO_SYMLINKS | + PRELOAD_RESOLVE_NO_MAGICLINKS | + PRELOAD_RESOLVE_NO_XDEV, + }; + + return syscall(SYS_openat2, dirfd, path, &how, sizeof(how)); +} +#endif + +static int open_one_fallback(struct preload_bulk_scan *scan, int parent_fd, + const char *name, int check_mount) +{ + int fd = openat(parent_fd, name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + + if (fd < 0) + return -1; + if (check_mount && verify_mount(scan, fd)) { + int saved_errno = errno; + + close(fd); + errno = saved_errno; + return -1; + } + return fd; +} + +int preload_bulk_linux_open_dir_at( + struct preload_bulk_worker *worker, int parent_fd, + const char *name) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_linux_data *data = scan->platform_data; + + if (!valid_component(name, strlen(name)) || strchr(name, '/')) { + errno = EINVAL; + return -1; + } +#ifdef SYS_openat2 + if (data->use_openat2) + return preload_bulk_linux_openat2_raw(parent_fd, name); +#else + (void)data; +#endif + /* scan_directory() verifies the opened descriptor's mount ID. */ + return open_one_fallback(scan, parent_fd, name, 0); +} + +int preload_bulk_linux_open_relative(struct preload_bulk_scan *scan, + const char *path) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + const char *component = path; + int fd; + + if (!*path || *path == '/' || path[strlen(path) - 1] == '/') { + errno = EINVAL; + return -1; + } +#ifdef SYS_openat2 + if (data->use_openat2) + return preload_bulk_linux_openat2_raw(scan->root_fd, path); +#else + (void)data; +#endif + fd = fcntl(scan->root_fd, F_DUPFD_CLOEXEC, 0); + if (fd < 0) + return -1; + if (verify_mount(scan, fd)) { + int saved_errno = errno; + + close(fd); + errno = saved_errno; + return -1; + } + if (!strcmp(path, ".")) + return fd; + while (*component) { + const char *slash = strchr(component, '/'); + size_t len = slash ? (size_t)(slash - component) : + strlen(component); + char *name; + int next; + + if (!valid_component(component, len)) { + close(fd); + errno = EINVAL; + return -1; + } + name = xmemdupz(component, len); + next = open_one_fallback(scan, fd, name, 1); + free(name); + close(fd); + if (next < 0) + return -1; + fd = next; + if (!slash) + break; + component = slash + 1; + } + return fd; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index e4530ac0fda8e6..e26ee469350b23 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -10,6 +10,10 @@ #define PRELOAD_AT_SYMLINK_NOFOLLOW 0x100 #define PRELOAD_STATX_BASIC_STATS 0x000007ffU #define PRELOAD_STATX_MNT_ID 0x00001000U +#define PRELOAD_RESOLVE_NO_XDEV 0x01 +#define PRELOAD_RESOLVE_NO_MAGICLINKS 0x02 +#define PRELOAD_RESOLVE_NO_SYMLINKS 0x04 +#define PRELOAD_RESOLVE_BENEATH 0x08 struct preload_linux_statx_timestamp { int64_t tv_sec; @@ -44,10 +48,18 @@ struct preload_linux_statx { uint64_t spare3[12]; }; +struct preload_linux_open_how { + uint64_t flags; + uint64_t mode; + uint64_t resolve; +}; + struct preload_bulk_linux_data { uint64_t root_mnt_id; + int use_openat2; }; +struct preload_bulk_scan; struct preload_bulk_worker; #if defined(SYS_getdents64) && defined(SYS_statx) @@ -66,6 +78,14 @@ int preload_bulk_linux_fd_stat(struct preload_bulk_worker *worker, int fd, struct preload_linux_statx *stx, struct stat *st); +#ifdef SYS_openat2 +int preload_bulk_linux_openat2_raw(int dirfd, const char *path); +#endif +int preload_bulk_linux_open_dir_at(struct preload_bulk_worker *worker, + int parent_fd, const char *name); +int preload_bulk_linux_open_relative(struct preload_bulk_scan *scan, + const char *path); + #endif /* SYS_getdents64 && SYS_statx */ #endif /* __linux__ */ diff --git a/config.mak.uname b/config.mak.uname index f59b4bfa4aea4a..293d084e01e318 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,6 +63,7 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o + COMPAT_OBJS += compat/preload-index/bulk-linux-open.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index aec2421a64da6b..815b7512af0d64 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -277,6 +277,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") unix-socket.c unix-stream-server.c compat/linux/procinfo.c + compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE diff --git a/meson.build b/meson.build index 4f34fdf9ab7985..ba17de2a8d8e50 100644 --- a/meson.build +++ b/meson.build @@ -1362,6 +1362,7 @@ endif if host_machine.system() == 'linux' compat_sources += [ 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-stat.c', ] elif host_machine.system() == 'windows' From 522f61f4a24526f5ba8743de00c3c7335bd13296 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:04:03 -0700 Subject: [PATCH 048/403] dir: consume preloaded UNTR directory stat results valid_cached_dir() performs a synchronous lstat() whenever an untracked-cache directory cannot rely on fsmonitor. A separate validation pass cannot remove that duplicated work unless traversal knows whether a saved result was checked and matched. Add transient stat_checked and stat_matches bits to each cached directory and let traversal consume them only when its caller marks the cache preloaded. Clear that marker after traversal and on the symlink-leading-path exit. Existing callers leave the marker clear, so ordinary lstat() validation and the fsmonitor path remain unchanged. Signed-off-by: Taylor Blau --- dir.c | 28 +++++++++++++++++++--------- dir.h | 4 ++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/dir.c b/dir.c index f55c9377df6942..2bfe4dbd1637e4 100644 --- a/dir.c +++ b/dir.c @@ -2572,15 +2572,23 @@ static int valid_cached_dir(struct dir_struct *dir, */ refresh_fsmonitor(istate); if (!(dir->untracked->use_fsmonitor && untracked->valid)) { - if (lstat(path->len ? path->buf : ".", &st)) { - memset(&untracked->stat_data, 0, sizeof(untracked->stat_data)); - return 0; - } - if (!untracked->valid || - match_untracked_dir_stat_racy( - &istate->timestamp, &untracked->stat_data, &st)) { - fill_stat_data(&untracked->stat_data, &st); - return 0; + if (dir->internal.untracked_cache_preloaded && + untracked->stat_checked) { + if (!untracked->valid || !untracked->stat_matches) + return 0; + } else { + if (lstat(path->len ? path->buf : ".", &st)) { + memset(&untracked->stat_data, 0, + sizeof(untracked->stat_data)); + return 0; + } + if (!untracked->valid || + match_untracked_dir_stat_racy( + &istate->timestamp, + &untracked->stat_data, &st)) { + fill_stat_data(&untracked->stat_data, &st); + return 0; + } } } @@ -3179,6 +3187,7 @@ int read_directory(struct dir_struct *dir, struct index_state *istate, dir->internal.visited_directories = 0; if (has_symlink_leading_path(path, len)) { + dir->internal.untracked_cache_preloaded = 0; trace2_region_leave("dir", "read_directory", istate->repo); return dir->nr; } @@ -3217,6 +3226,7 @@ int read_directory(struct dir_struct *dir, struct index_state *istate, } } + dir->internal.untracked_cache_preloaded = 0; return dir->nr; } diff --git a/dir.h b/dir.h index 83e0f648a81f36..d6fc0c8ef8676a 100644 --- a/dir.h +++ b/dir.h @@ -182,6 +182,9 @@ struct untracked_cache_dir { /* all data except 'dirs' in this struct are good */ unsigned int valid : 1; unsigned int recurse : 1; + /* transient results from directory-stat preloading */ + unsigned int stat_checked : 1; + unsigned int stat_matches : 1; /* null object ID means this directory does not have .gitignore */ struct object_id exclude_oid; char name[FLEX_ARRAY]; @@ -353,6 +356,7 @@ struct dir_struct { /* Stats about the traversal */ unsigned visited_paths; unsigned visited_directories; + unsigned untracked_cache_preloaded : 1; } internal; }; From 84d2614d29402202eb711b00106d0f04ffa9211c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:12 -0500 Subject: [PATCH 049/403] preload-index: enumerate Linux directories without trusting d_type A getdents64 record supplies a type hint, not proof that a path is a regular file or directory. Trusting that hint can hide a tracked replacement, misapply ignore rules, or report the wrong visible untracked shape. Parse record lengths and names within a bounded 1 MiB worker buffer. Require statx metadata and matching mount identity for tracked paths. When collecting untracked paths, obtain authoritative metadata before classifying a wholly untracked file or directory. Use directory hints only to schedule paths with tracked descendants. Preserve per-entry fallback for special and multiply linked tracked files. Stop an untracked subtree once its normal-status witness is visible, and invalidate uncertain untracked results. Register the module in all three Linux builds; the native DEVELOPER=1 boundary build compiles it without selecting the backend. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-entry.c | 263 ++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 7 + config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + 5 files changed, 273 insertions(+) create mode 100644 compat/preload-index/bulk-linux-entry.c diff --git a/compat/preload-index/bulk-linux-entry.c b/compat/preload-index/bulk-linux-entry.c new file mode 100644 index 00000000000000..d911c2be137964 --- /dev/null +++ b/compat/preload-index/bulk-linux-entry.c @@ -0,0 +1,263 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include +#include + +#include "compat/preload-index/bulk-linux.h" +#include "dir.h" +#include "preload-index-bulk.h" + +#define PRELOAD_INDEX_BULK_LINUX_BUFFER_SIZE (1024 * 1024) + +struct preload_linux_dirent64 { + uint64_t ino; + int64_t off; + uint16_t reclen; + uint8_t type; + char name[FLEX_ARRAY]; +}; + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static void record_foreign_entry(struct preload_bulk_worker *worker, + const char *path, size_t path_len, + int pos, mode_t mode) +{ + if (pos >= 0) + preload_bulk_record_tracked_fallback(worker, pos); + if (S_ISDIR(mode)) + preload_bulk_record_tracked_descendants_fallback( + worker, path, path_len); + if (worker->scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); +} + +static void handle_directory( + struct preload_bulk_worker *worker, + const struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity, + const char *name, int pos) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_untracked_root *untracked_root = + task->untracked_root; + int has_tracked_descendants; + + if (pos >= 0) { + if (scan->collect_untracked && + preload_bulk_index_entry_is_gitlink(scan, pos)) + return; + preload_bulk_record_tracked_fallback(worker, pos); + return; + } + has_tracked_descendants = + preload_bulk_index_pos_has_tracked_descendants( + scan, worker->path.buf, worker->path.len, pos); + if (!has_tracked_descendants && + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, worker->path.len)) { + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); + return; + } + if (!has_tracked_descendants) { + if (!scan->collect_untracked || + preload_bulk_untracked_is_invalid(worker) || + preload_bulk_untracked_root_is_visible( + worker, untracked_root) || + preload_bulk_path_is_excluded( + worker, worker->path.buf, DT_DIR)) + return; + if (!untracked_root) + untracked_root = preload_bulk_untracked_root_new( + worker, worker->path.buf, worker->path.len); + } + preload_bulk_schedule_directory( + worker, fd, parent_identity, NULL, untracked_root, + name, worker->path.buf, worker->path.len); +} + +static void record_untracked(struct preload_bulk_worker *worker, + const struct preload_bulk_task *task, + int dtype) +{ + struct preload_bulk_scan *scan = worker->scan; + + if (!scan->collect_untracked || + preload_bulk_untracked_is_invalid(worker)) + return; + if (preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, worker->path.len)) { + preload_bulk_invalidate_untracked(worker); + return; + } + if (!preload_bulk_path_is_excluded( + worker, worker->path.buf, dtype)) + preload_bulk_record_untracked( + worker, task->untracked_root, worker->path.buf); +} + +int preload_bulk_linux_enumerate( + struct preload_bulk_worker *worker, + struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity) +{ + struct preload_bulk_scan *scan = worker->scan; + char *buf = worker->buffer; + size_t path_prefix_len; + + if (!buf) { + buf = xmalloc(PRELOAD_INDEX_BULK_LINUX_BUFFER_SIZE); + worker->buffer = buf; + } + worker->dirs++; + strbuf_reset(&worker->path); + if (strcmp(task->path, ".")) { + strbuf_addstr(&worker->path, task->path); + strbuf_addch(&worker->path, '/'); + } + path_prefix_len = worker->path.len; + + for (;;) { + long bytes = syscall(SYS_getdents64, fd, buf, + PRELOAD_INDEX_BULK_LINUX_BUFFER_SIZE); + size_t offset = 0; + + worker->bulk_calls++; + if (bytes < 0) + return -1; + if (!bytes) + return 0; + while (offset < (size_t)bytes) { + struct preload_linux_dirent64 *de = + (void *)(buf + offset); + size_t minimum = + offsetof(struct preload_linux_dirent64, name) + 1; + size_t name_space; + struct preload_linux_statx stx; + struct stat st; + char *nul; + unsigned char dtype; + int has_tracked_descendants = 0, pos; + + if (scan->collect_untracked && + preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) + return 0; + if ((size_t)bytes - offset < minimum || + de->reclen < minimum || + de->reclen > (size_t)bytes - offset) + goto malformed; + name_space = de->reclen - + offsetof(struct preload_linux_dirent64, name); + nul = memchr(de->name, '\0', name_space); + if (!nul || nul == de->name || + memchr(de->name, '/', nul - de->name)) + goto malformed; + offset += de->reclen; + if (is_dot_or_dotdot(de->name)) + continue; + worker->entries++; + if (!fspathcmp(de->name, ".git")) { + if (strcmp(task->path, ".") && + scan->collect_untracked) + preload_bulk_invalidate_untracked( + worker); + continue; + } + + strbuf_setlen(&worker->path, path_prefix_len); + strbuf_addstr(&worker->path, de->name); + if (worker->path.len > INT_MAX) + goto malformed; + pos = preload_bulk_index_position( + scan, worker->path.buf, worker->path.len); + dtype = de->type; + + /* + * Exact tracked paths always reach statx. A directory + * which may contain tracked descendants can be + * scheduled directly: the O_DIRECTORY open and + * descriptor statx remain authoritative. + * + * A hint cannot classify a wholly untracked entry: + * file-versus-directory changes exclude matching and + * result shape. Force those entries through statx before + * taking either shortcut. + */ + if (pos < 0 && dtype != DT_UNKNOWN) + has_tracked_descendants = + preload_bulk_index_pos_has_tracked_descendants( + scan, worker->path.buf, + worker->path.len, pos); + if (scan->collect_untracked && pos < 0 && + !has_tracked_descendants) + dtype = DT_UNKNOWN; + if (dtype == DT_DIR && pos < 0) { + handle_directory(worker, task, fd, + parent_identity, + de->name, pos); + continue; + } + if (pos < 0 && !has_tracked_descendants && + (dtype == DT_REG || dtype == DT_LNK)) { + record_untracked( + worker, task, + dtype == DT_LNK ? DT_LNK : DT_REG); + continue; + } + if (pos < 0 && !has_tracked_descendants && + dtype != DT_UNKNOWN) { + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); + continue; + } + if (preload_bulk_linux_entry_stat( + worker, fd, de->name, &stx, &st)) { + if (errno == EXDEV) { + record_foreign_entry( + worker, worker->path.buf, + worker->path.len, pos, + stx.mode); + continue; + } + goto malformed; + } + if (S_ISDIR(st.st_mode)) { + handle_directory(worker, task, fd, + parent_identity, + de->name, pos); + continue; + } + if (pos < 0) { + if (S_ISREG(st.st_mode)) + record_untracked(worker, task, DT_REG); + else if (S_ISLNK(st.st_mode)) + record_untracked(worker, task, DT_LNK); + else + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); + continue; + } + if ((!S_ISREG(st.st_mode) && !S_ISLNK(st.st_mode)) || + st.st_nlink != 1) { + preload_bulk_record_tracked_fallback( + worker, pos); + continue; + } + preload_bulk_record_tracked(worker, pos, &st); + } + } + +malformed: + worker->malformed++; + return -1; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index e26ee469350b23..f8ec615656d601 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -60,7 +60,9 @@ struct preload_bulk_linux_data { }; struct preload_bulk_scan; +struct preload_bulk_task; struct preload_bulk_worker; +struct preload_bulk_dir_identity; #if defined(SYS_getdents64) && defined(SYS_statx) @@ -86,6 +88,11 @@ int preload_bulk_linux_open_dir_at(struct preload_bulk_worker *worker, int preload_bulk_linux_open_relative(struct preload_bulk_scan *scan, const char *path); +int preload_bulk_linux_enumerate( + struct preload_bulk_worker *worker, + struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity); + #endif /* SYS_getdents64 && SYS_statx */ #endif /* __linux__ */ diff --git a/config.mak.uname b/config.mak.uname index 293d084e01e318..d17fc31a0b541a 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,6 +63,7 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o + COMPAT_OBJS += compat/preload-index/bulk-linux-entry.o COMPAT_OBJS += compat/preload-index/bulk-linux-open.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 815b7512af0d64..4190755222a9d3 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -277,6 +277,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") unix-socket.c unix-stream-server.c compat/linux/procinfo.c + compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") diff --git a/meson.build b/meson.build index ba17de2a8d8e50..8aead4261dbd54 100644 --- a/meson.build +++ b/meson.build @@ -1362,6 +1362,7 @@ endif if host_machine.system() == 'linux' compat_sources += [ 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux-entry.c', 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-stat.c', ] From f8bbac23eabb4eee7c7c3251cc0b49e5eb83d6f3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:05:22 -0700 Subject: [PATCH 050/403] dir: snapshot UNTR directory validation inputs Tracked-index refresh can invalidate the mutable untracked-cache tree. A concurrent directory-validation worker therefore cannot discover nodes or read their validation inputs directly from that live tree. Capture each node pointer, copied pathname, saved stat data, and prior validity before concurrent work begins. The opaque preload object also retains the cache, root, index timestamp, repository, and directory flags needed to recognize its original context. Expose construction and release as a complete ownership boundary. This preparatory change allocates one snapshot per cached directory but has no production caller and does not start workers or publish results. Signed-off-by: Taylor Blau --- dir.c | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dir.h | 5 ++++ 2 files changed, 91 insertions(+) diff --git a/dir.c b/dir.c index 2bfe4dbd1637e4..a66050a6f55e9b 100644 --- a/dir.c +++ b/dir.c @@ -71,6 +71,92 @@ struct cached_dir { struct untracked_cache_dir *ucd; }; +struct untracked_cache_preload_task { + struct untracked_cache_dir *ucd; + char *path; + struct stat_data stat_data; + unsigned int was_valid : 1; + unsigned int stat_checked : 1; + unsigned int stat_matches : 1; + unsigned int update_stat_data : 1; +}; + +struct untracked_cache_preload { + struct repository *repo; + struct untracked_cache *uc; + struct untracked_cache_dir *root; + struct untracked_cache_preload_task *tasks; + struct cache_time index_timestamp; + size_t nr; + unsigned int dir_flags; +}; + +static void collect_untracked_cache_preload_tasks( + struct untracked_cache_dir *ucd, + struct strbuf *path, + struct untracked_cache_preload_task **tasks, + size_t *nr, + size_t *alloc) +{ + size_t i; + + ALLOC_GROW(*tasks, *nr + 1, *alloc); + memset(&(*tasks)[*nr], 0, sizeof(**tasks)); + (*tasks)[*nr].ucd = ucd; + (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); + (*tasks)[*nr].stat_data = ucd->stat_data; + (*tasks)[*nr].was_valid = ucd->valid; + (*nr)++; + + for (i = 0; i < ucd->dirs_nr; i++) { + struct untracked_cache_dir *child = ucd->dirs[i]; + size_t old_len = path->len; + + if (path->len) + strbuf_addch(path, '/'); + strbuf_addstr(path, child->name); + collect_untracked_cache_preload_tasks(child, path, tasks, nr, + alloc); + strbuf_setlen(path, old_len); + } +} + +struct untracked_cache_preload *untracked_cache_preload_start_ordinary( + struct index_state *istate, unsigned int dir_flags) +{ + struct untracked_cache *uc = istate->untracked; + struct untracked_cache_preload *preload; + struct strbuf path = STRBUF_INIT; + size_t alloc = 0; + + if (!uc || !uc->root || uc->use_fsmonitor || + uc->dir_flags != dir_flags) + return NULL; + + CALLOC_ARRAY(preload, 1); + preload->repo = istate->repo; + preload->uc = uc; + preload->root = uc->root; + preload->index_timestamp = istate->timestamp; + preload->dir_flags = dir_flags; + collect_untracked_cache_preload_tasks( + uc->root, &path, &preload->tasks, &preload->nr, &alloc); + strbuf_release(&path); + return preload; +} + +void untracked_cache_preload_release(struct untracked_cache_preload *preload) +{ + size_t i; + + if (!preload) + return; + for (i = 0; i < preload->nr; i++) + free(preload->tasks[i].path); + free(preload->tasks); + free(preload); +} + /* * Unlike cache entries, an untracked-cache directory has no later content * check to correct a false stat match. Compare every field saved in UNTR, diff --git a/dir.h b/dir.h index d6fc0c8ef8676a..c6273985981a60 100644 --- a/dir.h +++ b/dir.h @@ -611,6 +611,11 @@ void untracked_cache_invalidate_trimmed_path(struct index_state *, void untracked_cache_remove_from_index(struct index_state *, const char *); void untracked_cache_add_to_index(struct index_state *, const char *); +struct untracked_cache_preload; +struct untracked_cache_preload *untracked_cache_preload_start_ordinary( + struct index_state *, unsigned int dir_flags); +void untracked_cache_preload_release(struct untracked_cache_preload *); + void free_untracked_cache(struct untracked_cache *); struct untracked_cache *read_untracked_extension(const void *data, unsigned long sz); void write_untracked_extension(struct strbuf *out, struct untracked_cache *untracked); From eede914c17067879490cac687e4199f69b1ff484 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:39 -0500 Subject: [PATCH 051/403] preload-index: detect replaced Linux scan directories Holding a directory descriptor establishes what workers read, but does not prove that the original directory stayed in the worktree. A child can also move under a different parent while queued. Publishing observations from either replacement could hide worktree changes. Capture the complete directory statx observation and converted stat identity before enumeration. Verify the descriptor mount and recheck both identities afterward. For queued children, resolve the parent through the held child descriptor and compare it with the recorded parent identity. Add the mount identifier to the shared directory identity and register the Linux scan module with Make, CMake, and Meson. The native DEVELOPER=1 boundary build compiles it, while recorded directory changes prevent the completed scan from being accepted. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-scan.c | 107 +++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 2 + config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + preload-index-bulk.h | 1 + 6 files changed, 113 insertions(+) create mode 100644 compat/preload-index/bulk-linux-scan.c diff --git a/compat/preload-index/bulk-linux-scan.c b/compat/preload-index/bulk-linux-scan.c new file mode 100644 index 00000000000000..c6da94aa41564b --- /dev/null +++ b/compat/preload-index/bulk-linux-scan.c @@ -0,0 +1,107 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include + +#include "compat/preload-index/bulk-linux.h" +#include "path-namespace.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static struct preload_bulk_dir_identity directory_identity( + const struct preload_linux_statx *stx, const struct stat *st) +{ + struct preload_bulk_dir_identity result = { + .stat = *st, + .platform_id = stx->mnt_id, + .complete = 1, + }; + + return result; +} + +static int directory_identity_matches( + const struct preload_bulk_dir_identity *before, + const struct preload_linux_statx *stx, const struct stat *after) +{ + return S_ISDIR(after->st_mode) && + path_namespace_stat_equal(&before->stat, after) && + before->platform_id == stx->mnt_id; +} + +int preload_bulk_linux_scan_directory(struct preload_bulk_worker *worker, + struct preload_bulk_task *task) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_linux_statx before_stx, after_stx; + struct preload_bulk_dir_identity before_identity; + struct stat before, after; + size_t path_len; + int fd = task->fd; + int ret = -1; + + if (fd < 0) + fd = preload_bulk_linux_open_relative(scan, task->path); + if (fd < 0) + goto out; + if (preload_bulk_test_barrier(scan, task->path)) + goto out; + if (preload_bulk_linux_fd_stat( + worker, fd, &before_stx, &before) || + !S_ISDIR(before.st_mode)) { + if (errno != EXDEV) + goto out; + path_len = strlen(task->path); + preload_bulk_record_tracked_descendants_fallback( + worker, task->path, path_len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); + ret = 0; + goto out; + } + before_identity = directory_identity(&before_stx, &before); + if ((!scan->collect_untracked || + !preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) && + preload_bulk_linux_enumerate( + worker, task, fd, &before_identity)) + goto out; + if (preload_bulk_linux_fd_stat( + worker, fd, &after_stx, &after)) + goto out; + if (!preload_bulk_linux_statx_same(&before_stx, &after_stx) || + !directory_identity_matches( + &before_identity, &after_stx, &after)) + worker->changed_dirs++; + ret = 0; + +out: + if (task->has_parent_identity) { + struct preload_linux_statx parent_stx; + struct stat parent_after; + int parent_changed = fd < 0; + + /* + * Resolve ".." through the held child descriptor so a move + * cannot redirect the parent check to the old path. + */ + if (!parent_changed) + parent_changed = preload_bulk_linux_entry_stat( + worker, fd, "..", &parent_stx, + &parent_after); + if (parent_changed || + !directory_identity_matches( + &task->parent_identity, &parent_stx, + &parent_after)) + worker->changed_dirs++; + } + if (fd >= 0) + close(fd); + return ret; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index f8ec615656d601..672a71c13328ff 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -92,6 +92,8 @@ int preload_bulk_linux_enumerate( struct preload_bulk_worker *worker, struct preload_bulk_task *task, int fd, const struct preload_bulk_dir_identity *parent_identity); +int preload_bulk_linux_scan_directory(struct preload_bulk_worker *worker, + struct preload_bulk_task *task); #endif /* SYS_getdents64 && SYS_statx */ diff --git a/config.mak.uname b/config.mak.uname index d17fc31a0b541a..f4215b95b587c5 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -65,6 +65,7 @@ ifeq ($(uname_S),Linux) COMPAT_OBJS += compat/linux/procinfo.o COMPAT_OBJS += compat/preload-index/bulk-linux-entry.o COMPAT_OBJS += compat/preload-index/bulk-linux-open.o + COMPAT_OBJS += compat/preload-index/bulk-linux-scan.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 4190755222a9d3..315cedd4406a85 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -279,6 +279,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") compat/linux/procinfo.c compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c + compat/preload-index/bulk-linux-scan.c compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE diff --git a/meson.build b/meson.build index 8aead4261dbd54..5f604e8928befd 100644 --- a/meson.build +++ b/meson.build @@ -1364,6 +1364,7 @@ if host_machine.system() == 'linux' 'compat/linux/procinfo.c', 'compat/preload-index/bulk-linux-entry.c', 'compat/preload-index/bulk-linux-open.c', + 'compat/preload-index/bulk-linux-scan.c', 'compat/preload-index/bulk-linux-stat.c', ] elif host_machine.system() == 'windows' diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 2934d31b8674c1..b08269dfb3ed59 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -12,6 +12,7 @@ struct preload_bulk_untracked_root; struct preload_bulk_dir_identity { struct stat stat; + uint64_t platform_id; unsigned complete : 1; }; From c06e17657e234e991d89bb9a17e8d7273c85953f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:06:00 -0700 Subject: [PATCH 052/403] dir: validate captured UNTR directory stats A captured untracked-cache directory must not be marked reusable from a stale snapshot: tracked-index refresh may invalidate its live node, replace the cache, or change the traversal's directory flags. Run lstat() against each saved pathname and compare its immutable stat snapshot using the strict, racy-aware directory comparison. Publish the checked result only if the current cache, root, and directory flags still match. Preserve any invalidation that happened after capture; failed or changed stats leave ordinary traversal responsible for rescan. Validation remains synchronous, and no status caller invokes the new finish operation at this boundary. Signed-off-by: Taylor Blau --- dir.c | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dir.h | 2 ++ 2 files changed, 78 insertions(+) diff --git a/dir.c b/dir.c index a66050a6f55e9b..5dbd5659b8a9bb 100644 --- a/dir.c +++ b/dir.c @@ -91,6 +91,10 @@ struct untracked_cache_preload { unsigned int dir_flags; }; +static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, + const struct stat_data *sd, + const struct stat *st); + static void collect_untracked_cache_preload_tasks( struct untracked_cache_dir *ucd, struct strbuf *path, @@ -145,6 +149,78 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( return preload; } +static void validate_untracked_cache_preload( + struct untracked_cache_preload *preload) +{ + size_t i; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct stat st; + + if (!task->was_valid) + continue; + task->stat_checked = 1; + if (lstat(task->path, &st)) { + memset(&task->stat_data, 0, sizeof(task->stat_data)); + task->update_stat_data = 1; + continue; + } + if (!match_untracked_dir_stat_racy( + &preload->index_timestamp, &task->stat_data, &st)) { + task->stat_matches = 1; + continue; + } + fill_stat_data(&task->stat_data, &st); + task->update_stat_data = 1; + } +} + +int untracked_cache_preload_finish(struct untracked_cache_preload *preload, + struct index_state *istate, + unsigned int dir_flags) +{ + struct untracked_cache *uc; + size_t i; + int applied = 0; + int valid = 1; + + if (!preload) + return 0; + validate_untracked_cache_preload(preload); + uc = istate->untracked; + if (uc != preload->uc || !uc || uc->root != preload->root || + dir_flags != preload->dir_flags) + goto done; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct untracked_cache_dir *ucd = task->ucd; + + ucd->stat_checked = 0; + ucd->stat_matches = 0; + /* Invalidation performed after the snapshot always wins. */ + if (!task->was_valid || !ucd->valid) { + valid = 0; + continue; + } + ucd->stat_checked = task->stat_checked; + ucd->stat_matches = task->stat_matches; + if (!task->stat_checked || !task->stat_matches) + valid = 0; + if (task->update_stat_data) + ucd->stat_data = task->stat_data; + } + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/valid", valid); + applied = 1; +done: + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/applied", applied); + untracked_cache_preload_release(preload); + return applied; +} + void untracked_cache_preload_release(struct untracked_cache_preload *preload) { size_t i; diff --git a/dir.h b/dir.h index c6273985981a60..631bdad2b2b845 100644 --- a/dir.h +++ b/dir.h @@ -614,6 +614,8 @@ void untracked_cache_add_to_index(struct index_state *, const char *); struct untracked_cache_preload; struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *, unsigned int dir_flags); +int untracked_cache_preload_finish(struct untracked_cache_preload *, + struct index_state *, unsigned int dir_flags); void untracked_cache_preload_release(struct untracked_cache_preload *); void free_untracked_cache(struct untracked_cache *); From 1aa5e9b72ff1ab72db33024e85b4a1d3a21050c4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:35:16 -0500 Subject: [PATCH 053/403] preload-index: validate Linux mount topology around bulk scans Individually anchored descriptors do not establish that the mount namespace or named worktree root stayed unchanged throughout a scan. A mount replacement can invalidate otherwise consistent directory observations. Accept only ext-family and XFS filesystems with complete root statx and mount-identity data. Capture /proc/self/mountinfo before the scan, compare it at completion, and freshly reopen the named worktree root with O_NOFOLLOW to verify its original complete identity. Probe openat2() without requiring it. Register the topology module in all three Linux builds. The native DEVELOPER=1 boundary build compiles it. Missing namespace proof, unsupported filesystems, changed mount tables, or replaced roots reject the result; the retained mount snapshot adds memory and can reject unrelated namespace changes. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-topology.c | 142 +++++++++++++++++++++ compat/preload-index/bulk-linux.h | 8 ++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 3 +- meson.build | 1 + 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 compat/preload-index/bulk-linux-topology.c diff --git a/compat/preload-index/bulk-linux-topology.c b/compat/preload-index/bulk-linux-topology.c new file mode 100644 index 00000000000000..abfb80d214d10a --- /dev/null +++ b/compat/preload-index/bulk-linux-topology.c @@ -0,0 +1,142 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" +#include "repository.h" +#include "trace2.h" + +#ifndef EXT_FAMILY_SUPER_MAGIC +#define EXT_FAMILY_SUPER_MAGIC 0xef53 +#endif +#ifndef XFS_SUPER_MAGIC +#define XFS_SUPER_MAGIC 0x58465342 +#endif + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static int read_mountinfo(struct strbuf *out) +{ + int fd = open("/proc/self/mountinfo", O_RDONLY | O_CLOEXEC); + int ret = -1; + + if (fd < 0) + return -1; + strbuf_reset(out); + if (strbuf_read(out, fd, 0) >= 0) + ret = 0; + if (close(fd)) + ret = -1; + return ret; +} + +const char *preload_bulk_linux_start(struct preload_bulk_scan *scan) +{ + struct preload_bulk_linux_data *data; + struct statfs fs; + const char *fs_name; + + CALLOC_ARRAY(data, 1); + strbuf_init(&data->mountinfo, 0); + scan->platform_data = data; + scan->root_fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (scan->root_fd < 0 || fstatfs(scan->root_fd, &fs)) + return "unsupported-filesystem"; + if ((unsigned long)fs.f_type == EXT_FAMILY_SUPER_MAGIC) + fs_name = "ext-family"; + else if ((unsigned long)fs.f_type == XFS_SUPER_MAGIC) + fs_name = "xfs"; + else + return "unsupported-filesystem"; + trace2_data_string("index", scan->repo, "preload/bulk_filesystem", + fs_name); + if (preload_bulk_linux_statx_raw( + scan->root_fd, "", PRELOAD_AT_EMPTY_PATH, + &data->root_statx) || + !preload_bulk_linux_statx_complete(&data->root_statx) || + !S_ISDIR(data->root_statx.mode)) + return "statx-unavailable"; + data->root_mnt_id = data->root_statx.mnt_id; + if (read_mountinfo(&data->mountinfo)) + return "namespace-check-unavailable"; +#ifdef SYS_openat2 + { + int fd = preload_bulk_linux_openat2_raw(scan->root_fd, "."); + + if (fd >= 0) { + struct preload_linux_statx probe; + + if (!preload_bulk_linux_statx_raw( + fd, "", PRELOAD_AT_EMPTY_PATH, &probe) && + preload_bulk_linux_statx_complete(&probe) && + probe.mnt_id == data->root_mnt_id) + data->use_openat2 = 1; + close(fd); + } + } +#endif + trace2_data_intmax("index", scan->repo, "preload/bulk_openat2", + data->use_openat2); + return NULL; +} + +const char *preload_bulk_linux_finish(struct preload_bulk_scan *scan) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + struct strbuf after = STRBUF_INIT; + struct preload_linux_statx root_after; + const char *result = NULL; + int fd; + + if (read_mountinfo(&after)) { + result = "namespace-check-unavailable"; + goto out; + } + if (strbuf_cmp(&data->mountinfo, &after)) { + trace2_data_intmax( + "index", scan->repo, + "preload/bulk_namespace_churn", 1); + result = "namespace-churn"; + goto out; + } + fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) { + result = "namespace-race"; + goto out; + } + if (preload_bulk_linux_statx_raw( + fd, "", PRELOAD_AT_EMPTY_PATH, &root_after) || + !preload_bulk_linux_statx_same( + &data->root_statx, &root_after)) + result = "namespace-race"; + close(fd); + +out: + strbuf_release(&after); + return result; +} + +void preload_bulk_linux_release(struct preload_bulk_scan *scan) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + + if (scan->root_fd >= 0) { + close(scan->root_fd); + scan->root_fd = -1; + } + if (!data) + return; + strbuf_release(&data->mountinfo); + free(data); + scan->platform_data = NULL; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index 672a71c13328ff..ac1398216e1662 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -5,6 +5,8 @@ #include +#include "strbuf.h" + #define PRELOAD_AT_NO_AUTOMOUNT 0x800 #define PRELOAD_AT_EMPTY_PATH 0x1000 #define PRELOAD_AT_SYMLINK_NOFOLLOW 0x100 @@ -55,6 +57,8 @@ struct preload_linux_open_how { }; struct preload_bulk_linux_data { + struct preload_linux_statx root_statx; + struct strbuf mountinfo; uint64_t root_mnt_id; int use_openat2; }; @@ -95,6 +99,10 @@ int preload_bulk_linux_enumerate( int preload_bulk_linux_scan_directory(struct preload_bulk_worker *worker, struct preload_bulk_task *task); +const char *preload_bulk_linux_start(struct preload_bulk_scan *scan); +const char *preload_bulk_linux_finish(struct preload_bulk_scan *scan); +void preload_bulk_linux_release(struct preload_bulk_scan *scan); + #endif /* SYS_getdents64 && SYS_statx */ #endif /* __linux__ */ diff --git a/config.mak.uname b/config.mak.uname index f4215b95b587c5..bc768e714c9ee7 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -67,6 +67,7 @@ ifeq ($(uname_S),Linux) COMPAT_OBJS += compat/preload-index/bulk-linux-open.o COMPAT_OBJS += compat/preload-index/bulk-linux-scan.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o + COMPAT_OBJS += compat/preload-index/bulk-linux-topology.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. ifneq ($(findstring .el7.,$(uname_R)),) diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 315cedd4406a85..e961999f61a0a4 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -280,7 +280,8 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-scan.c - compat/preload-index/bulk-linux-stat.c) + compat/preload-index/bulk-linux-stat.c + compat/preload-index/bulk-linux-topology.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE USE_ST_TIMESPEC) diff --git a/meson.build b/meson.build index 5f604e8928befd..7074bd322ed7a2 100644 --- a/meson.build +++ b/meson.build @@ -1366,6 +1366,7 @@ if host_machine.system() == 'linux' 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-scan.c', 'compat/preload-index/bulk-linux-stat.c', + 'compat/preload-index/bulk-linux-topology.c', ] elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' From b2e7dcf338599e5e79f48137ffc00c947511036c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:07:06 -0700 Subject: [PATCH 054/403] dir: validate captured UNTR directory stats in parallel Synchronous validation still places every cached-directory lstat() on one execution path. Independent, already-captured directory snapshots can instead be divided among bounded workers without reading the live cache from those workers. Partition the snapshots using approximately 1,000 directories per worker, cap the worker count at six and at three times the available CPU count, and permit a bounded test override. Workers retain results in their own snapshot ranges; the existing finish operation joins them before publishing anything. Record worker count, thread-creation failures, directory count, and elapsed worker time through the threads, thread_failure, dirs, and wall_us Trace2 keys. Publication validity and applied-result counters remain in the earlier finishing boundary. Run the work synchronously for a single worker or without pthreads. If thread creation stops partway through, join started workers and process every unstarted range synchronously. No status caller enables the preload at this boundary. Signed-off-by: Taylor Blau --- dir.c | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 12 deletions(-) diff --git a/dir.c b/dir.c index 5dbd5659b8a9bb..73494b54768602 100644 --- a/dir.c +++ b/dir.c @@ -33,6 +33,8 @@ #include "strbuf.h" #include "submodule-config.h" #include "symlinks.h" +#include "thread-utils.h" +#include "trace.h" #include "trace2.h" #include "tree.h" #include "hex.h" @@ -81,19 +83,35 @@ struct untracked_cache_preload_task { unsigned int update_stat_data : 1; }; +struct untracked_cache_preload; + +struct untracked_cache_preload_data { + pthread_t pthread; + struct untracked_cache_preload *preload; + size_t offset, nr; + int started; +}; + struct untracked_cache_preload { struct repository *repo; struct untracked_cache *uc; struct untracked_cache_dir *root; struct untracked_cache_preload_task *tasks; + struct untracked_cache_preload_data *data; struct cache_time index_timestamp; size_t nr; + int threads; unsigned int dir_flags; + uint64_t started_at; }; +#define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 +#define UNTRACKED_CACHE_PRELOAD_COST 1000 + static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat_data *sd, const struct stat *st); +static void *preload_untracked_cache_thread(void *data); static void collect_untracked_cache_preload_tasks( struct untracked_cache_dir *ucd, @@ -131,7 +149,9 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; struct strbuf path = STRBUF_INIT; - size_t alloc = 0; + size_t alloc = 0, offset = 0, work, i; + unsigned long test_threads; + int threads, online, create_threads = 1; if (!uc || !uc->root || uc->use_fsmonitor || uc->dir_flags != dir_flags) @@ -146,15 +166,61 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( collect_untracked_cache_preload_tasks( uc->root, &path, &preload->tasks, &preload->nr, &alloc); strbuf_release(&path); + + threads = HAVE_THREADS ? preload->nr / UNTRACKED_CACHE_PRELOAD_COST : 1; + online = HAVE_THREADS ? online_cpus() : 1; + if (threads > online * 3) + threads = online * 3; + if (threads > UNTRACKED_CACHE_MAX_OVERLAP_THREADS) + threads = UNTRACKED_CACHE_MAX_OVERLAP_THREADS; + test_threads = git_env_ulong("GIT_TEST_UNTRACKED_CACHE_THREADS", 0); + if (test_threads && HAVE_THREADS) + threads = test_threads > UNTRACKED_CACHE_MAX_OVERLAP_THREADS ? + UNTRACKED_CACHE_MAX_OVERLAP_THREADS : test_threads; + if (threads < 1) + threads = 1; + if ((size_t)threads > preload->nr) + threads = preload->nr; + preload->threads = threads; + + preload->started_at = getnanotime(); + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/threads", threads); + CALLOC_ARRAY(preload->data, threads); + work = DIV_ROUND_UP(preload->nr, threads); + for (i = 0; i < threads; i++) { + struct untracked_cache_preload_data *data = &preload->data[i]; + int err; + + data->preload = preload; + data->offset = offset; + data->nr = offset < preload->nr ? + (preload->nr - offset < work ? + preload->nr - offset : work) : 0; + offset += data->nr; + if (threads == 1 || !create_threads) + continue; + err = pthread_create(&data->pthread, NULL, + preload_untracked_cache_thread, data); + if (err) { + create_threads = 0; + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/thread_failure", err); + continue; + } + data->started = 1; + } return preload; } -static void validate_untracked_cache_preload( - struct untracked_cache_preload *preload) +static void *preload_untracked_cache_thread(void *_data) { + struct untracked_cache_preload_data *data = _data; + struct untracked_cache_preload *preload = data->preload; size_t i; - for (i = 0; i < preload->nr; i++) { + for (i = data->offset; i < data->offset + data->nr; i++) { struct untracked_cache_preload_task *task = &preload->tasks[i]; struct stat st; @@ -174,6 +240,43 @@ static void validate_untracked_cache_preload( fill_stat_data(&task->stat_data, &st); task->update_stat_data = 1; } + return NULL; +} + +static void untracked_cache_preload_join( + struct untracked_cache_preload *preload) +{ + int i; + + if (preload->threads == 1) { + preload_untracked_cache_thread(&preload->data[0]); + return; + } + for (i = 0; i < preload->threads; i++) { + if (!preload->data[i].started) { + preload_untracked_cache_thread(&preload->data[i]); + continue; + } + if (pthread_join(preload->data[i].pthread, NULL)) + die(_("unable to join untracked-cache preload thread")); + } +} + +static void untracked_cache_preload_free( + struct untracked_cache_preload *preload) +{ + size_t i; + + trace2_data_intmax("dir", preload->repo, + "preload_untracked_cache/dirs", preload->nr); + trace2_data_intmax("dir", preload->repo, + "preload_untracked_cache/wall_us", + (getnanotime() - preload->started_at) / 1000); + free(preload->data); + for (i = 0; i < preload->nr; i++) + free(preload->tasks[i].path); + free(preload->tasks); + free(preload); } int untracked_cache_preload_finish(struct untracked_cache_preload *preload, @@ -187,7 +290,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, if (!preload) return 0; - validate_untracked_cache_preload(preload); + untracked_cache_preload_join(preload); uc = istate->untracked; if (uc != preload->uc || !uc || uc->root != preload->root || dir_flags != preload->dir_flags) @@ -217,20 +320,16 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, done: trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/applied", applied); - untracked_cache_preload_release(preload); + untracked_cache_preload_free(preload); return applied; } void untracked_cache_preload_release(struct untracked_cache_preload *preload) { - size_t i; - if (!preload) return; - for (i = 0; i < preload->nr; i++) - free(preload->tasks[i].path); - free(preload->tasks); - free(preload); + untracked_cache_preload_join(preload); + untracked_cache_preload_free(preload); } /* From 6f70d18bc55d829c8249ae6ae268e0d505200db1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:39:36 -0500 Subject: [PATCH 055/403] preload-index: enable the verified Linux bulk scan backend The separately registered Linux metadata, anchored-open, enumeration, directory-validation, and topology modules cannot safely publish a physical scan by themselves. They must share the existing bulk backend lifecycle so every closing check runs before results are accepted. Assemble those modules into the Linux backend and register the shared and platform objects with Make, CMake, and Meson. Retain the existing requirements that core.preloadIndex and core.preloadIndexBulk are enabled and fsmonitor is disabled. Preserve ordinary preload when a required syscall, filesystem, mount proof, or closing validation is unavailable. Cap Linux scans at 16 workers. Each worker can allocate a 1 MiB directory buffer; mount snapshots and retained scan results add further memory. Document ext-family and XFS support and keep directory-type injection confined to the documented test environment. Add and register t7532-preload-index-linux.sh with 12 Linux-only cases. Native Linux validation passes 12/12 in the threaded build and 12/12 in a separate NO_PTHREADS build; CMake and Meson link Git. The suite compares ordinary status for tracked changes, visible and ignored paths, false type hints, fallback shapes, and a synchronized child replacement. Signed-off-by: Taylor Blau --- Documentation/config/core.adoc | 5 +- compat/preload-index/bulk-linux-entry.c | 4 + compat/preload-index/bulk-linux-topology.c | 24 ++ compat/preload-index/bulk-linux.c | 37 +++ compat/preload-index/bulk-linux.h | 2 + config.mak.uname | 11 +- contrib/buildsystems/CMakeLists.txt | 7 +- meson.build | 8 + preload-index-bulk.c | 3 + preload-index-bulk.h | 1 + t/README | 4 + t/meson.build | 1 + t/t7532-preload-index-linux.sh | 346 +++++++++++++++++++++ 13 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 compat/preload-index/bulk-linux.c create mode 100755 t/t7532-preload-index-linux.sh diff --git a/Documentation/config/core.adoc b/Documentation/config/core.adoc index 5f01b603e5761a..59bc4a818cceb8 100644 --- a/Documentation/config/core.adoc +++ b/Documentation/config/core.adoc @@ -736,8 +736,9 @@ core.preloadIndexBulk:: This replaces per-entry filesystem lookups with a physical directory scan, but may cost more than normal preload depending on filesystem and cache state. Inconclusive scans are discarded before continuing with the normal -preload. Currently this is supported on APFS and only has an effect when -`core.preloadIndex` is enabled. Defaults to false. +preload. Currently this is supported on APFS, ext-family filesystems, and +XFS, and only has an effect when `core.preloadIndex` is enabled. Defaults +to false. core.unsetenvvars:: Windows-only: comma-separated list of environment variables' diff --git a/compat/preload-index/bulk-linux-entry.c b/compat/preload-index/bulk-linux-entry.c index d911c2be137964..8b63db291e7562 100644 --- a/compat/preload-index/bulk-linux-entry.c +++ b/compat/preload-index/bulk-linux-entry.c @@ -105,6 +105,7 @@ int preload_bulk_linux_enumerate( const struct preload_bulk_dir_identity *parent_identity) { struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_linux_data *data = scan->platform_data; char *buf = worker->buffer; size_t path_prefix_len; @@ -175,6 +176,9 @@ int preload_bulk_linux_enumerate( pos = preload_bulk_index_position( scan, worker->path.buf, worker->path.len); dtype = de->type; + if (data->test_dirent_path && + !strcmp(data->test_dirent_path, worker->path.buf)) + dtype = data->test_dirent_type; /* * Exact tracked paths always reach statx. A directory diff --git a/compat/preload-index/bulk-linux-topology.c b/compat/preload-index/bulk-linux-topology.c index abfb80d214d10a..523e6c08b909b3 100644 --- a/compat/preload-index/bulk-linux-topology.c +++ b/compat/preload-index/bulk-linux-topology.c @@ -2,10 +2,12 @@ #ifdef __linux__ +#include #include #include #include "compat/preload-index/bulk-linux.h" +#include "parse.h" #include "preload-index-bulk.h" #include "repository.h" #include "trace2.h" @@ -19,6 +21,26 @@ #if defined(SYS_getdents64) && defined(SYS_statx) +static void load_test_dirent_type(struct preload_bulk_linux_data *data) +{ + const char *path, *value; + + if (!git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) + return; + value = getenv("GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE"); + if (!value) + return; + if (skip_prefix(value, "dir:", &path)) + data->test_dirent_type = DT_DIR; + else if (skip_prefix(value, "reg:", &path)) + data->test_dirent_type = DT_REG; + else + die("invalid GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE"); + if (!*path) + die("GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE needs a path"); + data->test_dirent_path = xstrdup(path); +} + static int read_mountinfo(struct strbuf *out) { int fd = open("/proc/self/mountinfo", O_RDONLY | O_CLOEXEC); @@ -42,6 +64,7 @@ const char *preload_bulk_linux_start(struct preload_bulk_scan *scan) CALLOC_ARRAY(data, 1); strbuf_init(&data->mountinfo, 0); + load_test_dirent_type(data); scan->platform_data = data; scan->root_fd = open(repo_get_work_tree(scan->repo), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); @@ -133,6 +156,7 @@ void preload_bulk_linux_release(struct preload_bulk_scan *scan) if (!data) return; strbuf_release(&data->mountinfo); + free(data->test_dirent_path); free(data); scan->platform_data = NULL; } diff --git a/compat/preload-index/bulk-linux.c b/compat/preload-index/bulk-linux.c new file mode 100644 index 00000000000000..5c4c120a61840c --- /dev/null +++ b/compat/preload-index/bulk-linux.c @@ -0,0 +1,37 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static const struct preload_bulk_backend linux_backend = { + .collects_untracked = 1, + .max_threads = 16, + .start = preload_bulk_linux_start, + .finish = preload_bulk_linux_finish, + .release = preload_bulk_linux_release, + .open_proof_parent = preload_bulk_linux_open_relative, + .open_dir_at = preload_bulk_linux_open_dir_at, + .scan_directory = preload_bulk_linux_scan_directory, +}; + +const struct preload_bulk_backend *preload_bulk_platform_backend(void) +{ + return &linux_backend; +} + +#else /* !SYS_getdents64 || !SYS_statx */ + +const struct preload_bulk_backend *preload_bulk_platform_backend(void) +{ + return NULL; +} + +#endif + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index ac1398216e1662..e25db006115584 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -60,6 +60,8 @@ struct preload_bulk_linux_data { struct preload_linux_statx root_statx; struct strbuf mountinfo; uint64_t root_mnt_id; + char *test_dirent_path; + unsigned char test_dirent_type; int use_openat2; }; diff --git a/config.mak.uname b/config.mak.uname index bc768e714c9ee7..4d205c4c493939 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,11 +63,12 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o - COMPAT_OBJS += compat/preload-index/bulk-linux-entry.o - COMPAT_OBJS += compat/preload-index/bulk-linux-open.o - COMPAT_OBJS += compat/preload-index/bulk-linux-scan.o - COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o - COMPAT_OBJS += compat/preload-index/bulk-linux-topology.o + PRELOAD_INDEX_BULK_BACKEND = linux + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-entry.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-open.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-scan.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-stat.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-topology.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. ifneq ($(findstring .el7.,$(uname_R)),) diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index e961999f61a0a4..6440b31a4483a1 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -272,11 +272,13 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") set(NO_UNIX_SOCKETS 1) elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") - add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) + add_compile_definitions(HAVE_PRELOAD_INDEX_BULK + PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c + compat/preload-index/bulk-linux.c compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-scan.c @@ -679,7 +681,8 @@ include_directories(${CMAKE_BINARY_DIR}) #libgit parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS") -if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin" OR + CMAKE_SYSTEM_NAME STREQUAL "Linux") list(APPEND libgit_SOURCES preload-index-bulk-index.c preload-index-bulk-thread.c diff --git a/meson.build b/meson.build index 7074bd322ed7a2..23888563a37db6 100644 --- a/meson.build +++ b/meson.build @@ -1319,6 +1319,8 @@ if host_machine.system() == 'darwin' libgit_c_args += '-DHAVE_PRELOAD_INDEX_BULK' libgit_c_args += '-DPRECOMPOSE_UNICODE' libgit_c_args += '-DPROTECT_HFS_DEFAULT' +elif host_machine.system() == 'linux' + libgit_c_args += '-DHAVE_PRELOAD_INDEX_BULK' endif # Configure general compatibility wrappers. @@ -1362,12 +1364,18 @@ endif if host_machine.system() == 'linux' compat_sources += [ 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux.c', 'compat/preload-index/bulk-linux-entry.c', 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-scan.c', 'compat/preload-index/bulk-linux-stat.c', 'compat/preload-index/bulk-linux-topology.c', ] + libgit_sources += [ + 'preload-index-bulk-index.c', + 'preload-index-bulk-thread.c', + 'preload-index-bulk.c', + ] elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' diff --git a/preload-index-bulk.c b/preload-index-bulk.c index cd53761358af4f..eed468b0d156ca 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -213,6 +213,9 @@ int preload_bulk_collect(struct index_state *istate, int threads, backend->open_proof_parent; if (istate->preload_untracked && !scan.collect_untracked) untracked_reason = "backend-unsupported"; + if (backend->max_threads > 0 && + scan.threads > backend->max_threads) + scan.threads = backend->max_threads; if (scan.collect_untracked) { scan.exclude_dir = &exclude_dir; #if HAVE_THREADS diff --git a/preload-index-bulk.h b/preload-index-bulk.h index b08269dfb3ed59..c6c862dbb8136c 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -60,6 +60,7 @@ struct preload_bulk_worker { struct preload_bulk_backend { unsigned collects_untracked : 1; + int max_threads; const char *(*start)(struct preload_bulk_scan *scan); const char *(*finish)(struct preload_bulk_scan *scan); void (*release)(struct preload_bulk_scan *scan); diff --git a/t/README b/t/README index e063266240fda9..31ead13c82aeb5 100644 --- a/t/README +++ b/t/README @@ -425,6 +425,10 @@ by overriding the minimum number of cache entries required per thread. GIT_TEST_PRELOAD_INDEX_BULK= overrides the `core.preloadIndexBulk` setting. +GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=:, when +GIT_TEST_PRELOAD_INDEX_BULK is enabled, overrides the Linux directory +entry type for one worktree-relative path. is `dir` or `reg`. + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH=, GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY=, and GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME=, when diff --git a/t/meson.build b/t/meson.build index de510614fad57b..0951dce558bea3 100644 --- a/t/meson.build +++ b/t/meson.build @@ -960,6 +960,7 @@ integration_tests = [ 't7528-signed-commit-ssh.sh', 't7529-preload-index-apfs.sh', 't7531-semantic-verify.sh', + 't7532-preload-index-linux.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7532-preload-index-linux.sh b/t/t7532-preload-index-linux.sh new file mode 100755 index 00000000000000..2941448326397a --- /dev/null +++ b/t/t7532-preload-index-linux.sh @@ -0,0 +1,346 @@ +#!/bin/sh + +test_description='Linux bulk index preload' + +. ./test-lib.sh + +if test "$(uname -s)" != Linux +then + skip_all='Linux getdents64/statx backend required' + test_done +fi + +case "$(stat -f -c %t "$TRASH_DIRECTORY")" in +ef53) + filesystem=ext-family + ;; +58465342) + filesystem=xfs + ;; +*) + skip_all='tests require an ext-family filesystem or XFS' + test_done + ;; +esac + +setup_repo () { + repo=$1 && + git init "$repo" && + mkdir -p "$repo/nested/deep" && + test_write_lines root >"$repo/root" && + test_write_lines peer >"$repo/peer" && + test_write_lines nested >"$repo/nested/tracked" && + test_write_lines deep >"$repo/nested/deep/tracked" && + git -C "$repo" add . && + git -C "$repo" commit -m base && + git -C "$repo" config core.fsmonitor false && + test-tool chmtime -120 "$repo/root" "$repo/peer" \ + "$repo/nested/tracked" "$repo/nested/deep/tracked" && + git -C "$repo" update-index --refresh +} + +test_lazy_prereq LINUX_BULK_PRELOAD ' + setup_repo linux-bulk-prereq && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/linux-bulk-prereq.trace" \ + git -C linux-bulk-prereq \ + -c core.preloadIndexBulk=true \ + status --porcelain=v2 >actual && + test_must_be_empty actual && + test_trace2_data index preload/bulk_result complete \ + <"$TRASH_DIRECTORY/linux-bulk-prereq.trace" +' + +if ! test_have_prereq LINUX_BULK_PRELOAD +then + skip_all="Linux bulk preload backend unavailable at runtime" + test_done +fi + +ordinary_status () { + GIT_OPTIONAL_LOCKS=0 \ + git -C "$1" -c core.preloadIndex=false \ + status --porcelain=v2 >"$2" +} + +bulk_status () { + repo=$1 && + output=$2 && + bulk_trace=$TRASH_DIRECTORY/$3 && + rm -f "$bulk_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$bulk_trace" \ + git -C "$repo" status --porcelain=v2 >"$output" +} + +check_data () { + test_trace2_data index "$2" "$3" <"$TRASH_DIRECTORY/$1" +} + +check_lstat_data () { + test_have_prereq !PTHREADS || + check_data "$1" preload/sum_lstat "$2" +} + +compare_status () { + ordinary_status "$1" expect && + bulk_status "$1" actual "$2" && + test_cmp expect actual +} + +cleanup_race () { + exec 9>&- + if test -n "$status_pid" + then + kill "$status_pid" 2>/dev/null || : + wait "$status_pid" 2>/dev/null || : + fi + status_pid= && + rm -f "$ready" "$resume" +} + +wait_for_ready () { + for i in $(test_seq 1 1000) + do + test "$(cat "$ready" 2>/dev/null)" = ready && return 0 + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +start_raced_status () { + repo=$1 && + barrier=$2 && + ready=$TRASH_DIRECTORY/$repo.ready && + resume=$TRASH_DIRECTORY/$repo.resume && + race_trace=$TRASH_DIRECTORY/$repo.trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH="$barrier" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY="$ready" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + git -C "$repo" status --porcelain=v2 >actual 9>&- & + status_pid=$! + } && + wait_for_ready +} + +finish_raced_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= && + ordinary_status "$1" expect && + test_cmp expect actual && + test_trace2_data index preload/bulk_applied 0 <"$race_trace" +} + +test_expect_success 'clean entries are published without lstat' ' + setup_repo clean && + bulk_status clean actual clean.trace && + test_must_be_empty actual && + check_data clean.trace preload/bulk_filesystem "$filesystem" && + check_data clean.trace preload/bulk_applied 4 && + check_data clean.trace preload/bulk_untracked_complete 1 && + check_lstat_data clean.trace 0 +' + +test_expect_success 'tracked files ignore a directory type hint' ' + setup_repo dirent-file && + ordinary_status dirent-file expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=dir:root \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-file.trace" \ + git -C dirent-file status --porcelain=v2 >actual && + test_cmp expect actual && + check_data dirent-file.trace preload/bulk_result complete && + check_data dirent-file.trace preload/bulk_applied 4 && + check_data dirent-file.trace preload/bulk_fallback 0 +' + +test_expect_success 'tracked subtrees ignore a regular-file type hint' ' + setup_repo dirent-prefix && + test_write_lines visible >dirent-prefix/nested/untracked && + ordinary_status dirent-prefix expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=reg:nested \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-prefix.trace" \ + git -C dirent-prefix status --porcelain=v2 >actual && + test_cmp expect actual && + check_data dirent-prefix.trace preload/bulk_result complete && + check_data dirent-prefix.trace preload/bulk_applied 4 && + check_data dirent-prefix.trace preload/bulk_definitive_deleted 0 && + check_data dirent-prefix.trace preload/bulk_fallback 0 && + check_data dirent-prefix.trace preload/bulk_untracked_complete 1 +' + +test_expect_success 'untracked directories ignore a regular-file type hint' ' + setup_repo dirent-untracked-directory && + mkdir -p dirent-untracked-directory/collapsed/deep && + test_write_lines visible \ + >dirent-untracked-directory/collapsed/deep/file && + ordinary_status dirent-untracked-directory expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=reg:collapsed \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-untracked-directory.trace" \ + git -C dirent-untracked-directory \ + status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep "^? collapsed/$" actual && + check_data dirent-untracked-directory.trace \ + preload/bulk_result complete && + check_data dirent-untracked-directory.trace \ + preload/bulk_untracked_complete 1 && + check_data dirent-untracked-directory.trace \ + preload/bulk_untracked_count 1 +' + +test_expect_success 'ignored directories ignore a regular-file type hint' ' + setup_repo dirent-ignored-directory && + test_write_lines "*.ignored" >dirent-ignored-directory/.gitignore && + git -C dirent-ignored-directory add .gitignore && + git -C dirent-ignored-directory commit -m ignore && + mkdir dirent-ignored-directory/ignored-only && + test_write_lines ignored \ + >dirent-ignored-directory/ignored-only/file.ignored && + ordinary_status dirent-ignored-directory expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=reg:ignored-only \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-ignored-directory.trace" \ + git -C dirent-ignored-directory \ + status --porcelain=v2 >actual && + test_cmp expect actual && + test_must_be_empty actual && + check_data dirent-ignored-directory.trace \ + preload/bulk_result complete && + check_data dirent-ignored-directory.trace \ + preload/bulk_untracked_complete 1 && + check_data dirent-ignored-directory.trace \ + preload/bulk_untracked_count 0 +' + +test_expect_success 'untracked files ignore a directory type hint' ' + setup_repo dirent-untracked-file && + test_write_lines "visible/" >dirent-untracked-file/.gitignore && + git -C dirent-untracked-file add .gitignore && + git -C dirent-untracked-file commit -m ignore && + test_write_lines visible >dirent-untracked-file/visible && + ordinary_status dirent-untracked-file expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=dir:visible \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-untracked-file.trace" \ + git -C dirent-untracked-file status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep "^? visible$" actual && + check_data dirent-untracked-file.trace \ + preload/bulk_result complete && + check_data dirent-untracked-file.trace \ + preload/bulk_untracked_complete 1 && + check_data dirent-untracked-file.trace \ + preload/bulk_untracked_count 1 +' + +test_expect_success 'visible and ignored paths match ordinary status' ' + setup_repo visible && + test_write_lines "*.ignored" >visible/.gitignore && + git -C visible add .gitignore && + git -C visible commit -m ignore && + test_write_lines root >visible/untracked && + mkdir -p visible/collapsed/deep visible/ignored-only && + test_write_lines nested >visible/collapsed/deep/file && + test_write_lines ignored >visible/ignored-only/file.ignored && + compare_status visible visible.trace && + test_grep "^? untracked$" actual && + test_grep "^? collapsed/$" actual && + test_grep ! "ignored-only" actual && + check_data visible.trace preload/bulk_untracked_complete 1 && + check_data visible.trace preload/bulk_untracked_count 2 && + test_grep ! "\"category\":\"read_directory\"" visible.trace +' + +test_expect_success 'tracked changes match ordinary status' ' + for mode in modified deleted metadata + do + setup_repo "$mode" || return 1 && + case "$mode" in + modified) test_write_lines changed-content >"$mode/root" ;; + deleted) rm "$mode/nested/tracked" ;; + metadata) test-tool chmtime +60 "$mode/root" ;; + esac && + compare_status "$mode" "$mode.trace" || return 1 + done && + check_data modified.trace preload/bulk_definitive_modified 1 && + check_data modified.trace refresh/sum_lstat 0 && + check_data deleted.trace preload/bulk_definitive_deleted 1 && + check_data deleted.trace refresh/sum_lstat 0 && + check_data metadata.trace preload/bulk_content_check 1 && + check_data metadata.trace refresh/sum_lstat 0 +' + +test_expect_success 'tracked-file replacement directories are pruned' ' + setup_repo replacement-dir && + rm replacement-dir/root && + mkdir -p replacement-dir/root/deep/embedded && + test_write_lines hidden >replacement-dir/root/deep/untracked && + git -C replacement-dir/root/deep/embedded init && + compare_status replacement-dir replacement-dir.trace && + test_line_count = 1 actual && + check_data replacement-dir.trace preload/bulk_fallback 1 && + check_data replacement-dir.trace preload/bulk_untracked_complete 1 +' + +test_expect_success PIPE 'tracked FIFO replacements fall back' ' + setup_repo tracked-fifo && + rm tracked-fifo/root && + mkfifo tracked-fifo/root && + compare_status tracked-fifo tracked-fifo.trace && + test_grep "^1 \\.M .* root$" actual && + check_data tracked-fifo.trace preload/bulk_result complete && + check_data tracked-fifo.trace preload/bulk_applied 3 && + check_data tracked-fifo.trace preload/bulk_fallback 1 && + check_data tracked-fifo.trace preload/bulk_definitive_deleted 0 +' + +test_expect_success PIPE 'queued child replacement discards observations' ' + setup_repo child-race && + test_when_finished cleanup_race && + start_raced_status child-race nested/deep && + mv child-race/nested/deep child-race/deep-away && + mkdir child-race/nested/deep && + test_write_lines dirty >child-race/nested/deep/tracked && + finish_raced_status child-race && + test_file_not_empty actual +' + +test_expect_success PIPE 'fallback shapes retain exact output' ' + setup_repo shapes && + ln shapes/root shapes/linked && + mkfifo shapes/fifo && + git init shapes/embedded && + compare_status shapes shapes.trace && + check_data shapes.trace preload/bulk_fallback 1 && + check_data shapes.trace preload/bulk_untracked_complete 0 +' + +test_done From 176a394583043e3d683a50eb335c67065d50ad08 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:56:51 -0500 Subject: [PATCH 056/403] status: overlap UNTR validation with tracked-index refresh Directory validation and tracked-index refresh inspect different snapshots, but running them consecutively leaves both operations on the status command's critical path. Start the cached-directory preload after reading the index and before refresh_index(). Join its workers after configuring excludes and before collecting untracked paths, then pass their results into directory traversal. Release any unfinished preload when status buffers are freed. Keep activation behind GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD until production eligibility is defined. The untracked-cache status test checks unchanged and modified directories, preserved output, and the worker count selected by the running build's pthread support. Signed-off-by: Taylor Blau --- builtin/commit.c | 1 + dir.c | 2 ++ t/t7063-status-untracked-cache.sh | 49 +++++++++++++++++++++++++++++++ wt-status.c | 32 ++++++++++++++++++-- wt-status.h | 4 +++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 28f61745034506..54e41c8ba578c1 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1627,6 +1627,7 @@ struct repository *repo UNUSED) status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); + wt_status_start_untracked_cache_preload(&s); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED|progress_flag, &s.pathspec, NULL, NULL); diff --git a/dir.c b/dir.c index 73494b54768602..c97f59cbf3b1de 100644 --- a/dir.c +++ b/dir.c @@ -153,6 +153,8 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( unsigned long test_threads; int threads, online, create_threads = 1; + if (!git_env_bool("GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD", 0)) + return NULL; if (!uc || !uc->root || uc->use_fsmonitor || uc->dir_flags != dir_flags) return NULL; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 4ab20cc0693dc4..8f49cfeebb6896 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1017,4 +1017,53 @@ test_expect_success 'directory snapshots ignore weak file-stat configuration' ' ) ' +test_expect_success 'status preloads cached-directory validation' ' + test_create_repo auto-preload && + ( + cd auto-preload && + mkdir -p nested/deep && + echo tracked >nested/tracked && + git add nested/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + echo visible >nested/deep/visible && + git -c core.untrackedCache=false status --porcelain \ + >.git/expect && + avoid_racy && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=3 \ + GIT_TRACE2_EVENT="$PWD/.git/normal.trace" \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual && + if test_have_prereq PTHREADS + then + expect_threads=3 + else + expect_threads=1 + fi && + test_grep \ + "preload_untracked_cache/threads.*value.*$expect_threads" \ + .git/normal.trace && + test_grep "preload_untracked_cache/valid.*value.*1" \ + .git/normal.trace && + test_grep "opendir.*value.*0" .git/normal.trace && + echo changed >nested/deep/changed && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain >.git/expect-changed && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=3 \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain >.git/actual-changed && + test_cmp .git/expect-changed .git/actual-changed && + test_grep "preload_untracked_cache/valid.*value.*0" \ + .git/changed.trace && + test_grep "opendir.*value.*[1-9][0-9]*" \ + .git/changed.trace + ) +' + test_done diff --git a/wt-status.c b/wt-status.c index 57772c7501fdba..ffb9ff8f044fd9 100644 --- a/wt-status.c +++ b/wt-status.c @@ -803,6 +803,26 @@ static void wt_status_collect_changes_initial(struct wt_status *s) strbuf_release(&base); } +static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) +{ + if (s->show_untracked_files == SHOW_ALL_UNTRACKED_FILES) + return 0; + return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; +} + +void wt_status_start_untracked_cache_preload(struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + unsigned int dir_flags; + + if (s->untracked_cache_preload) + BUG("untracked-cache preload already started"); + + dir_flags = wt_status_untracked_dir_flags(s); + s->untracked_cache_preload = + untracked_cache_preload_start_ordinary(istate, dir_flags); +} + static void wt_status_collect_untracked(struct wt_status *s) { int i; @@ -814,8 +834,7 @@ static void wt_status_collect_untracked(struct wt_status *s) return; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) - dir.flags |= - DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; + dir.flags |= wt_status_untracked_dir_flags(s); if (s->show_ignored_mode) { dir.flags |= DIR_SHOW_IGNORED_TOO; @@ -826,6 +845,13 @@ static void wt_status_collect_untracked(struct wt_status *s) } setup_standard_excludes(&dir); + if (s->untracked_cache_preload) { + s->untracked_cache_preloaded = untracked_cache_preload_finish( + s->untracked_cache_preload, istate, dir.flags); + s->untracked_cache_preload = NULL; + } + dir.internal.untracked_cache_preloaded = + s->untracked_cache_preloaded; fill_directory(&dir, istate, &s->pathspec); @@ -889,6 +915,8 @@ void wt_status_collect(struct wt_status *s) void wt_status_collect_free_buffers(struct wt_status *s) { + untracked_cache_preload_release(s->untracked_cache_preload); + s->untracked_cache_preload = NULL; wt_status_state_free_buffers(&s->state); } diff --git a/wt-status.h b/wt-status.h index e9fe32e98cc18c..e64eda2d9cc666 100644 --- a/wt-status.h +++ b/wt-status.h @@ -8,6 +8,7 @@ struct repository; struct worktree; +struct untracked_cache_preload; enum color_wt_status { WT_STATUS_HEADER = 0, @@ -145,6 +146,8 @@ struct wt_status { struct string_list untracked; struct string_list ignored; uint32_t untracked_in_ms; + struct untracked_cache_preload *untracked_cache_preload; + unsigned untracked_cache_preloaded : 1; }; size_t wt_status_locate_end(const char *s, size_t len); @@ -153,6 +156,7 @@ void wt_status_add_cut_line(struct wt_status *s); void wt_status_prepare(struct repository *r, struct wt_status *s); void wt_status_print(struct wt_status *s); void wt_status_collect(struct wt_status *s); +void wt_status_start_untracked_cache_preload(struct wt_status *s); /* * Collect all changes between the two trees. Changes will be displayed as if From 922cea4cf328b20b93d22a5d5506bb4e029b69f3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:53:33 -0500 Subject: [PATCH 057/403] status: close bulk content proofs with the provider token The bulk preloader rejected an active fsmonitor provider, so status verified ambiguous tracked entries through a separate semantic scan. Publishing bulk observations or refreshed stat data before the closing provider query would permit a concurrent change to invalidate a clean result. Pass held parent descriptors, basenames, and observed metadata from both platform walkers to semantic_verify_file_at(). Borrow the captured provider proof epoch, hash eligible raw-safe files during the bulk walk, and retain clean states and stat updates provisionally. After the closing provider query confirms the same epoch, validate all pending positions before publishing clean states, refreshed stat data, and fsmonitor-valid bits. Clear provisional state on provider failure, epoch mismatch, or invalid updates, and retain the existing complete-refresh fallback. Choose the provider-backed bulk path from its actual safety conditions, not from whether semantic history is awaiting adoption. This lets a trivial daemon response or daemon restart rebuild and close an ordinary bulk proof, including for a skipHash index, while retaining the complete proof epoch and closing query. Require both preload settings, an expanded index, a pending built-in IPC token, and an eligible whole-worktree request. Keep APFS and Linux within their platform and filesystem limits. Allocate a bounded hash buffer and attribute check per content-verification worker, and retain tracked states and stat updates only until closure. Extend the APFS and Linux tests with same-size, restored-mtime content changes. Cover accepted closure, provider failure, dirty status, daemon token reset with a null-checksum index, and Trace2 evidence of hashing, deferred publication, and token acceptance or rejection. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 3 +- compat/preload-index/bulk-linux-entry.c | 3 +- fsmonitor-ll.h | 3 +- fsmonitor.c | 13 +- preload-index-bulk-index.c | 61 ++++++- preload-index-bulk-thread.c | 74 ++++++++ preload-index-bulk.c | 14 ++ preload-index-bulk.h | 28 ++- preload-index.c | 221 ++++++++++++++++++++---- preload-index.h | 2 + read-cache-ll.h | 10 +- read-cache.c | 4 + semantic-verify-file.c | 32 +++- semantic-verify-internal.h | 2 +- t/t7519-status-fsmonitor.sh | 31 ++++ t/t7527-builtin-fsmonitor.sh | 65 +++++++ t/t7529-preload-index-apfs.sh | 114 ++++++++++++ t/t7532-preload-index-linux.sh | 75 ++++++++ wt-status.c | 104 +++++++++-- 19 files changed, 789 insertions(+), 70 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 7e65c5a24c78eb..8aef3a12a61413 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -495,7 +495,8 @@ static int enumerate_directory(struct preload_bulk_worker *worker, entry.uid, entry.gid, entry.access, entry.linkcount, entry.size)) goto malformed_record; - preload_bulk_record_tracked(worker, pos, &st); + preload_bulk_record_tracked( + worker, pos, fd, entry.name, &st, 0); next_record: record += entry.record_len; diff --git a/compat/preload-index/bulk-linux-entry.c b/compat/preload-index/bulk-linux-entry.c index 8b63db291e7562..02cb27882bece3 100644 --- a/compat/preload-index/bulk-linux-entry.c +++ b/compat/preload-index/bulk-linux-entry.c @@ -253,7 +253,8 @@ int preload_bulk_linux_enumerate( worker, pos); continue; } - preload_bulk_record_tracked(worker, pos, &st); + preload_bulk_record_tracked( + worker, pos, fd, de->name, &st, 0); } } diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 9e64d7d8571b87..339a21078c98ff 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -74,7 +74,8 @@ int fsmonitor_reopen_token(struct index_state *istate); enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready); void fsmonitor_accept_pending_token(struct index_state *istate, - int untracked_ready); + int untracked_proof_complete, + int untracked_cache_valid); void fsmonitor_reject_pending_token(struct index_state *istate); void fsmonitor_mark_untracked_cache_valid(struct index_state *istate); diff --git a/fsmonitor.c b/fsmonitor.c index dd3e529db468f7..ee15d75bab4ca5 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1385,8 +1385,11 @@ enum fsmonitor_token_result fsmonitor_query_pending_token( } void fsmonitor_accept_pending_token(struct index_state *istate, - int untracked_ready) + int untracked_proof_complete, + int untracked_cache_valid) { + if (untracked_cache_valid && !untracked_proof_complete) + BUG("valid untracked cache without a complete proof"); if (!fsmonitor_pending_token_from_provider(istate)) return; FREE_AND_NULL(istate->fsmonitor_last_update); @@ -1394,15 +1397,15 @@ void fsmonitor_accept_pending_token(struct index_state *istate, istate->fsmonitor_last_update_pending = NULL; istate->fsmonitor_pending_token_from_provider = 0; istate->fsmonitor_token_valid = 1; - istate->fsmonitor_untracked_valid = !!untracked_ready; + istate->fsmonitor_untracked_valid = !!untracked_cache_valid; if (istate->untracked) - istate->untracked->use_fsmonitor = !!untracked_ready; + istate->untracked->use_fsmonitor = !!untracked_cache_valid; istate->cache_changed |= FSMONITOR_CHANGED; FREE_AND_NULL(istate->fsmonitor_untracked_token); - if (untracked_ready) + if (untracked_cache_valid) istate->fsmonitor_untracked_token = xstrdup(istate->fsmonitor_last_update); - else { + else if (!untracked_proof_complete) { /* * Keep a query anchored at the accepted tracked token. A * later in-process status may need to close work done after diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index b09b69582397a8..d4691d07678bd4 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -3,6 +3,9 @@ #include "object.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" #ifndef __has_builtin #define __has_builtin(x) 0 @@ -78,6 +81,18 @@ static int record_tracked_state(struct preload_bulk_worker *worker, int pos, return recorded; } +static void record_stat_update(struct preload_bulk_worker *worker, int pos, + const struct stat_data *stat_data) +{ + struct preload_bulk_stat_update *update; + + ALLOC_GROW(worker->stat_updates, worker->stat_updates_nr + 1, + worker->stat_updates_alloc); + update = &worker->stat_updates[worker->stat_updates_nr++]; + update->cache_pos = pos; + memcpy(&update->stat_data, stat_data, sizeof(update->stat_data)); +} + static int tracked_entry_is_eligible(const struct cache_entry *ce) { return !ce_stage(ce) && @@ -109,6 +124,38 @@ static int size_change_is_definitive(const struct cache_entry *ce, DATA_CHANGED); } +static unsigned char verify_content_at( + struct preload_bulk_worker *worker, int pos, int parent_fd, + const char *basename, const struct stat *st, + int observed_has_platform_identity, struct stat_data *stat_data, + int *has_stat_update) +{ + struct preload_bulk_scan *scan = worker->scan; + struct cache_entry *ce = scan->istate->cache[pos]; + struct semantic_verify_file_result file; + + *has_stat_update = 0; + if (!scan->verify_content || + !semantic_verify_classify_entry( + scan->istate, ce, worker->attr_check, 0, &file)) + return PRELOAD_BULK_TRACKED_CONTENT_CHECK; + semantic_verify_file_at( + parent_fd, basename, st, observed_has_platform_identity, + scan->root_dev, ce, scan->istate->repo, + worker->hash_buffer, &file); + worker->bytes_hashed += file.bytes_hashed; + if (file.kind == SEMANTIC_VERIFY_RAW_MODIFIED) + return PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED; + if (file.kind != SEMANTIC_VERIFY_RAW_CLEAN || !file.persistable) + return PRELOAD_BULK_TRACKED_CONTENT_CHECK; + if (memcmp(&file.stat_data, &ce->ce_stat_data, + sizeof(file.stat_data))) { + memcpy(stat_data, &file.stat_data, sizeof(*stat_data)); + *has_stat_update = 1; + } + return PRELOAD_BULK_TRACKED_CLEAN; +} + int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, int pos) { @@ -116,12 +163,16 @@ int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, } void preload_bulk_record_tracked( - struct preload_bulk_worker *worker, int pos, const struct stat *st) + struct preload_bulk_worker *worker, int pos, int parent_fd, + const char *basename, const struct stat *st, + int observed_has_platform_identity) { struct preload_bulk_scan *scan = worker->scan; struct cache_entry *ce = scan->istate->cache[pos]; + struct stat_data stat_data; unsigned int changed; unsigned char state; + int has_stat_update = 0; if (!tracked_entry_is_eligible(ce)) return; @@ -133,8 +184,12 @@ void preload_bulk_record_tracked( else if (size_change_is_definitive(ce, st, changed)) state = PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED; else - state = PRELOAD_BULK_TRACKED_CONTENT_CHECK; - record_tracked_state(worker, pos, state); + state = verify_content_at( + worker, pos, parent_fd, basename, st, + observed_has_platform_identity, &stat_data, + &has_stat_update); + if (record_tracked_state(worker, pos, state) && has_stat_update) + record_stat_update(worker, pos, &stat_data); } void preload_bulk_record_tracked_fallback( diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 793bb79f6a670a..6cfc4f8409c45a 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -2,7 +2,13 @@ #include +#include "attr.h" +#include "clean-status.h" +#include "convert.h" #include "preload-index-bulk.h" +#include "read-cache-ll.h" +#include "semantic-verify-internal.h" +#include "trace2.h" #define PRELOAD_INDEX_BULK_OPEN_FD_CAP 128 #define PRELOAD_INDEX_BULK_OPEN_FD_RESERVE 16 @@ -185,12 +191,77 @@ static void *preload_bulk_worker_main(void *data) static void release_workers(struct preload_bulk_scan *scan) { for (int i = 0; i < scan->threads; i++) { + attr_check_free(scan->workers[i].attr_check); free(scan->workers[i].buffer); + free(scan->workers[i].hash_buffer); + free(scan->workers[i].stat_updates); strbuf_release(&scan->workers[i].path); } FREE_AND_NULL(scan->workers); } +static void prepare_content_verification(struct preload_bulk_scan *scan) +{ + if (!scan->proof_epoch) + return; + + convert_attrs_prepare(scan->istate); + for (int i = 0; i < scan->threads; i++) { + scan->workers[i].attr_check = convert_attrs_check_alloc(); + git_check_attr( + scan->istate, "", scan->workers[i].attr_check); + } + if (!clean_status_proof_epoch_prime_matches( + scan->istate, scan->proof_epoch)) { + for (int i = 0; i < scan->threads; i++) { + attr_check_free(scan->workers[i].attr_check); + scan->workers[i].attr_check = NULL; + } + git_attr_invalidate_all(); + return; + } + for (int i = 0; i < scan->threads; i++) + scan->workers[i].hash_buffer = + xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); + scan->verify_content = 1; + trace2_data_intmax("index", scan->repo, + "preload/bulk_content_verify", 1); +} + +static void collect_stat_updates(struct preload_bulk_scan *scan) +{ + size_t nr = 0; + + for (int i = 0; i < scan->threads; i++) { + struct preload_bulk_worker *worker = &scan->workers[i]; + + for (size_t j = 0; j < worker->stat_updates_nr; j++) { + struct preload_bulk_stat_update *update = + &worker->stat_updates[j]; + + if (update->cache_pos >= scan->istate->cache_nr) + BUG("bulk stat update position out of range"); + if (scan->tracked_state[update->cache_pos] == + PRELOAD_BULK_TRACKED_CLEAN) + nr++; + } + } + ALLOC_ARRAY(scan->stat_updates, nr); + for (int i = 0; i < scan->threads; i++) { + struct preload_bulk_worker *worker = &scan->workers[i]; + + for (size_t j = 0; j < worker->stat_updates_nr; j++) { + struct preload_bulk_stat_update *update = + &worker->stat_updates[j]; + + if (scan->tracked_state[update->cache_pos] != + PRELOAD_BULK_TRACKED_CLEAN) + continue; + scan->stat_updates[scan->stat_updates_nr++] = *update; + } + } +} + int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result) { @@ -207,6 +278,7 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, scan->workers[i].scan = scan; strbuf_init(&scan->workers[i].path, 0); } + prepare_content_verification(scan); FLEX_ALLOC_STR(root_task, path, "."); if (!reserve_open_fd(&scan->queue)) @@ -244,9 +316,11 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, result->dirs += worker->dirs; result->entries += worker->entries; result->bulk_calls += worker->bulk_calls; + result->bytes_hashed += worker->bytes_hashed; result->changed_dirs += worker->changed_dirs; result->malformed += worker->malformed; } + collect_stat_updates(scan); result->threads = started_threads; result->untracked_complete = scan->collect_untracked && !scan->queue.untracked_invalid; diff --git a/preload-index-bulk.c b/preload-index-bulk.c index eed468b0d156ca..5756df1faceb90 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -6,6 +6,7 @@ #include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" +#include "repository.h" #include "trace2.h" struct preload_bulk_untracked_root { @@ -179,11 +180,13 @@ int preload_bulk_collect(struct index_state *istate, int threads, .repo = istate->repo, .istate = istate, .backend = backend, + .proof_epoch = istate->preload_bulk_proof_epoch, .root_fd = -1, .threads = threads, .untracked = STRING_LIST_INIT_DUP, }; struct preload_bulk_run_result run_result = { 0 }; + struct stat root_stat; const char *start_error, *finish_error = NULL; const char *untracked_reason = NULL; int scan_error = -1; @@ -236,6 +239,11 @@ int preload_bulk_collect(struct index_state *istate, int threads, CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); + if (!start_error && scan.proof_epoch && + (scan.root_fd < 0 || fstat(scan.root_fd, &root_stat))) + start_error = "root-stat"; + if (!start_error && scan.proof_epoch) + scan.root_dev = root_stat.st_dev; if (!start_error) { if (scan.collect_untracked) { exclude_proof = exclude_source_proof_create( @@ -296,11 +304,15 @@ int preload_bulk_collect(struct index_state *istate, int threads, } if (clean) { result->tracked_state = scan.tracked_state; + result->stat_updates = scan.stat_updates; + result->stat_updates_nr = scan.stat_updates_nr; result->nr = istate->cache_nr; result->can_skip_unseen_preload = scan.can_skip_unseen_preload; result->untracked_complete = run_result.untracked_complete; scan.tracked_state = NULL; + scan.stat_updates = NULL; + scan.stat_updates_nr = 0; } backend->release(&scan); @@ -320,12 +332,14 @@ int preload_bulk_collect(struct index_state *istate, int threads, exclude_source_proof_release(exclude_proof); } free(scan.tracked_state); + free(scan.stat_updates); return clean ? 0 : -1; } void preload_bulk_result_release(struct preload_bulk_result *result) { FREE_AND_NULL(result->tracked_state); + FREE_AND_NULL(result->stat_updates); string_list_clear(&result->untracked, 0); memset(result, 0, sizeof(*result)); } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index c6c862dbb8136c..3a7d0ef84c1c05 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -3,11 +3,16 @@ #include "git-compat-util.h" #include "preload-index.h" +#include "statinfo.h" #include "strbuf.h" #include "string-list.h" #include "thread-utils.h" struct dir_struct; +struct attr_check; +struct clean_status_proof_epoch; +struct index_state; +struct repository; struct preload_bulk_untracked_root; struct preload_bulk_dir_identity { @@ -49,10 +54,16 @@ struct preload_bulk_worker { struct preload_bulk_scan *scan; pthread_t thread; void *buffer; + void *hash_buffer; + struct attr_check *attr_check; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; + size_t stat_updates_alloc; struct strbuf path; uint64_t dirs; uint64_t entries; uint64_t bulk_calls; + uint64_t bytes_hashed; uint64_t changed_dirs; uint64_t malformed; unsigned started : 1; @@ -87,13 +98,18 @@ struct preload_bulk_scan { struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; + struct clean_status_proof_epoch *proof_epoch; struct dir_struct *exclude_dir; pthread_mutex_t exclude_mutex; struct preload_bulk_untracked_root *untracked_roots; struct string_list untracked; int root_fd; int threads; + dev_t root_dev; unsigned collect_untracked : 1; + unsigned verify_content : 1; unsigned case_insensitive : 1; unsigned can_skip_unseen_preload : 1; }; @@ -104,12 +120,15 @@ struct preload_bulk_run_result { uint64_t bulk_calls; uint64_t changed_dirs; uint64_t malformed; + uint64_t bytes_hashed; int threads; unsigned untracked_complete : 1; }; struct preload_bulk_result { unsigned char *tracked_state; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; size_t nr; const char *outcome; const char *reason; @@ -120,6 +139,11 @@ struct preload_bulk_result { unsigned untracked_complete : 1; }; +struct preload_bulk_stat_update { + uint32_t cache_pos; + struct stat_data stat_data; +}; + void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, @@ -134,7 +158,9 @@ int preload_bulk_index_pos_has_tracked_descendants( int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, int pos); void preload_bulk_record_tracked( - struct preload_bulk_worker *worker, int pos, const struct stat *st); + struct preload_bulk_worker *worker, int pos, int parent_fd, + const char *basename, const struct stat *st, + int observed_has_platform_identity); void preload_bulk_record_tracked_fallback( struct preload_bulk_worker *worker, int pos); void preload_bulk_record_tracked_descendants_fallback( diff --git a/preload-index.c b/preload-index.c index d7c7f99896c28e..a82063e8fd4149 100644 --- a/preload-index.c +++ b/preload-index.c @@ -47,6 +47,7 @@ struct thread_data { struct progress_data *progress; #ifdef HAVE_PRELOAD_INDEX_BULK const unsigned char *bulk_state; + unsigned bulk_provider_pending : 1; #endif int offset, nr; int t2_nr_lstat; @@ -90,7 +91,9 @@ static void *preload_thread(void *_data) #ifdef HAVE_PRELOAD_INDEX_BULK if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || - state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED || + (p->bulk_provider_pending && + state == PRELOAD_BULK_TRACKED_CLEAN)) continue; #endif if (p->progress && !(nr & 31)) { @@ -127,6 +130,13 @@ static void *preload_thread(void *_data) } #ifdef HAVE_PRELOAD_INDEX_BULK +struct preload_bulk_pending { + unsigned char *tracked_state; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; + unsigned provider : 1; +}; + static int stat_data_is_zero(const struct stat_data *sd) { return !sd->sd_ctime.sec && @@ -140,21 +150,24 @@ static int stat_data_is_zero(const struct stat_data *sd) !sd->sd_size; } -static int preload_bulk_entry_is_useful(const struct cache_entry *ce) +static int preload_bulk_entry_is_useful(const struct cache_entry *ce, + int allow_zero_stat) { return preload_entry_needs_stat(ce) && !ce_intent_to_add(ce) && !(ce->ce_flags & (CE_VALID | CE_REMOVE)) && (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) && - !stat_data_is_zero(&ce->ce_stat_data); + (allow_zero_stat || !stat_data_is_zero(&ce->ce_stat_data)); } -static size_t preload_bulk_useful_candidates(struct index_state *index) +static size_t preload_bulk_useful_candidates(struct index_state *index, + int allow_zero_stat) { size_t useful = 0; for (size_t i = 0; i < index->cache_nr; i++) - if (preload_bulk_entry_is_useful(index->cache[i])) + if (preload_bulk_entry_is_useful( + index->cache[i], allow_zero_stat)) useful++; return useful; } @@ -162,6 +175,7 @@ static size_t preload_bulk_useful_candidates(struct index_state *index) static size_t preload_bulk_apply_result( struct index_state *index, struct preload_bulk_result *result, + int defer_all, int *has_deferred) { size_t applied = 0; @@ -181,7 +195,7 @@ static size_t preload_bulk_apply_result( */ if (result->can_skip_unseen_preload && state == PRELOAD_BULK_TRACKED_UNSEEN && - preload_bulk_entry_is_useful(ce)) { + preload_bulk_entry_is_useful(ce, defer_all)) { state = PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED; result->tracked_state[i] = state; } @@ -190,9 +204,14 @@ static size_t preload_bulk_apply_result( state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) && preload_entry_needs_stat(ce)) *has_deferred = 1; + if (defer_all && state == PRELOAD_BULK_TRACKED_CLEAN && + preload_entry_needs_stat(ce)) + *has_deferred = 1; if (state != PRELOAD_BULK_TRACKED_CLEAN) continue; - if (!preload_bulk_entry_is_useful(ce)) + if (!preload_bulk_entry_is_useful(ce, defer_all)) + continue; + if (defer_all) continue; ce_mark_uptodate(ce); mark_fsmonitor_valid(index, ce); @@ -263,6 +282,9 @@ static void preload_bulk_trace_result( result->run.entries); trace2_data_intmax("index", index->repo, "preload/bulk_calls", result->run.bulk_calls); + trace2_data_intmax("index", index->repo, + "preload/bulk_bytes_hashed", + result->run.bytes_hashed); trace2_data_intmax("index", index->repo, "preload/bulk_workers", result->run.threads); trace2_data_intmax("index", index->repo, @@ -283,47 +305,72 @@ static void preload_bulk_trace_result( result->untracked.nr); } -static unsigned char *preload_bulk_try(struct index_state *index) +static int preload_bulk_config_enabled(struct index_state *index) +{ + int enabled = 0; + int control; + + control = git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", -1); + if (control < 0) + repo_config_get_bool(index->repo, "core.preloadindexbulk", + &enabled); + else + enabled = control; + return enabled; +} + +static void preload_bulk_try(struct index_state *index, + unsigned int refresh_flags, + struct preload_bulk_pending *pending) { struct preload_bulk_result result = { 0 }; - unsigned char *tracked_state = NULL; size_t useful; size_t applied = 0; + int provider = !!index->preload_bulk_proof_epoch; int has_deferred = 0; - int enabled = 0; - int control, threads; + int threads; /* * Let the test variable override configuration without bypassing * any of the proof checks. */ - control = git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", -1); - if (control < 0) - repo_config_get_bool(index->repo, "core.preloadindexbulk", - &enabled); - else - enabled = control; - if (!enabled || - fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_DISABLED || + if (!preload_bulk_config_enabled(index) || !preload_bulk_available()) - return NULL; - useful = preload_bulk_useful_candidates(index); + return; + if (provider) { + if (!(refresh_flags & REFRESH_DEFER_BULK_DIRTY) || + fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_IPC || + !fsmonitor_pending_token_from_provider(index)) + return; + } else if (fsm_settings__get_mode(index->repo) != + FSMONITOR_MODE_DISABLED) { + return; + } + useful = preload_bulk_useful_candidates(index, provider); trace2_data_intmax("index", index->repo, "preload/bulk_useful", useful); trace2_data_intmax("index", index->repo, "preload/bulk_cache_nr", index->cache_nr); - if (!useful) - return NULL; + if (!useful && !index->preload_untracked) + return; threads = preload_bulk_threads(useful); trace2_region_enter("index", "preload/bulk", index->repo); if (!preload_bulk_collect(index, threads, &result)) { applied = preload_bulk_apply_result(index, &result, + provider, &has_deferred); } preload_bulk_trace_result(index, &result, applied); if (has_deferred) { - tracked_state = result.tracked_state; + pending->tracked_state = result.tracked_state; result.tracked_state = NULL; + pending->provider = provider; + if (provider) { + pending->stat_updates = result.stat_updates; + pending->stat_updates_nr = result.stat_updates_nr; + result.stat_updates = NULL; + result.stat_updates_nr = 0; + } } if (result.untracked_complete && index->preload_untracked) { *index->preload_untracked = result.untracked; @@ -333,26 +380,126 @@ static unsigned char *preload_bulk_try(struct index_state *index) } trace2_region_leave("index", "preload/bulk", index->repo); preload_bulk_result_release(&result); - return tracked_state; } static void preload_bulk_finish_state(struct index_state *index, - unsigned char **state, + struct preload_bulk_pending *pending, unsigned int refresh_flags) { - if ((refresh_flags & REFRESH_DEFER_BULK_DIRTY) && *state) { - index->preload_bulk_tracked_state = *state; + if ((refresh_flags & REFRESH_DEFER_BULK_DIRTY) && + pending->tracked_state) { + index->preload_bulk_tracked_state = pending->tracked_state; index->preload_bulk_tracked_nr = index->cache_nr; - *state = NULL; + index->preload_bulk_stat_updates = pending->stat_updates; + index->preload_bulk_stat_updates_nr = + pending->stat_updates_nr; + index->preload_bulk_provider_pending = pending->provider; + memset(pending, 0, sizeof(*pending)); } - FREE_AND_NULL(*state); + free(pending->tracked_state); + free(pending->stat_updates); +} + +static int compare_stat_update(const void *va, const void *vb) +{ + const struct preload_bulk_stat_update *a = va; + const struct preload_bulk_stat_update *b = vb; + + return a->cache_pos < b->cache_pos ? -1 : + a->cache_pos > b->cache_pos ? 1 : 0; } #endif void preload_index_bulk_result_clear(struct index_state *index) { FREE_AND_NULL(index->preload_bulk_tracked_state); + FREE_AND_NULL(index->preload_bulk_stat_updates); index->preload_bulk_tracked_nr = 0; + index->preload_bulk_stat_updates_nr = 0; + index->preload_bulk_provider_pending = 0; +} + +int preload_index_bulk_can_close_provider(struct index_state *index) +{ +#ifdef HAVE_PRELOAD_INDEX_BULK + int core_preload_index = 1; + + repo_config_get_bool(index->repo, "core.preloadindex", + &core_preload_index); + return core_preload_index && + preload_bulk_config_enabled(index) && + preload_bulk_available() && + index->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(index->repo) == FSMONITOR_MODE_IPC && + fsmonitor_pending_token_from_provider(index) && + (preload_bulk_useful_candidates(index, 1) || + index->preload_untracked); +#else + (void)index; + return 0; +#endif +} + +int preload_index_bulk_result_accept(struct index_state *index) +{ +#ifdef HAVE_PRELOAD_INDEX_BULK + size_t update_nr = 0; + int applied = 0; + + if (!index->preload_bulk_provider_pending) + return 0; + if (!index->preload_bulk_tracked_state || + index->preload_bulk_tracked_nr != index->cache_nr) + return -1; + + QSORT(index->preload_bulk_stat_updates, + index->preload_bulk_stat_updates_nr, compare_stat_update); + for (size_t i = 0; i < index->preload_bulk_stat_updates_nr; i++) { + struct preload_bulk_stat_update *update = + &index->preload_bulk_stat_updates[i]; + + if (update->cache_pos >= index->cache_nr || + index->preload_bulk_tracked_state[update->cache_pos] != + PRELOAD_BULK_TRACKED_CLEAN || + (i && update[-1].cache_pos == update->cache_pos)) + return -1; + } + + for (size_t i = 0; i < index->cache_nr; i++) { + struct cache_entry *ce = index->cache[i]; + struct preload_bulk_stat_update *update = NULL; + + if (index->preload_bulk_tracked_state[i] != + PRELOAD_BULK_TRACKED_CLEAN) + continue; + if (update_nr < index->preload_bulk_stat_updates_nr && + index->preload_bulk_stat_updates[update_nr].cache_pos == i) + update = + &index->preload_bulk_stat_updates[update_nr++]; + if (update && + memcmp(&ce->ce_stat_data, &update->stat_data, + sizeof(ce->ce_stat_data))) { + memcpy(&ce->ce_stat_data, &update->stat_data, + sizeof(ce->ce_stat_data)); + ce->ce_flags |= CE_UPDATE_IN_BASE; + index->cache_changed |= CE_ENTRY_CHANGED; + } + ce_mark_uptodate(ce); + mark_fsmonitor_valid(index, ce); + applied++; + } + if (update_nr != index->preload_bulk_stat_updates_nr) + BUG("validated bulk stat update was not applied"); + + FREE_AND_NULL(index->preload_bulk_stat_updates); + index->preload_bulk_stat_updates_nr = 0; + index->preload_bulk_provider_pending = 0; + trace2_data_intmax("index", index->repo, + "preload/bulk_provider_applied", applied); +#else + (void)index; +#endif + return 0; } void preload_index(struct index_state *index, @@ -363,7 +510,7 @@ void preload_index(struct index_state *index, struct thread_data data[MAX_PARALLEL]; struct progress_data pd; #ifdef HAVE_PRELOAD_INDEX_BULK - unsigned char *bulk_state = NULL; + struct preload_bulk_pending bulk = { 0 }; #endif int t2_sum_lstat = 0; int core_preload_index = 1; @@ -379,11 +526,11 @@ void preload_index(struct index_state *index, #ifdef HAVE_PRELOAD_INDEX_BULK if (!pathspec || !pathspec->nr) - bulk_state = preload_bulk_try(index); + preload_bulk_try(index, refresh_flags, &bulk); #endif if (!HAVE_THREADS) { #ifdef HAVE_PRELOAD_INDEX_BULK - preload_bulk_finish_state(index, &bulk_state, refresh_flags); + preload_bulk_finish_state(index, &bulk, refresh_flags); #endif return; } @@ -393,7 +540,7 @@ void preload_index(struct index_state *index, threads = 2; if (threads < 2) { #ifdef HAVE_PRELOAD_INDEX_BULK - preload_bulk_finish_state(index, &bulk_state, refresh_flags); + preload_bulk_finish_state(index, &bulk, refresh_flags); #endif return; } @@ -421,7 +568,9 @@ void preload_index(struct index_state *index, p->index = index; #ifdef HAVE_PRELOAD_INDEX_BULK - p->bulk_state = bulk_state ? bulk_state + offset : NULL; + p->bulk_state = bulk.tracked_state ? + bulk.tracked_state + offset : NULL; + p->bulk_provider_pending = bulk.provider; #endif if (pathspec) copy_pathspec(&p->pathspec, pathspec); @@ -443,7 +592,7 @@ void preload_index(struct index_state *index, } stop_progress(&pd.progress); #ifdef HAVE_PRELOAD_INDEX_BULK - preload_bulk_finish_state(index, &bulk_state, refresh_flags); + preload_bulk_finish_state(index, &bulk, refresh_flags); #endif if (pathspec) { diff --git a/preload-index.h b/preload-index.h index bb6deb6130cc2f..7f7fdcca28acb2 100644 --- a/preload-index.h +++ b/preload-index.h @@ -21,5 +21,7 @@ int repo_read_index_preload(struct repository *, const struct pathspec *pathspec, unsigned refresh_flags); void preload_index_bulk_result_clear(struct index_state *index); +int preload_index_bulk_can_close_provider(struct index_state *index); +int preload_index_bulk_result_accept(struct index_state *index); #endif /* PRELOAD_INDEX_H */ diff --git a/read-cache-ll.h b/read-cache-ll.h index 32496bade97a6f..5a50c0d6dd4bbf 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -31,6 +31,9 @@ struct cache_entry { char name[FLEX_ARRAY]; /* more */ }; +struct clean_status_proof_epoch; +struct preload_bulk_stat_update; + #define CE_STAGEMASK (0x3000) #define CE_EXTENDED (0x4000) #define CE_VALID (0x8000) @@ -191,7 +194,8 @@ struct index_state { fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, fsmonitor_pending_token_from_provider : 1, - preload_untracked_complete : 1; + preload_untracked_complete : 1, + preload_bulk_provider_pending : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; @@ -199,6 +203,10 @@ struct index_state { struct untracked_cache *untracked; unsigned char *preload_bulk_tracked_state; size_t preload_bulk_tracked_nr; + struct preload_bulk_stat_update *preload_bulk_stat_updates; + size_t preload_bulk_stat_updates_nr; + /* Borrowed only while refresh_index() performs a provider scan. */ + struct clean_status_proof_epoch *preload_bulk_proof_epoch; /* Borrowed for the duration of preload_index(). */ struct string_list *preload_untracked; char *fsmonitor_last_update; diff --git a/read-cache.c b/read-cache.c index 20f45870350cd5..db8b94a4c26bd3 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1626,6 +1626,9 @@ int refresh_index(struct index_state *istate, unsigned int flags, unsigned char state = istate->preload_bulk_tracked_state[i]; + if (istate->preload_bulk_provider_pending && + state == PRELOAD_BULK_TRACKED_CLEAN) + continue; if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK) { ce->ce_flags |= CE_CONTENT_CHECK_REQUIRED; continue; @@ -2542,6 +2545,7 @@ void release_index(struct index_state *istate) free(istate->fsmonitor_untracked_token); clean_status_release(istate); free(istate->preload_bulk_tracked_state); + free(istate->preload_bulk_stat_updates); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/semantic-verify-file.c b/semantic-verify-file.c index 811b9fc43355fc..40d95c2abb67cb 100644 --- a/semantic-verify-file.c +++ b/semantic-verify-file.c @@ -117,9 +117,32 @@ int semantic_verify_classify_entry(struct index_state *istate, } #if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static int observed_stat_equal(const struct stat *a, const struct stat *b, + int has_platform_identity) +{ + if (a->st_dev != b->st_dev || a->st_ino != b->st_ino || + a->st_mode != b->st_mode || a->st_nlink != b->st_nlink || + a->st_uid != b->st_uid || a->st_gid != b->st_gid || + a->st_size != b->st_size || a->st_mtime != b->st_mtime || + ST_MTIME_NSEC(*a) != ST_MTIME_NSEC(*b) || + a->st_ctime != b->st_ctime || + ST_CTIME_NSEC(*a) != ST_CTIME_NSEC(*b)) + return 0; +#ifdef __APPLE__ + if (has_platform_identity && + (a->st_birthtimespec.tv_sec != b->st_birthtimespec.tv_sec || + a->st_birthtimespec.tv_nsec != b->st_birthtimespec.tv_nsec || + a->st_gen != b->st_gen)) + return 0; +#else + (void)has_platform_identity; +#endif + return 1; +} + void semantic_verify_file_at(int parent_fd, const char *basename, const struct stat *observed, - dev_t root_dev, + int observed_has_platform_identity, dev_t root_dev, const struct cache_entry *ce, struct repository *repo, void *buffer, struct semantic_verify_file_result *result) @@ -152,7 +175,8 @@ void semantic_verify_file_at(int parent_fd, const char *basename, if (fstat(fd, &fd_before)) goto unstable; if (fd_before.st_dev != root_dev || - !path_namespace_stat_equal(&path_before, &fd_before)) { + !observed_stat_equal(&path_before, &fd_before, + observed_has_platform_identity)) { errno = EAGAIN; goto unstable; } @@ -213,7 +237,7 @@ void semantic_verify_file(struct semantic_verify_root *root, SEMANTIC_VERIFY_RAW_MODIFIED : SEMANTIC_VERIFY_ERROR; return; } - semantic_verify_file_at(parent_fd, basename, &path_before, + semantic_verify_file_at(parent_fd, basename, &path_before, 1, root->stat.st_dev, ce, repo, buffer, result); } #else @@ -228,7 +252,7 @@ static void semantic_verify_file_unavailable( void semantic_verify_file_at( int parent_fd UNUSED, const char *basename UNUSED, const struct stat *observed UNUSED, - dev_t root_dev UNUSED, + int observed_has_platform_identity UNUSED, dev_t root_dev UNUSED, const struct cache_entry *ce UNUSED, struct repository *repo UNUSED, void *buffer UNUSED, struct semantic_verify_file_result *result) diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index b35af3ff93b271..e784cf4d78f955 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -82,7 +82,7 @@ void semantic_verify_file(struct semantic_verify_root *root, struct semantic_verify_file_result *result); void semantic_verify_file_at(int parent_fd, const char *basename, const struct stat *observed, - dev_t root_dev, + int observed_has_platform_identity, dev_t root_dev, const struct cache_entry *ce, struct repository *repo, void *buffer, struct semantic_verify_file_result *result); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 002d5f1a83c1fc..66c5a3a28bfdf3 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -594,6 +594,37 @@ prepare_builtin_closure_repo () { ) } +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin closure initializes a new untracked cache' ' + test_when_finished "rm -rf builtin-closure-new-uc" && + test_create_repo builtin-closure-new-uc && + ( + cd builtin-closure-new-uc && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 1 .git/read-directory && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSUC .git/index + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin clean closure publishes its proof' ' test_when_finished "rm -rf builtin-closure-clean" && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index a72cd29af06487..7587626dc0cd7d 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1623,4 +1623,69 @@ test_expect_success MACOS 'worktree binding rejects same-gitdir aliases' ' git -C binding-a fsmonitor--daemon stop ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'configured unused filters establish scoped history' ' + test_when_finished "rm -rf configured-filter" && + test_create_repo configured-filter && + ( + cd configured-filter && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config filter.demo.clean cat && + git config core.preloadIndexBulk true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/filter-scope.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/filter-scope.out && + test_must_be_empty .git/filter-scope.out && + test_trace2_data status semantic_verify/prepared 1 \ + <.git/filter-scope.trace && + ! test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/filter-scope.trace && + test_trace2_data semantic_verify active-filters 0 \ + <.git/filter-scope.trace && + test_trace2_data semantic_verify filter-scope-checked 1 \ + <.git/filter-scope.trace && + test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/filter-scope.trace && + test_grep FSCF .git/index && + + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/warm-filter-scope.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/warm-filter-scope.out && + test_must_be_empty .git/warm-filter-scope.out && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/warm-filter-scope.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/warm-filter-scope.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <.git/warm-filter-scope.trace && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <.git/warm-filter-scope.trace && + + test_write_lines "tracked filter=demo" >.git/info/attributes && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/active-filter.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/active-filter.out && + test_must_be_empty .git/active-filter.out && + test_trace2_data status semantic_verify/prepared 1 \ + <.git/active-filter.trace && + test_trace2_data semantic_verify active-filters 1 \ + <.git/active-filter.trace && + test_trace2_data semantic_verify filter-scope-rejected 1 \ + <.git/active-filter.trace && + ! test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/active-filter.trace + ) +' + test_done diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index b726b7559b9b3d..b10d61a3360a01 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -107,6 +107,47 @@ configured_bulk_status () { status --porcelain=v2 >"$output" } +setup_provider_proof_repo () { + setup_repo "$1" && + ( + cd "$1" && + git config core.trustctime false && + git config core.checkStat minimal && + mtime=$(test-tool chmtime --get root) && + printf "dirt\n" >root && + test-tool chmtime =$mtime root && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid root && + test_grep ! FSCF .git/index + ) +} + +setup_provider_proof_repo_with_untracked_cache () { + setup_repo "$1" && + ( + cd "$1" && + git config core.untrackedCache true && + git status --porcelain=2 >.git/prime && + test_must_be_empty .git/prime && + test_grep UNTR .git/index && + git config core.trustctime false && + git config core.checkStat minimal && + mtime=$(test-tool chmtime --get root) && + printf "dirt\n" >root && + test-tool chmtime =$mtime root && + test_write_lines visible >visible && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid root && + test_grep ! FSCF .git/index + ) +} + test_expect_success 'bulk preload follows its configuration' ' setup_repo opt-in && GIT_OPTIONAL_LOCKS=0 \ @@ -144,6 +185,79 @@ test_expect_success 'bulk preload waits for fsmonitor provider closure' ' test_grep ! "\"key\":\"preload/bulk_result\"" fsmonitor.trace ' +test_expect_success 'provider closure accepts bulk content proofs' ' + setup_provider_proof_repo provider-proof && + ( + cd provider-proof && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_bytes_hashed \ + "[1-9][0-9]*" \ + <.git/status.trace && + test_trace2_data index preload/bulk_provider_applied 7 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index + ) +' + +test_expect_success \ + 'provider bulk preserves an existing untracked-cache binding' ' + setup_provider_proof_repo_with_untracked_cache provider-proof-uc && + ( + cd provider-proof-uc && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_grep "^? visible$" .git/actual && + ! test_trace2_data index preload/bulk_untracked_complete 1 \ + <.git/status.trace && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 1 .git/read-directory && + test_grep FSUC .git/index + ) +' + +test_expect_success 'provider failure discards bulk content proofs' ' + setup_provider_proof_repo provider-failure && + ( + cd provider-failure && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + ! test_trace2_data index preload/bulk_provider_applied \ + "[0-9][0-9]*" <.git/status.trace && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSCF .git/index + ) +' + cleanup_race () { exec 9>&- if test -n "$status_pid" diff --git a/t/t7532-preload-index-linux.sh b/t/t7532-preload-index-linux.sh index 2941448326397a..4d4063809f884c 100755 --- a/t/t7532-preload-index-linux.sh +++ b/t/t7532-preload-index-linux.sh @@ -39,6 +39,27 @@ setup_repo () { git -C "$repo" update-index --refresh } +setup_provider_proof_repo () { + setup_repo "$1" && + ( + cd "$1" && + git config core.trustctime false && + git config core.checkStat minimal && + size=$(test_file_size root) && + mtime=$(test-tool chmtime --get root) && + printf "dirt\n" >root && + test "$(test_file_size root)" = "$size" && + test-tool chmtime =$mtime root && + test "$(test-tool chmtime --get root)" = "$mtime" && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid root && + test_grep ! FSCF .git/index + ) +} + test_lazy_prereq LINUX_BULK_PRELOAD ' setup_repo linux-bulk-prereq && GIT_OPTIONAL_LOCKS=0 \ @@ -156,6 +177,60 @@ test_expect_success 'clean entries are published without lstat' ' check_lstat_data clean.trace 0 ' +test_expect_success 'provider closure accepts bulk content proofs' ' + ( + sane_unset GIT_TEST_SPLIT_INDEX && + setup_provider_proof_repo provider-proof && + cd provider-proof && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_bytes_hashed \ + "[1-9][0-9]*" <.git/status.trace && + test_trace2_data index preload/bulk_provider_applied 3 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index + ) +' + +test_expect_success 'provider failure discards bulk content proofs' ' + ( + sane_unset GIT_TEST_SPLIT_INDEX && + setup_provider_proof_repo provider-failure && + cd provider-failure && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_bytes_hashed \ + "[1-9][0-9]*" <.git/status.trace && + ! test_trace2_data index preload/bulk_provider_applied \ + "[0-9][0-9]*" <.git/status.trace && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSCF .git/index + ) +' + test_expect_success 'tracked files ignore a directory type hint' ' setup_repo dirent-file && ordinary_status dirent-file expect && diff --git a/wt-status.c b/wt-status.c index 38a2743b9db34d..3c5e0facbf473b 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1048,8 +1048,17 @@ static int wt_status_collect_untracked_1( return used_untracked_cache; } +static int wt_status_can_use_bulk_provider( + struct wt_status *s, unsigned int refresh_flags) +{ + return !s->show_ignored_mode && !s->pathspec.nr && + !clean_status_filter_scope_needs_validation(s->repo->index) && + (refresh_flags & REFRESH_DEFER_BULK_DIRTY) && + preload_index_bulk_can_close_provider(s->repo->index); +} + static struct semantic_verify_proof *wt_status_prepare_semantic_verify( - struct wt_status *s) + struct wt_status *s, unsigned int refresh_flags) { struct index_state *istate = s->repo->index; struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; @@ -1064,6 +1073,11 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( !fsmonitor_pending_token_from_provider(istate) || !clean_status_fsmonitor_semantic_adoption_needed(istate)) return NULL; + if (wt_status_can_use_bulk_provider(s, refresh_flags)) { + trace2_data_intmax("status", s->repo, + "semantic_verify/bulk_scan", 1); + return NULL; + } options.require_proof_epoch = 1; options.validate_filter_scope = @@ -1096,7 +1110,9 @@ struct wt_status_token_closure { unsigned int refresh_flags; int require_untracked; int can_prime; + int use_bulk_provider; int untracked_ready; + int untracked_proof_complete; struct string_list staged_untracked; struct string_list staged_ignored; int staged_untracked_ready; @@ -1191,18 +1207,38 @@ static void wt_status_discard_semantic_verify( static void wt_status_refresh_for_token( struct wt_status *s, unsigned int refresh_flags, - struct clean_status_proof_epoch **epoch, int *refresh_result) + struct clean_status_proof_epoch **epoch, int use_bulk_provider, + int *refresh_result) { struct index_state *istate = s->repo->index; clean_status_release_proof_epoch(*epoch); *epoch = clean_status_capture_proof_epoch( istate, s->attr_source_snapshot, 0); + if (*epoch && use_bulk_provider) + istate->preload_bulk_proof_epoch = *epoch; if (*epoch) { *refresh_result |= refresh_index( istate, refresh_flags | REFRESH_IN_PROOF_EPOCH, &s->pathspec, NULL, NULL); } + istate->preload_bulk_proof_epoch = NULL; +} + +static int wt_status_untracked_cache_valid( + const struct wt_status_token_closure *closure) +{ + const struct index_state *istate = closure->status->repo->index; + + return closure->untracked_ready && + istate->untracked && istate->untracked->root; +} + +static void wt_status_record_bulk_untracked( + struct wt_status_token_closure *closure) +{ + if (closure->status->repo->index->preload_untracked_complete) + closure->untracked_proof_complete = 1; } static int wt_status_close_ordinary_fsmonitor_token( @@ -1222,6 +1258,7 @@ static int wt_status_close_ordinary_fsmonitor_token( if (reliable_stat) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, + closure->use_bulk_provider, &closure->refresh_result); if (!scan_epoch) return 0; @@ -1230,8 +1267,12 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); } - if (!closure->untracked_ready && closure->can_prime) { - closure->untracked_ready = wt_status_stage_untracked(closure); + wt_status_record_bulk_untracked(closure); + if (!closure->untracked_proof_complete && closure->can_prime) { + closure->untracked_ready = + wt_status_stage_untracked(closure); + closure->untracked_proof_complete = + closure->untracked_ready; if (closure->queries) trace2_data_intmax( "status", s->repo, @@ -1248,7 +1289,8 @@ static int wt_status_close_ordinary_fsmonitor_token( break; closure->queries++; result = fsmonitor_query_pending_token( - istate, closure->untracked_ready); + istate, + wt_status_untracked_cache_valid(closure)); if (result == FSMONITOR_TOKEN_CLEAN) { if (reliable_stat && !clean_status_proof_epoch_matches( @@ -1256,19 +1298,27 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); break; } - if (closure->untracked_ready || + if (closure->untracked_proof_complete || !closure->require_untracked) { + if (preload_index_bulk_result_accept(istate) < 0) + break; if (reliable_stat) clean_status_mark_fsmonitor_config_valid( istate, istate->fsmonitor_last_update_pending); clean_status_release_proof_epoch(scan_epoch); fsmonitor_accept_pending_token( - istate, closure->untracked_ready); + istate, + closure->untracked_proof_complete, + wt_status_untracked_cache_valid( + closure)); return 1; } break; } + wt_status_discard_staged_untracked(closure); + closure->untracked_proof_complete = + !closure->require_untracked || !istate->untracked; clean_status_release_proof_epoch(scan_epoch); scan_epoch = NULL; if (!fsmonitor_token_requires_rescan(result)) @@ -1281,6 +1331,7 @@ static int wt_status_close_ordinary_fsmonitor_token( if (reliable_stat) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, + closure->use_bulk_provider, &closure->refresh_result); if (!scan_epoch) break; @@ -1289,9 +1340,14 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); } - if (closure->can_prime) + wt_status_record_bulk_untracked(closure); + if (!closure->untracked_proof_complete && + closure->can_prime) { closure->untracked_ready = wt_status_stage_untracked(closure); + closure->untracked_proof_complete = + closure->untracked_ready; + } } clean_status_release_proof_epoch(scan_epoch); return 0; @@ -1312,7 +1368,8 @@ wt_status_close_semantic_fsmonitor_token( struct index_state *istate = s->repo->index; enum fsmonitor_token_result result; int defer_untracked = - closure->can_prime && !closure->untracked_ready; + closure->can_prime && + !closure->untracked_proof_complete; int applied; if (!semantic_verify_start_token_is_current(istate, *proof)) { @@ -1324,7 +1381,8 @@ wt_status_close_semantic_fsmonitor_token( /* The first query closes the tracked scan and its semantic proof. */ closure->queries++; result = fsmonitor_query_pending_token( - istate, defer_untracked ? 0 : closure->untracked_ready); + istate, defer_untracked ? 0 : + wt_status_untracked_cache_valid(closure)); if (result != FSMONITOR_TOKEN_CLEAN) { wt_status_discard_semantic_verify( s, proof, "token-reset"); @@ -1352,7 +1410,10 @@ wt_status_close_semantic_fsmonitor_token( } if (defer_untracked) { - closure->untracked_ready = wt_status_stage_untracked(closure); + closure->untracked_ready = + wt_status_stage_untracked(closure); + closure->untracked_proof_complete = + closure->untracked_ready; trace2_data_intmax( "status", s->repo, "fsmonitor_token/untracked-after-semantic", @@ -1364,11 +1425,14 @@ wt_status_close_semantic_fsmonitor_token( /* A second query closes the subsequent untracked scan. */ closure->queries++; result = fsmonitor_query_pending_token( - istate, closure->untracked_ready); + istate, + wt_status_untracked_cache_valid(closure)); if (result != FSMONITOR_TOKEN_CLEAN) { + wt_status_discard_staged_untracked(closure); untracked_cache_invalidate_all(istate); fsmonitor_invalidate_semantics(istate); closure->untracked_ready = 0; + closure->untracked_proof_complete = 0; wt_status_discard_semantic_verify( s, proof, "token-reset"); if (fsmonitor_token_requires_rescan(result)) @@ -1392,7 +1456,9 @@ wt_status_close_semantic_fsmonitor_token( istate, istate->fsmonitor_last_update_pending); semantic_verify_proof_clear(*proof); *proof = NULL; - fsmonitor_accept_pending_token(istate, closure->untracked_ready); + fsmonitor_accept_pending_token( + istate, closure->untracked_proof_complete, + wt_status_untracked_cache_valid(closure)); return WT_STATUS_TOKEN_CLOSURE_ACCEPTED; } @@ -1438,11 +1504,15 @@ static int wt_status_close_fsmonitor_token( } closure.can_prime = require_untracked && - istate->untracked && istate->untracked->root && + istate->untracked && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode; + closure.use_bulk_provider = + wt_status_can_use_bulk_provider(s, refresh_flags); closure.untracked_ready = !istate->untracked || !istate->untracked->root; + closure.untracked_proof_complete = + !require_untracked || !istate->untracked; if (require_untracked && !closure.can_prime && !closure.untracked_ready) BUG("cannot close required untracked scan"); @@ -1471,6 +1541,7 @@ static int wt_status_close_fsmonitor_token( fallback: wt_status_discard_semantic_verify(s, &proof, "fallback"); wt_status_discard_staged_untracked(&closure); + preload_index_bulk_result_clear(istate); fsmonitor_reject_pending_token(istate); if (fstat_is_reliable()) { if (closure.can_prime) @@ -1496,7 +1567,7 @@ int wt_status_refresh_index(struct wt_status *s, wt_status_begin_attr_snapshot(s); refresh_fsmonitor(istate); - proof = wt_status_prepare_semantic_verify(s); + proof = wt_status_prepare_semantic_verify(s, refresh_flags); ret = wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); if (istate->preload_untracked == &s->untracked) { @@ -1581,7 +1652,8 @@ void wt_status_collect(struct wt_status *s) (used_untracked_cache || !s->repo->index->untracked || !s->repo->index->untracked->root)) { if (fsmonitor_pending_token_from_provider(s->repo->index)) - fsmonitor_accept_pending_token(s->repo->index, 1); + fsmonitor_accept_pending_token( + s->repo->index, 1, used_untracked_cache); else fsmonitor_reject_pending_token(s->repo->index); } From 987940d22f25ea5372c6df1e896bc5e3f9719632 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:48:16 -0500 Subject: [PATCH 058/403] status: gate automatic UNTR validation preloads Starting directory-validation workers for a small cache, restricted pathspec, incompatible traversal, or fsmonitor-managed cache adds work without providing a safe whole-worktree reuse opportunity. Enable automatic preload only when the existing untracked cache and its root are valid, fsmonitor is disabled, traversal flags agree, and a bounded count finds at least 2,000 cached directories. Reject pathspecs, disabled untracked output, ignored-output modes, and incompatible -uall cache settings. Keep the test override for focused small-cache coverage. Status tests exercise both sides of the directory threshold and verify that restricted pathspecs and incompatible -uall requests retain the ordinary traversal path. Signed-off-by: Taylor Blau --- dir.c | 44 ++++++++++++++++++++++++++--- t/t7063-status-untracked-cache.sh | 47 ++++++++++++++++++++++++++++++- wt-status.c | 5 ++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/dir.c b/dir.c index c97f59cbf3b1de..8ad94248770a39 100644 --- a/dir.c +++ b/dir.c @@ -143,8 +143,33 @@ static void collect_untracked_cache_preload_tasks( } } -struct untracked_cache_preload *untracked_cache_preload_start_ordinary( - struct index_state *istate, unsigned int dir_flags) +static size_t count_untracked_cache_dirs_bounded( + const struct untracked_cache_dir *ucd, + size_t limit) +{ + size_t i, nr = 1; + + for (i = 0; i < ucd->dirs_nr && nr < limit; i++) + nr += count_untracked_cache_dirs_bounded(ucd->dirs[i], limit - nr); + return nr; +} + +#define UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS 2000 + +static int untracked_cache_auto_preload_worthwhile( + const struct untracked_cache *uc) +{ + if (!uc || !uc->root || uc->use_fsmonitor || !uc->root->valid) + return 0; + if (git_env_bool("GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD", 0)) + return 1; + return count_untracked_cache_dirs_bounded( + uc->root, UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS) >= + UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS; +} + +static struct untracked_cache_preload *untracked_cache_preload_start_1( + struct index_state *istate, unsigned int dir_flags, int automatic) { struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; @@ -153,8 +178,6 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( unsigned long test_threads; int threads, online, create_threads = 1; - if (!git_env_bool("GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD", 0)) - return NULL; if (!uc || !uc->root || uc->use_fsmonitor || uc->dir_flags != dir_flags) return NULL; @@ -188,6 +211,8 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( preload->started_at = getnanotime(); trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/threads", threads); + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/automatic", automatic); CALLOC_ARRAY(preload->data, threads); work = DIV_ROUND_UP(preload->nr, threads); for (i = 0; i < threads; i++) { @@ -216,6 +241,17 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( return preload; } +struct untracked_cache_preload *untracked_cache_preload_start_ordinary( + struct index_state *istate, unsigned int dir_flags) +{ + struct untracked_cache *uc = istate->untracked; + + if (!uc || uc->dir_flags != dir_flags || + !untracked_cache_auto_preload_worthwhile(uc)) + return NULL; + return untracked_cache_preload_start_1(istate, dir_flags, 1); +} + static void *preload_untracked_cache_thread(void *_data) { struct untracked_cache_preload_data *data = _data; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 8f49cfeebb6896..9f7c5fee79d639 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1017,6 +1017,35 @@ test_expect_success 'directory snapshots ignore weak file-stat configuration' ' ) ' +test_expect_success 'automatic preload observes its directory threshold' ' + test_create_repo auto-preload-threshold && + ( + cd auto-preload-threshold && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor false && + sane_unset GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD && + for i in $(test_seq 1 1998) + do + mkdir "d$i" && + >"d$i/file" || return 1 + done && + git status --porcelain >/dev/null && + GIT_TRACE2_EVENT="$PWD/.git/below-threshold.trace" \ + git status --porcelain >/dev/null && + test_grep ! "preload_untracked_cache/automatic" \ + .git/below-threshold.trace && + + mkdir d1999 && + >d1999/file && + git status --porcelain >/dev/null && + GIT_TRACE2_EVENT="$PWD/.git/at-threshold.trace" \ + git status --porcelain >/dev/null && + test_grep \ + "preload_untracked_cache/automatic.*value.*1" \ + .git/at-threshold.trace + ) +' test_expect_success 'status preloads cached-directory validation' ' test_create_repo auto-preload && ( @@ -1062,7 +1091,23 @@ test_expect_success 'status preloads cached-directory validation' ' test_grep "preload_untracked_cache/valid.*value.*0" \ .git/changed.trace && test_grep "opendir.*value.*[1-9][0-9]*" \ - .git/changed.trace + .git/changed.trace && + + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain -- nested >.git/expect-pathspec && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TRACE2_EVENT="$PWD/.git/pathspec.trace" \ + git status --porcelain -- nested >.git/actual-pathspec && + test_cmp .git/expect-pathspec .git/actual-pathspec && + test_grep ! 'preload_untracked_cache/threads' \ + .git/pathspec.trace && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain -uall >.git/expect-uall && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TRACE2_EVENT="$PWD/.git/uall.trace" \ + git status --porcelain -uall >.git/actual-uall && + test_cmp .git/expect-uall .git/actual-uall && + test_grep ! 'preload_untracked_cache/threads' .git/uall.trace ) ' diff --git a/wt-status.c b/wt-status.c index ffb9ff8f044fd9..da642642d4a229 100644 --- a/wt-status.c +++ b/wt-status.c @@ -817,6 +817,11 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); + if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED || + s->pathspec.nr || + s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode) + return; dir_flags = wt_status_untracked_dir_flags(s); s->untracked_cache_preload = From 3bb4e57cd4d164d2aedf6f861ff841f58c191c07 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:13:48 -0500 Subject: [PATCH 059/403] exclude: compute stable source-proof digests A live exclude-source proof uses filesystem identity to keep one observation coherent. That identity cannot compare equivalent ignore sources captured by separate status processes: replacing a file with the same contents changes its identity without changing ignore semantics. Hash the existing, validated observations in first-observation order. Frame the digest with its version, source object format, unique source count, path, lookup policy, presence, and content identity. Exclude transient stat identity so an equivalent replacement retains the same semantic digest. Extend the existing exclude-proof unit tests to capture independent proofs across a same-content replacement and repeated observation. The digest is independently testable without issuing a sidecar or changing normal exclude-source validation. Signed-off-by: Taylor Blau --- exclude-source-proof.c | 46 +++++++++++++++++++++++++-- exclude-source-proof.h | 10 ++++++ t/unit-tests/u-exclude-source-proof.c | 32 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/exclude-source-proof.c b/exclude-source-proof.c index 022aa5a7ba1d5a..1a2ebd5f87190b 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "exclude-source-proof.h" +#include "hash-framing.h" #include "object-file.h" #include "path-namespace.h" #include "read-cache-ll.h" @@ -8,9 +9,9 @@ #include "trace2.h" /* - * Each entry describes one path/policy observation. Filesystem identities - * are used only to make capture and validation coherent; the durable - * observation is the source's existence and bytes. + * Each entry describes one path/policy observation for replay or a stable + * digest. Filesystem identities make capture and validation coherent; the + * durable observation is the source's existence and bytes. */ struct exclude_source_proof_entry { char *path; @@ -415,6 +416,45 @@ int exclude_source_proof_validate(struct exclude_source_proof *proof) return valid; } +int exclude_source_proof_digest( + struct exclude_source_proof *proof, + const struct git_hash_algo *algo, + struct object_id *oid) +{ + static const char domain[] = "git-exclude-source-proof-digest-v1"; + const struct git_hash_algo *source_algo; + struct git_hash_ctx ctx; + unsigned char count[sizeof(uint64_t)]; + unsigned char format[sizeof(uint32_t)]; + + if (!proof || !algo || !oid || + !exclude_source_proof_validate(proof)) + return -1; + source_algo = proof->istate->repo->hash_algo; + git_hash_init(&ctx, algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + put_be32(format, source_algo->format_id); + hash_length_delimited(&ctx, format, sizeof(format)); + put_be64(count, proof->nr); + hash_length_delimited(&ctx, count, sizeof(count)); + for (size_t i = 0; i < proof->nr; i++) { + const struct exclude_source_proof_entry *entry = + &proof->entries[i]; + unsigned char policy[] = { + entry->nofollow, + entry->exists, + }; + + hash_length_delimited(&ctx, entry->path, + strlen(entry->path)); + hash_length_delimited(&ctx, policy, sizeof(policy)); + hash_length_delimited(&ctx, entry->oid.hash, + entry->exists ? source_algo->rawsz : 0); + } + git_hash_final_oid(oid, &ctx); + return 0; +} + void exclude_source_proof_release(struct exclude_source_proof *proof) { if (!proof) diff --git a/exclude-source-proof.h b/exclude-source-proof.h index e2932f535fb7a4..f1c03b4a3cbf5f 100644 --- a/exclude-source-proof.h +++ b/exclude-source-proof.h @@ -12,7 +12,9 @@ struct exclude_source_capture; struct exclude_source_proof; +struct git_hash_algo; struct index_state; +struct object_id; struct stat; typedef int (*exclude_source_open_parent_fn)(void *data, const char *path); @@ -33,6 +35,14 @@ void exclude_source_capture_record( void exclude_source_capture_error(struct exclude_source_capture *capture); void exclude_source_capture_release(struct exclude_source_capture *capture); int exclude_source_proof_validate(struct exclude_source_proof *proof); +/* + * Hash unique observations in first-observation order. The caller must + * capture them in a deterministic order when comparing across processes. + */ +int exclude_source_proof_digest( + struct exclude_source_proof *proof, + const struct git_hash_algo *algo, + struct object_id *oid); void exclude_source_proof_release(struct exclude_source_proof *proof); #endif /* EXCLUDE_SOURCE_PROOF_H */ diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index 26d5f16e6f2e9c..e579dbcd51247a 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -198,6 +198,37 @@ void test_exclude_source_proof__rejects_conflicting_observations(void) free(parent); } +void test_exclude_source_proof__digest_deduplicates_and_ignores_identity(void) +{ + struct exclude_source_proof *first_proof = + exclude_source_proof_create(&istate, NULL, open_parent); + struct exclude_source_proof *second_proof; + struct object_id first, second; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(first_proof, source); + cl_must_pass(exclude_source_proof_digest( + first_proof, repo.hash_algo, &first)); + + cl_must_pass(unlink(source)); + write_file_buf(source, "content", 7); + second_proof = + exclude_source_proof_create(&istate, NULL, open_parent); + record_file(second_proof, source); + record_file(second_proof, source); + cl_must_pass(exclude_source_proof_digest( + second_proof, repo.hash_algo, &second)); + cl_assert(oideq(&first, &second)); + + exclude_source_proof_release(second_proof); + exclude_source_proof_release(first_proof); + free(source); + free(parent); +} + void test_exclude_source_proof__rejects_open_failure(void) { struct exclude_source_proof *proof = new_proof(); @@ -391,6 +422,7 @@ SKIP_TEST(test_exclude_source_proof__rejects_different_content_replacement) SKIP_TEST(test_exclude_source_proof__accepts_repeated_observation) SKIP_TEST(test_exclude_source_proof__rejects_missing_source_buffer) SKIP_TEST(test_exclude_source_proof__rejects_conflicting_observations) +SKIP_TEST(test_exclude_source_proof__digest_deduplicates_and_ignores_identity) SKIP_TEST(test_exclude_source_proof__rejects_open_failure) SKIP_TEST(test_exclude_source_proof__fails_closed_without_parent_opener) SKIP_TEST(test_exclude_source_proof__honors_nofollow) From 9124f8f9e0c70f087ce4eca6298cf79708d37813 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:04 -0500 Subject: [PATCH 060/403] fsmonitor: rediscover linked worktrees when starting a daemon An implicitly started fsmonitor daemon inherits its caller's repository environment and current directory. In a linked worktree, inherited Git directory, worktree, common-directory, prefix, and index settings can make the child discover a different repository than the worktree whose status requested the daemon. Resolve the requested worktree to its canonical path, start the child from that directory, and remove repository-addressing variables from its environment. Keep the existing daemon start command and return an error if the worktree cannot be resolved. Add a macOS regression that implicitly starts fsmonitor from a linked worktree and checks the daemon child's working directory in Trace2. Signed-off-by: Taylor Blau --- fsmonitor-ipc.c | 29 ++++++++++++++++++++++++++++- t/t7527-builtin-fsmonitor.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 6112d130644f04..78720fa4aba04c 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -1,6 +1,8 @@ #define USE_THE_REPOSITORY_VARIABLE #include "git-compat-util.h" +#include "abspath.h" +#include "environment.h" #include "gettext.h" #include "simple-ipc.h" #include "fsmonitor-ipc.h" @@ -45,6 +47,17 @@ int fsmonitor_ipc__send_command(const char *command UNUSED, #else +static void prepare_spawn_env(struct strvec *env) +{ + /* Let the child rediscover this repository from the worktree. */ + strvec_push(env, GIT_DIR_ENVIRONMENT); + strvec_push(env, GIT_WORK_TREE_ENVIRONMENT); + strvec_push(env, GIT_COMMON_DIR_ENVIRONMENT); + strvec_push(env, GIT_PREFIX_ENVIRONMENT); + strvec_push(env, GIT_IMPLICIT_WORK_TREE_ENVIRONMENT); + strvec_push(env, INDEX_ENVIRONMENT); +} + int fsmonitor_ipc__is_supported(void) { return 1; @@ -58,7 +71,18 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) static int spawn_daemon(void) { struct child_process cmd = CHILD_PROCESS_INIT; + struct strbuf canonical_worktree = STRBUF_INIT; + const char *worktree = repo_get_work_tree(the_repository); + int ret = -1; + if (!worktree || + !strbuf_realpath(&canonical_worktree, worktree, 0)) { + error(_("cannot start fsmonitor daemon without a work tree")); + goto done; + } + + prepare_spawn_env(&cmd.env); + cmd.dir = canonical_worktree.buf; cmd.git_cmd = 1; cmd.no_stdin = 1; cmd.no_stdout = 1; @@ -67,7 +91,10 @@ static int spawn_daemon(void) cmd.trace2_child_class = "fsmonitor"; strvec_pushl(&cmd.args, "fsmonitor--daemon", "start", NULL); - return run_command(&cmd); + ret = run_command(&cmd); +done: + strbuf_release(&canonical_worktree); + return ret; } int fsmonitor_ipc__send_query(const char *since_token, diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 86195770e97779..9f463a3f7a8dbd 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1389,4 +1389,33 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep -q " M dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-try3.out" ' +test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' + test_when_finished " + git -C reexec-linked-wt fsmonitor--daemon stop 2>/dev/null || : + git -C reexec-linked-main worktree remove --force \ + ../reexec-linked-wt 2>/dev/null || : + " && + test_create_repo reexec-linked-main && + ( + cd reexec-linked-main && + test_commit base tracked && + git worktree add ../reexec-linked-wt && + git -C ../reexec-linked-wt config core.untrackedCache true && + git -C ../reexec-linked-wt config core.fsmonitor true && + linked_worktree=$(test-tool path-utils real_path \ + ../reexec-linked-wt) && + GIT_TRACE2_EVENT="$PWD/../reexec-linked.trace" \ + git -C ../reexec-linked-wt status --porcelain=v2 \ + >../reexec-linked.actual && + test_must_be_empty ../reexec-linked.actual && + test_subcommand git fsmonitor--daemon start \ + <../reexec-linked.trace && + test_grep \ + "\"child_class\":\"fsmonitor\",\"cd\":\"$linked_worktree\"" \ + ../reexec-linked.trace && + git -C ../reexec-linked-wt fsmonitor--daemon stop && + git worktree remove ../reexec-linked-wt + ) +' + test_done From a035ffa322f5616ee3d0ee4fb7231416de06f97c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:41:23 -0700 Subject: [PATCH 061/403] fsmonitor: keep multiply-linked files invalid A pathname monitor cannot establish that every name for a multiply-linked regular file lies inside its watch cone. Persisting CE_FSMONITOR_VALID after checking the tracked name can therefore hide a later write through an unmonitored hardlink. Use fsmonitor_stat_can_be_valid() to exclude regular files with more than one link from persistent fsmonitor validity when the platform reports real link counts. Apply that decision where index refresh, threaded preload, and diff-files first consume an actual stat. Preserve CE_UPTODATE for the current process and retain existing persistent validity for single-link and nonregular entries. Windows and Cygwin synthesize their link counts, so preserve their existing fsmonitor behavior without claiming the hardlink guarantee there. Add a hardlink regression in t/t7519-status-fsmonitor.sh on platforms with trustworthy stat metadata. It keeps a tracked hardlink outside the fsmonitor-valid bitmap and checks that a write through an alias outside the worktree appears in status. The deliberate cost is another stat in a subsequent process. Signed-off-by: Taylor Blau --- diff-lib.c | 7 ++++++- fsmonitor.h | 13 +++++++++++++ preload-index.c | 3 ++- read-cache.c | 6 ++++-- t/t7519-status-fsmonitor.sh | 31 +++++++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/diff-lib.c b/diff-lib.c index 748f9ec3f3e7e7..33b4233738cac3 100644 --- a/diff-lib.c +++ b/diff-lib.c @@ -130,6 +130,7 @@ void run_diff_files(struct rev_info *revs, unsigned int option) entries = istate->cache_nr; for (i = 0; i < entries; i++) { unsigned int oldmode, newmode; + int fsmonitor_valid = 0; struct cache_entry *ce = istate->cache[i]; int changed; unsigned dirty_submodule = 0; @@ -249,6 +250,8 @@ void run_diff_files(struct rev_info *revs, unsigned int option) if (ce->ce_flags & (CE_VALID | CE_FSMONITOR_VALID)) { changed = 0; newmode = ce->ce_mode; + fsmonitor_valid = + !!(ce->ce_flags & CE_FSMONITOR_VALID); } else { struct stat st; @@ -274,11 +277,13 @@ void run_diff_files(struct rev_info *revs, unsigned int option) changed = match_stat_with_submodule(&revs->diffopt, ce, &st, ce_option, &dirty_submodule); newmode = ce_mode_from_stat(revs->repo, ce, st.st_mode); + fsmonitor_valid = fsmonitor_stat_can_be_valid(&st); } if (!changed && !dirty_submodule) { ce_mark_uptodate(ce); - mark_fsmonitor_valid(istate, ce); + if (fsmonitor_valid) + mark_fsmonitor_valid(istate, ce); if (revs->diffopt.flags.find_copies_harder) diff_same(&revs->diffopt, newmode, &ce->oid, ce->name); diff --git a/fsmonitor.h b/fsmonitor.h index 4027ca83f2b8cd..47ce78de61c508 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -15,6 +15,19 @@ */ void fsmonitor_invalidate_cache_entry(struct cache_entry *ce); +/* + * A pathname monitor cannot prove that every name for a multiply-linked + * inode is inside its watch cone. When the platform reports real link + * counts, keep such regular files out of the persistent valid bitmap so + * that every new process checks their stat data. Platforms that synthesize + * link counts retain their existing fsmonitor behavior. The in-process + * CE_UPTODATE bit is still safe after the caller's lstat(). + */ +static inline int fsmonitor_stat_can_be_valid(const struct stat *st) +{ + return !S_ISREG(st->st_mode) || st->st_nlink <= 1; +} + /* * Check if refresh_fsmonitor has been called at least once. * refresh_fsmonitor is idempotent. Returns true if fsmonitor is diff --git a/preload-index.c b/preload-index.c index b222821b448526..6c675339285257 100644 --- a/preload-index.c +++ b/preload-index.c @@ -90,7 +90,8 @@ static void *preload_thread(void *_data) if (ie_match_stat(index, ce, &st, CE_MATCH_RACY_IS_DIRTY|CE_MATCH_IGNORE_FSMONITOR)) continue; ce_mark_uptodate(ce); - mark_fsmonitor_valid(index, ce); + if (fsmonitor_stat_can_be_valid(&st)) + mark_fsmonitor_valid(index, ce); } while (--nr > 0); if (p->progress) { struct progress_data *pd = p->progress; diff --git a/read-cache.c b/read-cache.c index fe989ce636003b..b853d79c72aa5f 100644 --- a/read-cache.c +++ b/read-cache.c @@ -199,7 +199,8 @@ void fill_stat_cache_info(struct index_state *istate, struct cache_entry *ce, st if (S_ISREG(st->st_mode)) { ce_mark_uptodate(ce); - mark_fsmonitor_valid(istate, ce); + if (fsmonitor_stat_can_be_valid(st)) + mark_fsmonitor_valid(istate, ce); } } @@ -1441,7 +1442,8 @@ static struct cache_entry *refresh_cache_ent(struct index_state *istate, */ if (!S_ISGITLINK(ce->ce_mode)) { ce_mark_uptodate(ce); - mark_fsmonitor_valid(istate, ce); + if (fsmonitor_stat_can_be_valid(&st)) + mark_fsmonitor_valid(istate, ce); } return ce; } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index a0a20aa80e4a8f..fb2fadc53d5986 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -55,6 +55,11 @@ test_lazy_prereq UNTRACKED_CACHE ' test $ret -ne 1 ' +test_lazy_prereq HARDLINKS ' + : >hardlink-a && + ln hardlink-a hardlink-b +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && @@ -652,4 +657,30 @@ test_expect_success 'provider global marker invalidates every tracked entry' ' ) ' +test_expect_success HARDLINKS,!MINGW,!CYGWIN \ + 'multiply-linked files stay fsmonitor-invalid' ' + test_when_finished "rm -f hardlink-alias" && + test_create_repo hardlink-validity && + ( + cd hardlink-validity && + echo content >tracked && + git add tracked && + git commit -m base && + ln tracked ../hardlink-alias && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + git ls-files -f >.git/flags && + test_grep "^H tracked$" .git/flags && + echo changed >>../hardlink-alias && + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* tracked$" .git/actual + ) +' + test_done From 3195ebce77f5cacbc13f3d594b557fbc0e7e2bdc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:04:25 -0500 Subject: [PATCH 062/403] status: add a bounded clean-status sidecar format A later status invocation cannot safely reuse an empty result unless its persistent record identifies the exact index and semantic inputs that the original scan proved. Accepting truncated, ambiguous, or forward-versioned records would turn a cache miss into a false clean result. Define the version-one CSTS encoding and serialize index identity in fixed-width network-byte-order fields. Bind the index format, entry count, checksum, HEAD tree, configuration and repository hashes, one exclude digest, and a bounded builtin-provider token. Protect the complete record with the repository's object-format checksum. Reject unknown flags, unsupported index formats, null required object IDs, invalid token bounds or prefixes, bad checksums, truncation, and trailing payload. Add fixed-width identity and sidecar unit coverage for both SHA-1 and SHA-256. Register the new source and unit suite in both Make and Meson. This patch defines and tests the format; it neither writes a sidecar nor changes status dispatch. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-identity.c | 29 +++ clean-status-identity.h | 9 + clean-status-sidecar.c | 128 +++++++++++ clean-status-sidecar.h | 35 +++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-identity.c | 38 ++++ t/unit-tests/u-clean-status-sidecar.c | 287 +++++++++++++++++++++++++ 9 files changed, 530 insertions(+) create mode 100644 clean-status-sidecar.c create mode 100644 clean-status-sidecar.h create mode 100644 t/unit-tests/u-clean-status-sidecar.c diff --git a/Makefile b/Makefile index 9b6f8b5e518fe9..974beec198be26 100644 --- a/Makefile +++ b/Makefile @@ -1136,6 +1136,7 @@ LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o +LIB_OBJS += clean-status-sidecar.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o @@ -1576,6 +1577,7 @@ CLAR_TEST_SUITES += u-clean-status-history-store CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest +CLAR_TEST_SUITES += u-clean-status-sidecar CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-identity.c b/clean-status-identity.c index 415ecab19a64f2..98d5a7e51b9a73 100644 --- a/clean-status-identity.c +++ b/clean-status-identity.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "clean-status-identity.h" +#include "strbuf.h" int clean_status_identity_from_stat(struct clean_status_identity *identity, const struct stat *st) @@ -25,3 +26,31 @@ int clean_status_identity_equal(const struct clean_status_identity *a, { return path_stat_identity_equal(&a->stat, &b->stat); } + +void clean_status_identity_write(struct strbuf *out, + const struct clean_status_identity *identity) +{ + uint64_t value; + size_t i; + + for (i = 0; i < ARRAY_SIZE(identity->stat.fields); i++) { + put_be64(&value, identity->stat.fields[i]); + strbuf_add(out, &value, sizeof(value)); + } +} + +int clean_status_identity_read(const unsigned char **p, + const unsigned char *end, + struct clean_status_identity *identity) +{ + size_t i; + + memset(identity, 0, sizeof(*identity)); + for (i = 0; i < ARRAY_SIZE(identity->stat.fields); i++) { + if ((size_t)(end - *p) < sizeof(uint64_t)) + return -1; + identity->stat.fields[i] = get_be64(*p); + *p += sizeof(uint64_t); + } + return 0; +} diff --git a/clean-status-identity.h b/clean-status-identity.h index 68e459a0349a1f..a22f8438f50874 100644 --- a/clean-status-identity.h +++ b/clean-status-identity.h @@ -3,16 +3,25 @@ #include "path-namespace.h" +struct strbuf; struct stat; struct clean_status_identity { struct path_stat_identity stat; }; +#define CLEAN_STATUS_IDENTITY_SIZE \ + (PATH_STAT_IDENTITY_FIELDS * sizeof(uint64_t)) + int clean_status_identity_from_stat(struct clean_status_identity *identity, const struct stat *st); int clean_status_identity_is_durable(void); int clean_status_identity_equal(const struct clean_status_identity *a, const struct clean_status_identity *b); +void clean_status_identity_write(struct strbuf *out, + const struct clean_status_identity *identity); +int clean_status_identity_read(const unsigned char **p, + const unsigned char *end, + struct clean_status_identity *identity); #endif /* CLEAN_STATUS_IDENTITY_H */ diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c new file mode 100644 index 00000000000000..b850f27e6a6d57 --- /dev/null +++ b/clean-status-sidecar.c @@ -0,0 +1,128 @@ +#include "git-compat-util.h" +#include "clean-status-sidecar.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" +#include "strbuf.h" + +#define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" + +static int checksum_valid(const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *bytes = data; + unsigned char actual[GIT_MAX_RAWSZ]; + + if (len < algo->rawsz) + return 0; + hash_buffer_digest(algo, data, len - algo->rawsz, actual); + return !memcmp(actual, bytes + len - algo->rawsz, algo->rawsz); +} + +static int token_valid(const unsigned char *token, size_t token_len) +{ + static const char prefix[] = "builtin:"; + + return token && token_len && + token_len <= FSMONITOR_CLEAN_PROOF_TOKEN_MAX && + !memchr(token, '\0', token_len) && + token_len >= sizeof(prefix) - 1 && + !memcmp(token, prefix, sizeof(prefix) - 1); +} + +static int proof_valid(const struct clean_status_proof *proof, + const struct git_hash_algo *algo) +{ + return proof->index_version >= 2 && proof->index_version <= 4 && + !is_null_oid(&proof->index_checksum) && + !is_null_oid(&proof->head_tree) && + !is_null_oid(&proof->exclude_source_digest) && + proof->index_checksum.algo == hash_algo_by_ptr(algo) && + proof->head_tree.algo == hash_algo_by_ptr(algo) && + proof->exclude_source_digest.algo == hash_algo_by_ptr(algo); +} + +int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, + const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *p = data; + const unsigned char *end; + size_t minimum = 4 + 2 * sizeof(uint32_t) + + CLEAN_STATUS_IDENTITY_SIZE + 3 * sizeof(uint32_t) + + 6 * algo->rawsz + 1; + uint32_t flags, token_len; + + memset(sidecar, 0, sizeof(*sidecar)); + if (len < minimum || memcmp(p, CLEAN_STATUS_SIDECAR_MAGIC, 4) || + !checksum_valid(data, len, algo)) + return -1; + end = p + len - algo->rawsz; + p += 4; + if (get_be32(p) != CLEAN_STATUS_SIDECAR_VERSION) + return -1; + p += sizeof(uint32_t); + flags = get_be32(p); + p += sizeof(uint32_t); + if (flags) + return -1; + if (clean_status_identity_read(&p, end, &sidecar->identity)) + return -1; + sidecar->proof.index_version = get_be32(p); + p += sizeof(uint32_t); + sidecar->proof.cache_nr = get_be32(p); + p += sizeof(uint32_t); + oidread(&sidecar->proof.index_checksum, p, algo); + p += algo->rawsz; + oidread(&sidecar->proof.head_tree, p, algo); + p += algo->rawsz; + memcpy(sidecar->proof.config_hash, p, algo->rawsz); + p += algo->rawsz; + memcpy(sidecar->proof.repo_hash, p, algo->rawsz); + p += algo->rawsz; + oidread(&sidecar->proof.exclude_source_digest, p, algo); + p += algo->rawsz; + token_len = get_be32(p); + p += sizeof(uint32_t); + if (!proof_valid(&sidecar->proof, algo) || + (size_t)(end - p) != token_len || + !token_valid(p, token_len)) + return -1; + sidecar->token = p; + sidecar->token_len = token_len; + return 0; +} + +int clean_status_sidecar_write(struct strbuf *out, + const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo) +{ + uint32_t value; + + strbuf_reset(out); + if (!proof_valid(&sidecar->proof, algo) || + sidecar->token_len > UINT32_MAX || + !token_valid(sidecar->token, sidecar->token_len)) + return -1; + + strbuf_add(out, CLEAN_STATUS_SIDECAR_MAGIC, 4); + put_be32(&value, CLEAN_STATUS_SIDECAR_VERSION); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, 0); + strbuf_add(out, &value, sizeof(value)); + clean_status_identity_write(out, &sidecar->identity); + put_be32(&value, sidecar->proof.index_version); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, sidecar->proof.cache_nr); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, sidecar->proof.index_checksum.hash, algo->rawsz); + strbuf_add(out, sidecar->proof.head_tree.hash, algo->rawsz); + strbuf_add(out, sidecar->proof.config_hash, algo->rawsz); + strbuf_add(out, sidecar->proof.repo_hash, algo->rawsz); + strbuf_add(out, sidecar->proof.exclude_source_digest.hash, + algo->rawsz); + put_be32(&value, sidecar->token_len); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, sidecar->token, sidecar->token_len); + hash_append_checksum(out, algo); + return 0; +} diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h new file mode 100644 index 00000000000000..1b99434f1f7045 --- /dev/null +++ b/clean-status-sidecar.h @@ -0,0 +1,35 @@ +#ifndef CLEAN_STATUS_SIDECAR_H +#define CLEAN_STATUS_SIDECAR_H + +#include "clean-status-identity.h" +#include "hash.h" + +struct strbuf; + +#define CLEAN_STATUS_SIDECAR_VERSION 1 + +struct clean_status_proof { + uint32_t index_version; + uint32_t cache_nr; + struct object_id index_checksum; + struct object_id head_tree; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char repo_hash[GIT_MAX_RAWSZ]; + struct object_id exclude_source_digest; +}; + +struct clean_status_sidecar { + struct clean_status_identity identity; + struct clean_status_proof proof; + const unsigned char *token; + size_t token_len; +}; + +int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, + const void *data, size_t len, + const struct git_hash_algo *algo); +int clean_status_sidecar_write(struct strbuf *out, + const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo); + +#endif /* CLEAN_STATUS_SIDECAR_H */ diff --git a/meson.build b/meson.build index 23888563a37db6..f9170fb500a250 100644 --- a/meson.build +++ b/meson.build @@ -341,6 +341,7 @@ libgit_sources = [ 'clean-status-identity.c', 'clean-status-index.c', 'clean-status-manifest.c', + 'clean-status-sidecar.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/t/meson.build b/t/meson.build index 0951dce558bea3..a92a264668bbaa 100644 --- a/t/meson.build +++ b/t/meson.build @@ -7,6 +7,7 @@ clar_test_suites = [ 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', + 'unit-tests/u-clean-status-sidecar.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-identity.c b/t/unit-tests/u-clean-status-identity.c index 33e7b80fcfc7e7..6875d594c58c5c 100644 --- a/t/unit-tests/u-clean-status-identity.c +++ b/t/unit-tests/u-clean-status-identity.c @@ -1,5 +1,43 @@ #include "unit-test.h" #include "clean-status-identity.h" +#include "strbuf.h" + +void test_clean_status_identity__round_trips_fixed_width_encoding(void) +{ + struct clean_status_identity expected = { + .stat.fields = { + 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, + }, + }; + struct clean_status_identity actual; + struct strbuf encoded = STRBUF_INIT; + const unsigned char *p; + + clean_status_identity_write(&encoded, &expected); + cl_assert_equal_i(encoded.len, CLEAN_STATUS_IDENTITY_SIZE); + p = (const unsigned char *)encoded.buf; + cl_assert_equal_i(clean_status_identity_read( + &p, (const unsigned char *)encoded.buf + encoded.len, &actual), 0); + cl_assert_equal_i(p - (const unsigned char *)encoded.buf, encoded.len); + cl_assert(clean_status_identity_equal(&expected, &actual)); + strbuf_release(&encoded); +} + +void test_clean_status_identity__rejects_every_truncation(void) +{ + struct clean_status_identity identity = { 0 }, parsed; + struct strbuf encoded = STRBUF_INIT; + + clean_status_identity_write(&encoded, &identity); + for (size_t len = 0; len < encoded.len; len++) { + const unsigned char *p = (const unsigned char *)encoded.buf; + + cl_assert_equal_i(clean_status_identity_read( + &p, (const unsigned char *)encoded.buf + len, &parsed), -1); + } + strbuf_release(&encoded); +} void test_clean_status_identity__requires_a_single_link_regular_file(void) { diff --git a/t/unit-tests/u-clean-status-sidecar.c b/t/unit-tests/u-clean-status-sidecar.c new file mode 100644 index 00000000000000..56ea5581b2a059 --- /dev/null +++ b/t/unit-tests/u-clean-status-sidecar.c @@ -0,0 +1,287 @@ +#include "unit-test.h" +#include "clean-status-sidecar.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" +#include "strbuf.h" + +struct sidecar_fixture { + struct clean_status_sidecar sidecar; + struct strbuf encoded; +}; + +static void fill_oid(struct object_id *oid, unsigned char value, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(hash, value, algo->rawsz); + oidread(oid, hash, algo); +} + +static void fixture_init(struct sidecar_fixture *fixture, + const struct git_hash_algo *algo) +{ + static const unsigned char token[] = "builtin:1:2"; + struct clean_status_proof *proof; + + memset(fixture, 0, sizeof(*fixture)); + fixture->encoded = (struct strbuf)STRBUF_INIT; + fixture->sidecar.identity.stat.fields[0] = 1; + fixture->sidecar.identity.stat.fields[1] = 2; + proof = &fixture->sidecar.proof; + proof->index_version = 4; + proof->cache_nr = 5; + fill_oid(&proof->index_checksum, 2, algo); + fill_oid(&proof->head_tree, 3, algo); + memset(proof->config_hash, 4, algo->rawsz); + memset(proof->repo_hash, 5, algo->rawsz); + fill_oid(&proof->exclude_source_digest, 6, algo); + fixture->sidecar.token = token; + fixture->sidecar.token_len = sizeof(token) - 1; +} + +static void fixture_encode(struct sidecar_fixture *fixture, + const struct git_hash_algo *algo) +{ + cl_assert_equal_i(clean_status_sidecar_write( + &fixture->encoded, &fixture->sidecar, algo), 0); +} + +static void fixture_release(struct sidecar_fixture *fixture) +{ + strbuf_release(&fixture->encoded); +} + +static void replace_checksum(struct strbuf *encoded, + const struct git_hash_algo *algo) +{ + strbuf_setlen(encoded, encoded->len - algo->rawsz); + hash_append_checksum(encoded, algo); +} + +static size_t flags_offset(void) +{ + return 4 + sizeof(uint32_t); +} + +static size_t proof_offset(void) +{ + return 4 + 2 * sizeof(uint32_t) + CLEAN_STATUS_IDENTITY_SIZE; +} + +static size_t index_checksum_offset(void) +{ + return proof_offset() + 2 * sizeof(uint32_t); +} + +static size_t head_tree_offset(const struct git_hash_algo *algo) +{ + return index_checksum_offset() + algo->rawsz; +} + +static size_t exclude_digest_offset(const struct git_hash_algo *algo) +{ + return index_checksum_offset() + 4 * algo->rawsz; +} + +static size_t token_length_offset(const struct git_hash_algo *algo) +{ + return index_checksum_offset() + 5 * algo->rawsz; +} + +static size_t token_offset(const struct git_hash_algo *algo) +{ + return token_length_offset(algo) + sizeof(uint32_t); +} + +static void assert_parse_fails(struct sidecar_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct clean_status_sidecar parsed; + + replace_checksum(&fixture->encoded, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture->encoded.buf, fixture->encoded.len, algo), -1); +} + +static void assert_round_trip(const struct git_hash_algo *algo) +{ + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert(clean_status_identity_equal(&parsed.identity, + &fixture.sidecar.identity)); + cl_assert_equal_i(parsed.proof.index_version, + fixture.sidecar.proof.index_version); + cl_assert_equal_i(parsed.proof.cache_nr, + fixture.sidecar.proof.cache_nr); + cl_assert(oideq(&parsed.proof.index_checksum, + &fixture.sidecar.proof.index_checksum)); + cl_assert(oideq(&parsed.proof.head_tree, + &fixture.sidecar.proof.head_tree)); + cl_assert(!memcmp(parsed.proof.config_hash, + fixture.sidecar.proof.config_hash, algo->rawsz)); + cl_assert(!memcmp(parsed.proof.repo_hash, + fixture.sidecar.proof.repo_hash, algo->rawsz)); + cl_assert(oideq(&parsed.proof.exclude_source_digest, + &fixture.sidecar.proof.exclude_source_digest)); + cl_assert_equal_i(parsed.token_len, fixture.sidecar.token_len); + cl_assert(!memcmp(parsed.token, fixture.sidecar.token, + parsed.token_len)); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__round_trips_both_object_formats(void) +{ + assert_round_trip(&hash_algos[GIT_HASH_SHA1]); + assert_round_trip(&hash_algos[GIT_HASH_SHA256]); +} + +void test_clean_status_sidecar__rejects_bad_envelopes(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len - 1, algo), -1); + + fixture.encoded.buf[0] ^= 1; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[0] ^= 1; + + put_be32(fixture.encoded.buf + 4, CLEAN_STATUS_SIDECAR_VERSION + 1); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + 4, CLEAN_STATUS_SIDECAR_VERSION); + + put_be32(fixture.encoded.buf + flags_offset(), 1); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + flags_offset(), 0); + + fixture.encoded.buf[fixture.encoded.len - 1] ^= 1; + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), -1); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_invalid_proofs(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + + put_be32(fixture.encoded.buf + proof_offset(), 1); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + proof_offset(), 4); + + memset(fixture.encoded.buf + index_checksum_offset(), 0, algo->rawsz); + assert_parse_fails(&fixture, algo); + memset(fixture.encoded.buf + index_checksum_offset(), 2, algo->rawsz); + + memset(fixture.encoded.buf + head_tree_offset(algo), 0, algo->rawsz); + assert_parse_fails(&fixture, algo); + memset(fixture.encoded.buf + head_tree_offset(algo), 3, algo->rawsz); + + memset(fixture.encoded.buf + exclude_digest_offset(algo), 0, + algo->rawsz); + assert_parse_fails(&fixture, algo); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_invalid_tokens(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + size_t token_len_offset = token_length_offset(algo); + size_t token_start = token_offset(algo); + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + + put_be32(fixture.encoded.buf + token_len_offset, 0); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + token_len_offset, + fixture.sidecar.token_len); + + fixture.encoded.buf[token_start] = 'x'; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[token_start] = 'b'; + + fixture.encoded.buf[token_start + fixture.sidecar.token_len - 1] = '\0'; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[token_start + fixture.sidecar.token_len - 1] = '2'; + + put_be32(fixture.encoded.buf + token_len_offset, + FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); + assert_parse_fails(&fixture, algo); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__accepts_the_maximum_token(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + unsigned char *token; + + fixture_init(&fixture, algo); + token = xmalloc(FSMONITOR_CLEAN_PROOF_TOKEN_MAX); + memset(token, 'x', FSMONITOR_CLEAN_PROOF_TOKEN_MAX); + memcpy(token, "builtin:", strlen("builtin:")); + fixture.sidecar.token = token; + fixture.sidecar.token_len = FSMONITOR_CLEAN_PROOF_TOKEN_MAX; + fixture_encode(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.token_len, FSMONITOR_CLEAN_PROOF_TOKEN_MAX); + free(token); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_trailing_payload(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + strbuf_setlen(&fixture.encoded, fixture.encoded.len - algo->rawsz); + strbuf_addch(&fixture.encoded, 'x'); + hash_append_checksum(&fixture.encoded, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &fixture.sidecar, fixture.encoded.buf, fixture.encoded.len, algo), + -1); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_invalid_writes(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + unsigned char *token; + + fixture_init(&fixture, algo); + oidclr(&fixture.sidecar.proof.index_checksum, algo); + cl_assert_equal_i(clean_status_sidecar_write( + &fixture.encoded, &fixture.sidecar, algo), -1); + fill_oid(&fixture.sidecar.proof.index_checksum, 2, algo); + + token = xmalloc(FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); + memset(token, 'x', FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); + memcpy(token, "builtin:", strlen("builtin:")); + fixture.sidecar.token = token; + fixture.sidecar.token_len = FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1; + cl_assert_equal_i(clean_status_sidecar_write( + &fixture.encoded, &fixture.sidecar, algo), -1); + free(token); + fixture_release(&fixture); +} From f8c8ac1d4f70d4e8fe3a02c0f78907f2a5f6bad8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:14 -0500 Subject: [PATCH 063/403] fsmonitor: start implicit daemons with the invoking Git executable Implicit fsmonitor startup resolves a Git command through the execution path and invokes its start subcommand. An overridden execution path can therefore select a different Git than the dispatcher that initiated the query, while adding another launcher between the client and daemon. Retain the absolute executable path during dispatcher initialization and expose it only for a real Git dispatcher. Start that executable directly with fsmonitor--daemon run --detach, then wait until its IPC socket is listening before accepting startup. Respect the configured startup timeout, defaulting to 60 seconds, and retain Git-command lookup when an authoritative dispatcher path is unavailable. The canonical worktree and sanitized environment established by S03/P01 remain in place. Update existing startup Trace2 checks for the direct invocation and add a macOS regression with a fake Git on the execution path to verify that the original executable is used. Signed-off-by: Taylor Blau --- exec-cmd.c | 44 +++++++++++++++++++++++++++++--- exec-cmd.h | 2 ++ fsmonitor-ipc.c | 49 +++++++++++++++++++++++++++++++++--- git.c | 3 +++ t/t7527-builtin-fsmonitor.sh | 37 +++++++++++++++++++++++---- 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/exec-cmd.c b/exec-cmd.c index 507e67d528b0dd..dc801d1a6d35d4 100644 --- a/exec-cmd.c +++ b/exec-cmd.c @@ -27,6 +27,14 @@ static const char *system_prefix(void); +/* + * Absolute path to the current executable, when it can be determined. Keep + * this separately from executable_dirname because some callers need to + * re-execute this exact Git rather than resolve "git" through PATH. + */ +static const char *executable_path; +static int executable_is_dispatcher; + #ifdef RUNTIME_PREFIX /** @@ -257,7 +265,8 @@ void git_resolve_executable_dir(const char *argv0) return; } - resolved = strbuf_detach(&buf, NULL); + executable_path = strbuf_detach(&buf, NULL); + resolved = xstrdup(executable_path); slash = find_last_dir_sep(resolved); if (slash) resolved[slash - resolved] = '\0'; @@ -278,15 +287,42 @@ static const char *system_prefix(void) } /* - * This is called during initialization, but No work needs to be done here when - * runtime prefix is not being used. + * A non-runtime-prefix build does not need the executable directory for path + * discovery, but an explicit argv[0] is still useful for exact re-execution. */ -void git_resolve_executable_dir(const char *argv0 UNUSED) +void git_resolve_executable_dir(const char *argv0) { + struct strbuf buf = STRBUF_INIT; + + /* A bare argv[0] would require a PATH lookup and is not authoritative. */ + if (!argv0 || !*argv0 || !find_last_dir_sep(argv0)) + return; + strbuf_add_absolute_path(&buf, argv0); + if (strbuf_normalize_path(&buf)) { + trace_printf("trace: could not normalize executable path: %s\n", + buf.buf); + strbuf_release(&buf); + return; + } + executable_path = strbuf_detach(&buf, NULL); + trace2_cmd_path(executable_path); } #endif /* RUNTIME_PREFIX */ +const char *git_executable_path(void) +{ + /* Helpers have an exact path too, but cannot dispatch Git builtins. */ + if (!executable_path || !executable_is_dispatcher) + return NULL; + return executable_path; +} + +void git_mark_executable_as_dispatcher(void) +{ + executable_is_dispatcher = 1; +} + char *system_path(const char *path) { struct strbuf d = STRBUF_INIT; diff --git a/exec-cmd.h b/exec-cmd.h index 330b41d54dec52..0613765ef7e4ee 100644 --- a/exec-cmd.h +++ b/exec-cmd.h @@ -5,6 +5,8 @@ struct strvec; void git_set_exec_path(const char *exec_path); void git_resolve_executable_dir(const char *path); +const char *git_executable_path(void); +void git_mark_executable_as_dispatcher(void); const char *git_exec_path(void); void setup_path(void); const char **prepare_git_cmd(struct strvec *out, const char **argv); diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 78720fa4aba04c..8957091bfccbb2 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -2,8 +2,11 @@ #include "git-compat-util.h" #include "abspath.h" +#include "config.h" #include "environment.h" +#include "exec-cmd.h" #include "gettext.h" +#include "parse.h" #include "simple-ipc.h" #include "fsmonitor-ipc.h" #include "repository.h" @@ -68,10 +71,44 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) return ipc_get_active_state(fsmonitor_ipc__get_path(the_repository)); } +#define FSMONITOR_START_TIMEOUT_KEY "fsmonitor.starttimeout" +#define FSMONITOR_START_TIMEOUT_DEFAULT 60 + +static unsigned int get_start_timeout(void) +{ + const char *value; + int timeout; + + if (!repo_config_get_value(the_repository, + FSMONITOR_START_TIMEOUT_KEY, &value) && + value && git_parse_int(value, &timeout) && timeout >= 0) + return timeout; + return FSMONITOR_START_TIMEOUT_DEFAULT; +} + +static int spawn_wait_cb(const struct child_process *cmd UNUSED, + void *cb_data UNUSED) +{ + switch (fsmonitor_ipc__get_state()) { + case IPC_STATE__LISTENING: + return 0; + case IPC_STATE__NOT_LISTENING: + case IPC_STATE__PATH_NOT_FOUND: + return 1; + default: + case IPC_STATE__INVALID_PATH: + case IPC_STATE__OTHER_ERROR: + return -1; + } +} + static int spawn_daemon(void) { struct child_process cmd = CHILD_PROCESS_INIT; struct strbuf canonical_worktree = STRBUF_INIT; + enum start_bg_result result; + unsigned int timeout = get_start_timeout(); + const char *git = git_executable_path(); const char *worktree = repo_get_work_tree(the_repository); int ret = -1; @@ -83,15 +120,21 @@ static int spawn_daemon(void) prepare_spawn_env(&cmd.env); cmd.dir = canonical_worktree.buf; - cmd.git_cmd = 1; + if (git) + strvec_push(&cmd.args, git); + else + cmd.git_cmd = 1; cmd.no_stdin = 1; cmd.no_stdout = 1; cmd.no_stderr = 1; cmd.close_fd_above_stderr = 1; cmd.trace2_child_class = "fsmonitor"; - strvec_pushl(&cmd.args, "fsmonitor--daemon", "start", NULL); + strvec_pushl(&cmd.args, "fsmonitor--daemon", "run", "--detach", NULL); - ret = run_command(&cmd); + result = start_bg_command(&cmd, spawn_wait_cb, NULL, timeout); + if (result == SBGR_READY || + fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) + ret = 0; done: strbuf_release(&canonical_worktree); return ret; diff --git a/git.c b/git.c index e5f1811b6bb762..956a0c2e4d8f54 100644 --- a/git.c +++ b/git.c @@ -929,6 +929,9 @@ int cmd_main(int argc, const char **argv) if (slash) cmd = slash + 1; } + /* A renamed dispatcher is still safer to re-exec than a Git from PATH. */ + if (!starts_with(cmd, "git-")) + git_mark_executable_as_dispatcher(); trace_command_performance(argv); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 9f463a3f7a8dbd..9c96c0e3a6aee8 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -384,7 +384,8 @@ test_expect_success 'update-index implicitly starts daemon' ' # Confirm that the trace2 log contains a record of the # daemon starting. - test_subcommand git fsmonitor--daemon start <.git/trace_implicit_1 + test_grep "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/trace_implicit_1 ' test_expect_success 'status implicitly starts daemon' ' @@ -400,7 +401,8 @@ test_expect_success 'status implicitly starts daemon' ' # Confirm that the trace2 log contains a record of the # daemon starting. - test_subcommand git fsmonitor--daemon start <.git/trace_implicit_2 + test_grep "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/trace_implicit_2 ' edit_files () { @@ -978,7 +980,8 @@ test_expect_success "submodule absorbgitdirs implicitly starts daemon" ' # Confirm that the trace2 log contains a record of the # daemon starting. - test_subcommand git fsmonitor--daemon start "$TRASH_DIRECTORY/fake-git-used" + exit 1 + EOF + ( + cd same-executable-spawn && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_EXEC_PATH="$TRASH_DIRECTORY/fake-exec-path" \ + GIT_TRACE2_EVENT="$PWD/.git/spawn.trace" \ + "$GIT_BUILD_DIR/git" status --porcelain=v2 \ + >.git/actual && + test_must_be_empty .git/actual && + test_path_is_missing "$TRASH_DIRECTORY/fake-git-used" && + test_grep -F "\"argv\":[\"$GIT_BUILD_DIR/git\",\"fsmonitor--daemon\",\"run\",\"--detach\"]" \ + .git/spawn.trace && + git fsmonitor--daemon stop + ) +' + test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' test_when_finished " git -C reexec-linked-wt fsmonitor--daemon stop 2>/dev/null || : @@ -1408,8 +1435,8 @@ test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' git -C ../reexec-linked-wt status --porcelain=v2 \ >../reexec-linked.actual && test_must_be_empty ../reexec-linked.actual && - test_subcommand git fsmonitor--daemon start \ - <../reexec-linked.trace && + test_grep "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + ../reexec-linked.trace && test_grep \ "\"child_class\":\"fsmonitor\",\"cd\":\"$linked_worktree\"" \ ../reexec-linked.trace && From db1393e52eea9416c3e387f71a732c7d63acb0b9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 064/403] dir: verify cached excludes during UNTR preload Matching directory metadata alone cannot prove that its cached ignore rules are unchanged. A rewritten .gitignore with restored timestamps can otherwise leave preload results valid while changing which untracked paths should be visible. Snapshot each cached exclude object ID and validate its per-directory file on the existing preload workers. Open regular files with open_nofollow(), reject files larger than 1 MiB, and compare their raw or trailing-LF blob hash with the cached object ID. Verify the open file's stat identity before and after reading, then reopen its pathname and require the same identity through S01/P08. Publish directory results only when both stat and exclude checks match; otherwise invalidate the cached ignore state and fall back to ordinary traversal. A status test rewrites .gitignore, restores its mtime, and checks the result against uncached status. Signed-off-by: Taylor Blau --- dir.c | 99 +++++++++++++++++++++++++++++-- dir.h | 1 + t/t7063-status-untracked-cache.sh | 31 ++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/dir.c b/dir.c index 8ad94248770a39..1f923c52396830 100644 --- a/dir.c +++ b/dir.c @@ -19,6 +19,7 @@ #include "name-hash.h" #include "object-file.h" #include "path.h" +#include "path-namespace.h" #include "refs.h" #include "repository.h" #include "wildmatch.h" @@ -77,9 +78,11 @@ struct untracked_cache_preload_task { struct untracked_cache_dir *ucd; char *path; struct stat_data stat_data; + struct object_id exclude_oid; unsigned int was_valid : 1; unsigned int stat_checked : 1; unsigned int stat_matches : 1; + unsigned int exclude_matches : 1; unsigned int update_stat_data : 1; }; @@ -99,6 +102,7 @@ struct untracked_cache_preload { struct untracked_cache_preload_task *tasks; struct untracked_cache_preload_data *data; struct cache_time index_timestamp; + char *exclude_per_dir; size_t nr; int threads; unsigned int dir_flags; @@ -107,6 +111,10 @@ struct untracked_cache_preload { #define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 #define UNTRACKED_CACHE_PRELOAD_COST 1000 +#define UNTRACKED_CACHE_MAX_EXCLUDE_SIZE (1024 * 1024) + +static void invalidate_gitignore(struct untracked_cache *uc, + struct untracked_cache_dir *dir); static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat_data *sd, @@ -127,6 +135,7 @@ static void collect_untracked_cache_preload_tasks( (*tasks)[*nr].ucd = ucd; (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); (*tasks)[*nr].stat_data = ucd->stat_data; + oidcpy(&(*tasks)[*nr].exclude_oid, &ucd->exclude_oid); (*tasks)[*nr].was_valid = ucd->valid; (*nr)++; @@ -168,6 +177,63 @@ static int untracked_cache_auto_preload_worthwhile( UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS; } +static int exclude_path_matches_fd(const char *path, + const struct stat *expected) +{ + struct stat st; + int fd = open_nofollow(path, O_RDONLY); + int ret = fd >= 0 && !fstat(fd, &st) && S_ISREG(st.st_mode) && + path_namespace_stat_equal(expected, &st); + + if (fd >= 0) + close(fd); + return ret; +} + +static int cached_exclude_file_matches( + const struct git_hash_algo *algo, + const char *path, const struct object_id *cached_oid) +{ + struct object_id raw_oid, normalized_oid; + struct stat st, st_after; + char *buf; + size_t size; + int fd, ret = 0; + + fd = open_nofollow(path, O_RDONLY); + if (fd < 0) + return 0; + if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode) || st.st_size < 0 || + st.st_size > UNTRACKED_CACHE_MAX_EXCLUDE_SIZE) + goto out_close; + + size = xsize_t(st.st_size); + buf = xmallocz(size + 1); + if (read_in_full(fd, buf, size) != size) + goto out; + /* Prove both the opened file and its pathname stayed unchanged. */ + if (fstat(fd, &st_after) || + !path_namespace_stat_equal(&st, &st_after) || + !exclude_path_matches_fd(path, &st_after)) + goto out; + + /* add_patterns() may record either the blob or its LF-normalized form. */ + hash_object_file(algo, buf, size, OBJ_BLOB, &raw_oid); + if (oideq(&raw_oid, cached_oid)) { + ret = 1; + goto out; + } + buf[size] = '\n'; + hash_object_file(algo, buf, size + 1, OBJ_BLOB, + &normalized_oid); + ret = oideq(&normalized_oid, cached_oid); +out: + free(buf); +out_close: + close(fd); + return ret; +} + static struct untracked_cache_preload *untracked_cache_preload_start_1( struct index_state *istate, unsigned int dir_flags, int automatic) { @@ -187,6 +253,7 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( preload->uc = uc; preload->root = uc->root; preload->index_timestamp = istate->timestamp; + preload->exclude_per_dir = xstrdup_or_null(uc->exclude_per_dir); preload->dir_flags = dir_flags; collect_untracked_cache_preload_tasks( uc->root, &path, &preload->tasks, &preload->nr, &alloc); @@ -260,6 +327,7 @@ static void *preload_untracked_cache_thread(void *_data) for (i = data->offset; i < data->offset + data->nr; i++) { struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct strbuf exclude_path = STRBUF_INIT; struct stat st; if (!task->was_valid) @@ -273,10 +341,26 @@ static void *preload_untracked_cache_thread(void *_data) if (!match_untracked_dir_stat_racy( &preload->index_timestamp, &task->stat_data, &st)) { task->stat_matches = 1; + } else { + fill_stat_data(&task->stat_data, &st); + task->update_stat_data = 1; continue; } - fill_stat_data(&task->stat_data, &st); - task->update_stat_data = 1; + if (is_null_oid(&task->exclude_oid)) { + task->exclude_matches = 1; + continue; + } + if (!preload->exclude_per_dir) + continue; + if (strcmp(task->path, ".")) + strbuf_addstr(&exclude_path, task->path); + if (exclude_path.len) + strbuf_addch(&exclude_path, '/'); + strbuf_addstr(&exclude_path, preload->exclude_per_dir); + task->exclude_matches = cached_exclude_file_matches( + preload->repo->hash_algo, exclude_path.buf, + &task->exclude_oid); + strbuf_release(&exclude_path); } return NULL; } @@ -314,6 +398,7 @@ static void untracked_cache_preload_free( for (i = 0; i < preload->nr; i++) free(preload->tasks[i].path); free(preload->tasks); + free(preload->exclude_per_dir); free(preload); } @@ -340,6 +425,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, ucd->stat_checked = 0; ucd->stat_matches = 0; + ucd->exclude_matches = 0; /* Invalidation performed after the snapshot always wins. */ if (!task->was_valid || !ucd->valid) { valid = 0; @@ -347,10 +433,14 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, } ucd->stat_checked = task->stat_checked; ucd->stat_matches = task->stat_matches; - if (!task->stat_checked || !task->stat_matches) + ucd->exclude_matches = task->exclude_matches; + if (!task->stat_checked || !task->stat_matches || + !task->exclude_matches) valid = 0; if (task->update_stat_data) ucd->stat_data = task->stat_data; + if (task->stat_matches && !task->exclude_matches) + invalidate_gitignore(uc, ucd); } trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/valid", valid); @@ -2873,7 +2963,8 @@ static int valid_cached_dir(struct dir_struct *dir, if (!(dir->untracked->use_fsmonitor && untracked->valid)) { if (dir->internal.untracked_cache_preloaded && untracked->stat_checked) { - if (!untracked->valid || !untracked->stat_matches) + if (!untracked->valid || !untracked->stat_matches || + !untracked->exclude_matches) return 0; } else { if (lstat(path->len ? path->buf : ".", &st)) { diff --git a/dir.h b/dir.h index 631bdad2b2b845..60b63bbf304782 100644 --- a/dir.h +++ b/dir.h @@ -185,6 +185,7 @@ struct untracked_cache_dir { /* transient results from directory-stat preloading */ unsigned int stat_checked : 1; unsigned int stat_matches : 1; + unsigned int exclude_matches : 1; /* null object ID means this directory does not have .gitignore */ struct object_id exclude_oid; char name[FLEX_ARRAY]; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 9f7c5fee79d639..70cd5b7dfdd2d2 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1046,6 +1046,37 @@ test_expect_success 'automatic preload observes its directory threshold' ' .git/at-threshold.trace ) ' + +test_expect_success 'preload verifies cached per-directory excludes' ' + test_create_repo auto-exclude && + ( + cd auto-exclude && + test_write_lines hide-a >.gitignore && + test_write_lines tracked >tracked && + git add .gitignore tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + test_write_lines a >hide-a && + test_write_lines b >hide-b && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + avoid_racy && + mtime=$(test-tool chmtime --get .gitignore) && + test_write_lines hide-b >.gitignore && + test-tool chmtime =$mtime .gitignore && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain >.git/expect && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/actual.trace" \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "preload_untracked_cache/valid.*value.*0" \ + .git/actual.trace + ) +' + test_expect_success 'status preloads cached-directory validation' ' test_create_repo auto-preload && ( From 19f9c5ce7987898890c19f109c0ce6e7b0965950 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:42:25 -0700 Subject: [PATCH 065/403] fsmonitor--daemon: invalidate globally for worktree hardlink events Darwin FSEvents identifies the pathname associated with a hardlink event, not every name referring to the same inode. Invalidating only that pathname can leave another tracked hardlink trusted after its contents change. Classify the event's absolute path before handling its hardlink flags. For worktree events, enqueue the provider-wide marker introduced by S04/P02 so clients content-check the tracked set. Leave gitdir events in the existing cookie and gitdir handling; otherwise reads of hardlinked object files could repeatedly trigger global invalidation. Add a MACOS,HARDLINKS daemon regression that rejects a marker for a gitdir hardlink, then verifies the marker and correct status for a changed worktree hardlink with its timestamp restored. Signed-off-by: Taylor Blau --- compat/fsmonitor/fsm-listen-darwin.c | 25 ++++++++++++++++- t/t7527-builtin-fsmonitor.sh | 40 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index 43c3a915a0edfc..ffd8392262261b 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -138,6 +138,12 @@ static int ef_is_dropped(const FSEventStreamEventFlags ef) ef & kFSEventStreamEventFlagUserDropped); } +static int ef_is_hardlink(const FSEventStreamEventFlags ef) +{ + return ef & (kFSEventStreamEventFlagItemIsHardlink | + kFSEventStreamEventFlagItemIsLastHardlink); +} + /* * If an `xattr` change is the only reason we received this event, * then silently ignore it. Git doesn't care about xattr's. We @@ -208,6 +214,7 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, const char *slash; char *resolved = NULL; struct strbuf tmp = STRBUF_INIT; + enum fsmonitor_path_type path_type; /* * Build a list of all filesystem changes into a private/local @@ -290,7 +297,23 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, continue; } - switch (fsmonitor_classify_path_absolute(state, path_k)) { + path_type = fsmonitor_classify_path_absolute(state, path_k); + if (ef_is_hardlink(event_flags[k]) && + path_type == IS_WORKDIR_PATH) { + /* + * An event for one name does not prove that all names of the + * inode are in this watch cone. Make the client content-check + * the entire tracked set rather than trusting path-local stats. + */ + if (trace_pass_fl(&trace_fsmonitor)) + log_flags_set(path_k, event_flags[k]); + if (!batch) + batch = fsmonitor_batch__new(); + my_add_path(batch, FSMONITOR_PATH_GLOBAL_INVALIDATE); + continue; + } + + switch (path_type) { case IS_INSIDE_DOT_GIT_WITH_COOKIE_PREFIX: case IS_INSIDE_GITDIR_WITH_COOKIE_PREFIX: diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index de7134af8b5c1a..9b3d9305310848 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -53,6 +53,11 @@ test_lazy_prereq FSMONITOR_WORKS ' return $ret ' +test_lazy_prereq HARDLINKS ' + : >hardlink-a && + ln hardlink-a hardlink-b +' + if ! test_have_prereq FSMONITOR_WORKS then skip_all="filesystem does not deliver fsmonitor events (container/overlayfs?)" @@ -1408,4 +1413,39 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep -q " M dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-try3.out" ' +test_expect_success MACOS,HARDLINKS 'hardlink events invalidate all tracked paths' ' + test_when_finished "git -C hardlink-event fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo hardlink-event && + ( + cd hardlink-event && + printf "AAAA\\n" >tracked && + git add tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + git config core.trustctime false && + git config core.checkStat minimal && + cp -p tracked .git/mtime-reference && + start_daemon --tf "$PWD/../hardlink-event.trace" && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + printf "ignore\n" >.git/hardlink-source && + ln .git/hardlink-source .git/hardlink-alias && + printf "still-ignore\n" >.git/hardlink-alias && + test-tool fsmonitor-client query >.git/gitdir-query && + test_grep ! "^event: //$" ../hardlink-event.trace && + ln tracked alias && + printf "BBBB\\n" >alias && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + touch -r .git/mtime-reference alias && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^event: //$" ../hardlink-event.trace && + git fsmonitor--daemon stop + ) +' + test_done From 28a3f3c4c0e4196f82edf077c38bae1d4894d719 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:06 -0500 Subject: [PATCH 066/403] status: install sidecars against a pinned index A valid sidecar encoding is not sufficient if its named index can be replaced between proof capture and publication. Publishing that record would let a later reader associate one clean result with another index. Expose the existing index-snapshot open and named-path revalidation helpers at their first store consumer. Require a durable index identity on local APFS, a matching index format, entry count, and checksum, and agreement between the held descriptor and the named index. Encode the sidecar under its own lockfile and repeat the index checks before committing that lock. Register the store unit suite with Make and Meson. Its local-APFS tests cover successful installation for SHA-1 and SHA-256 and rejection when the source index is replaced after pinning. Other filesystems fail closed. Signed-off-by: Taylor Blau --- Makefile | 1 + clean-status-sidecar.c | 104 ++++++++++++++++++ clean-status-sidecar.h | 9 ++ t/meson.build | 1 + t/unit-tests/u-clean-status-store.c | 161 ++++++++++++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 t/unit-tests/u-clean-status-store.c diff --git a/Makefile b/Makefile index 974beec198be26..230acf9a86741d 100644 --- a/Makefile +++ b/Makefile @@ -1578,6 +1578,7 @@ CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-clean-status-sidecar +CLAR_TEST_SUITES += u-clean-status-store CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index b850f27e6a6d57..e97873f82fa929 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -1,10 +1,23 @@ #include "git-compat-util.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-index.h" #include "clean-status-sidecar.h" #include "fsmonitor-clean-proof.h" #include "hash-framing.h" +#include "lockfile.h" #include "strbuf.h" +#include "wrapper.h" #define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" +#define CLEAN_STATUS_FILESYSTEM_ID_SIZE 16 + +struct clean_status_filesystem_id { + unsigned char value[CLEAN_STATUS_FILESYSTEM_ID_SIZE]; +}; static int checksum_valid(const void *data, size_t len, const struct git_hash_algo *algo) @@ -126,3 +139,94 @@ int clean_status_sidecar_write(struct strbuf *out, hash_append_checksum(out, algo); return 0; } + +static char *sidecar_path(const char *index_path) +{ + return xstrfmt("%s.csts", index_path); +} + +static int local_apfs_id(int fd MAYBE_UNUSED, + struct clean_status_filesystem_id *id) +{ +#ifdef __APPLE__ + struct statfs fs; +#endif + + memset(id, 0, sizeof(*id)); +#ifdef __APPLE__ + if (fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs") || + sizeof(fs.f_fsid) > sizeof(id->value)) + return -1; + memcpy(id->value, &fs.f_fsid, sizeof(fs.f_fsid)); + return 0; +#else + return -1; +#endif +} + +static int sidecar_matches_snapshot( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct clean_status_filesystem_id fsid; + + return clean_status_identity_is_durable() && + snapshot && snapshot->fd >= 0 && + !local_apfs_id(snapshot->fd, &fsid) && + clean_status_identity_equal(&snapshot->identity, + &sidecar->identity) && + snapshot->version == sidecar->proof.index_version && + snapshot->cache_nr == sidecar->proof.cache_nr && + oideq(&snapshot->checksum, + &sidecar->proof.index_checksum) && + clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo); +} + +int clean_status_sidecar_pin_source( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo, + struct clean_status_index_snapshot *snapshot) +{ + if (clean_status_index_snapshot_open(snapshot, index_path, algo)) + return -1; + if (sidecar_matches_snapshot( + index_path, sidecar, snapshot, algo)) + return 0; + clean_status_index_snapshot_release(snapshot); + return -1; +} + +int clean_status_sidecar_install( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct strbuf encoded = STRBUF_INIT; + struct lock_file lock = LOCK_INIT; + char *path = sidecar_path(index_path); + int sidecar_fd = -1, ret = -1; + + if (!sidecar_matches_snapshot( + index_path, sidecar, snapshot, algo) || + clean_status_sidecar_write(&encoded, sidecar, algo)) + goto done; + sidecar_fd = hold_lock_file_for_update(&lock, path, 0); + if (sidecar_fd < 0 || + (size_t)write_in_full(sidecar_fd, encoded.buf, encoded.len) != + encoded.len || + !sidecar_matches_snapshot( + index_path, sidecar, snapshot, algo) || + commit_lock_file(&lock)) + goto done; + ret = 0; + +done: + if (ret) + rollback_lock_file(&lock); + free(path); + strbuf_release(&encoded); + return ret; +} diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index 1b99434f1f7045..05e03c952022e5 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -4,6 +4,7 @@ #include "clean-status-identity.h" #include "hash.h" +struct clean_status_index_snapshot; struct strbuf; #define CLEAN_STATUS_SIDECAR_VERSION 1 @@ -31,5 +32,13 @@ int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, int clean_status_sidecar_write(struct strbuf *out, const struct clean_status_sidecar *sidecar, const struct git_hash_algo *algo); +int clean_status_sidecar_pin_source( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo, + struct clean_status_index_snapshot *snapshot); +int clean_status_sidecar_install( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo); #endif /* CLEAN_STATUS_SIDECAR_H */ diff --git a/t/meson.build b/t/meson.build index a92a264668bbaa..242c425550a7b5 100644 --- a/t/meson.build +++ b/t/meson.build @@ -8,6 +8,7 @@ clar_test_suites = [ 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-clean-status-sidecar.c', + 'unit-tests/u-clean-status-store.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-store.c b/t/unit-tests/u-clean-status-store.c new file mode 100644 index 00000000000000..ac25bb225e3c2e --- /dev/null +++ b/t/unit-tests/u-clean-status-store.c @@ -0,0 +1,161 @@ +#include "unit-test.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-index.h" +#include "clean-status-sidecar.h" +#include "dir.h" +#include "strbuf.h" + +struct store_fixture { + char *directory; + struct strbuf index_path; + struct clean_status_sidecar sidecar; +}; + +static void fill_oid(struct object_id *oid, unsigned char value, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(hash, value, algo->rawsz); + oidread(oid, hash, algo); +} + +static void fixture_init(struct store_fixture *fixture, + const struct git_hash_algo *algo) +{ + static const unsigned char token[] = "builtin:1:2"; + struct strbuf index = STRBUF_INIT; + struct clean_status_proof *proof; + struct stat st; + const char *tmp = getenv("TMPDIR"); + uint32_t value; + + memset(fixture, 0, sizeof(*fixture)); + fixture->index_path = (struct strbuf)STRBUF_INIT; + fixture->directory = xstrfmt("%s/status-store.XXXXXX", + tmp ? tmp : "/tmp"); + cl_assert(mkdtemp(fixture->directory) != NULL); + strbuf_addf(&fixture->index_path, "%s/index", fixture->directory); + strbuf_addstr(&index, "DIRC"); + put_be32(&value, 4); + strbuf_add(&index, &value, sizeof(value)); + put_be32(&value, 5); + strbuf_add(&index, &value, sizeof(value)); + strbuf_addchars(&index, 2, algo->rawsz); + write_file_buf(fixture->index_path.buf, index.buf, index.len); + cl_assert_equal_i(stat(fixture->index_path.buf, &st), 0); + cl_assert_equal_i(clean_status_identity_from_stat( + &fixture->sidecar.identity, &st), 0); + proof = &fixture->sidecar.proof; + proof->index_version = 4; + proof->cache_nr = 5; + fill_oid(&proof->index_checksum, 2, algo); + fill_oid(&proof->head_tree, 3, algo); + memset(proof->config_hash, 4, algo->rawsz); + memset(proof->repo_hash, 5, algo->rawsz); + fill_oid(&proof->exclude_source_digest, 6, algo); + fixture->sidecar.token = token; + fixture->sidecar.token_len = sizeof(token) - 1; + strbuf_release(&index); +} + +static void fixture_release(struct store_fixture *fixture) +{ + struct strbuf cleanup = STRBUF_INIT; + + strbuf_addstr(&cleanup, fixture->directory); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + strbuf_release(&fixture->index_path); + free(fixture->directory); +} + +static struct strbuf sidecar_path(struct store_fixture *fixture) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addf(&path, "%s.csts", fixture->index_path.buf); + return path; +} + +static void require_local_apfs(const char *path MAYBE_UNUSED) +{ +#ifdef __APPLE__ + struct statfs fs; + int fd = git_open_cloexec(path, O_RDONLY); + + if (fd < 0 || fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs")) { + if (fd >= 0) + close(fd); + cl_skip(); + } + close(fd); +#else + cl_skip(); +#endif +} + +static void assert_installs_against_source(const struct git_hash_algo *algo) +{ + struct clean_status_sidecar parsed; + struct clean_status_index_snapshot snapshot; + struct store_fixture fixture; + struct strbuf encoded = STRBUF_INIT; + struct strbuf path; + + fixture_init(&fixture, algo); + path = sidecar_path(&fixture); + cl_assert_equal_i(clean_status_sidecar_pin_source( + fixture.index_path.buf, &fixture.sidecar, algo, &snapshot), 0); + cl_assert_equal_i(clean_status_sidecar_install( + fixture.index_path.buf, &fixture.sidecar, &snapshot, algo), 0); + cl_assert(strbuf_read_file(&encoded, path.buf, 0) > 0); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, encoded.buf, encoded.len, algo), 0); + cl_assert(clean_status_identity_equal( + &parsed.identity, &fixture.sidecar.identity)); + + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&path); + strbuf_release(&encoded); + fixture_release(&fixture); +} + +void test_clean_status_store__installs_both_object_formats(void) +{ + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + assert_installs_against_source(&hash_algos[GIT_HASH_SHA1]); + assert_installs_against_source(&hash_algos[GIT_HASH_SHA256]); +} + +static void assert_rejects_replaced_source(const struct git_hash_algo *algo) +{ + struct clean_status_index_snapshot snapshot; + struct store_fixture fixture; + struct strbuf replacement = STRBUF_INIT; + + fixture_init(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_pin_source( + fixture.index_path.buf, &fixture.sidecar, algo, &snapshot), 0); + strbuf_addf(&replacement, "%s/replacement", fixture.directory); + write_file(replacement.buf, "replacement"); + cl_assert_equal_i(rename(replacement.buf, fixture.index_path.buf), 0); + cl_assert_equal_i(clean_status_sidecar_install( + fixture.index_path.buf, &fixture.sidecar, &snapshot, algo), -1); + + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&replacement); + fixture_release(&fixture); +} + +void test_clean_status_store__rejects_a_replaced_source_index(void) +{ + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + assert_rejects_replaced_source(&hash_algos[GIT_HASH_SHA1]); + assert_rejects_replaced_source(&hash_algos[GIT_HASH_SHA256]); +} From b1d033b4b3869dd32cb50704e6b8c318869654ee Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:41:39 -0500 Subject: [PATCH 067/403] fsmonitor: ignore startup timeout configuration in daemon run The fsmonitor.startTimeout setting controls how long a client waits for daemon startup; the daemon's run subcommand does not consume it. Nevertheless, daemon configuration parsing validates that setting for every subcommand. A malformed value can consequently kill an implicitly started daemon before it opens its IPC socket. Pass a run-specific configuration flag into the callback and skip startup-timeout parsing only for run. Continue parsing other daemon settings normally, and preserve strict timeout validation for the explicit start subcommand. Add a macOS regression that verifies implicit status still starts the daemon with a malformed timeout while explicit daemon start rejects the same configuration. Signed-off-by: Taylor Blau --- builtin/fsmonitor--daemon.c | 20 +++++++++++++++++--- t/t7527-builtin-fsmonitor.sh | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 4161dd82825b4c..659ce0b2621010 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -42,9 +42,15 @@ static int fsmonitor__start_timeout_sec = 60; #define FSMONITOR__ANNOUNCE_STARTUP "fsmonitor.announcestartup" static int fsmonitor__announce_startup = 0; +struct fsmonitor_config_data { + unsigned int ignore_start_timeout : 1; +}; + static int fsmonitor_config(const char *var, const char *value, const struct config_context *ctx, void *cb) { + struct fsmonitor_config_data *data = cb; + if (!strcmp(var, FSMONITOR__IPC_THREADS)) { int i = git_config_int(var, value, ctx->kvi); if (i < 1) @@ -55,7 +61,12 @@ static int fsmonitor_config(const char *var, const char *value, } if (!strcmp(var, FSMONITOR__START_TIMEOUT)) { - int i = git_config_int(var, value, ctx->kvi); + int i; + + /* The run process does not consume this client-only setting. */ + if (data && data->ignore_start_timeout) + return 0; + i = git_config_int(var, value, ctx->kvi); if (i < 0) return error(_("value of '%s' out of range: %d"), FSMONITOR__START_TIMEOUT, i); @@ -73,7 +84,7 @@ static int fsmonitor_config(const char *var, const char *value, return 0; } - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } /* @@ -1570,6 +1581,9 @@ int cmd_fsmonitor__daemon(int argc, const char *prefix, struct repository *repo UNUSED) { + struct fsmonitor_config_data config_data = { + .ignore_start_timeout = argc > 1 && !strcmp(argv[1], "run"), + }; const char *subcmd; enum fsmonitor_reason reason; int detach_console = 0; @@ -1586,7 +1600,7 @@ int cmd_fsmonitor__daemon(int argc, OPT_END() }; - repo_config(the_repository, fsmonitor_config, NULL); + repo_config(the_repository, fsmonitor_config, &config_data); argc = parse_options(argc, argv, prefix, options, builtin_fsmonitor__daemon_usage, 0); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 9c96c0e3a6aee8..b02df8b5ce5cd3 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1445,4 +1445,22 @@ test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' ) ' +test_expect_success MACOS 'implicit startup treats a bad timeout as best effort' ' + test_create_repo reexec-timeout && + ( + cd reexec-timeout && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + git config fsmonitor.starttimeout nonsense && + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + git config --unset fsmonitor.starttimeout && + git fsmonitor--daemon stop && + git config fsmonitor.starttimeout nonsense && + test_must_fail git fsmonitor--daemon start 2>.git/err && + test_grep "bad numeric config value" .git/err + ) +' + test_done From d1ce571027ebae19732485470c1569f9f9f3887d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:35:52 -0500 Subject: [PATCH 068/403] dir: rescan invalid collapsed UNTR witnesses In collapsed-directory mode, an untracked-cache parent may represent an entire directory by one descendant witness. If that witness becomes invalid or disappears, removing it without inspecting the directory can also hide another unvisited child that remains untracked. Compute cached validity from descendants upward after preload and invalidate collapsed ancestors when a required child proof fails. Before removing a stale collapsed witness, rescan its directory and retain the parent as untracked whenever another child survives. A focused untracked-cache test removes the cached witness while leaving a sibling present and verifies that status still reports the collapsed directory. Signed-off-by: Taylor Blau --- dir.c | 103 +++++++++++++++++++++++++++--- t/t7063-status-untracked-cache.sh | 31 +++++++++ 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/dir.c b/dir.c index 1f923c52396830..cda3dc208cdfd2 100644 --- a/dir.c +++ b/dir.c @@ -115,6 +115,8 @@ struct untracked_cache_preload { static void invalidate_gitignore(struct untracked_cache *uc, struct untracked_cache_dir *dir); +static void invalidate_directory(struct untracked_cache *uc, + struct untracked_cache_dir *dir); static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat_data *sd, @@ -365,6 +367,53 @@ static void *preload_untracked_cache_thread(void *_data) return NULL; } +static int untracked_cache_has_collapsed_child( + const struct untracked_cache_dir *parent, + const struct untracked_cache_dir *child) +{ + size_t i, len = strlen(child->name); + + for (i = 0; i < parent->untracked_nr; i++) { + const char *name = parent->untracked[i]; + + if (strlen(name) == len + 1 && name[len] == '/' && + !strncmp(name, child->name, len)) + return 1; + } + return 0; +} + +static int compute_untracked_cache_valid_recursive( + struct untracked_cache *uc, + struct untracked_cache_dir *ucd, + int invalidate_ancestors) +{ + size_t i; + int local_valid = ucd->valid && ucd->stat_checked && + ucd->stat_matches && ucd->exclude_matches; + int valid = local_valid; + int invalidate_self = !local_valid; + + for (i = 0; i < ucd->dirs_nr; i++) { + int child_valid = compute_untracked_cache_valid_recursive( + uc, ucd->dirs[i], invalidate_ancestors); + + if (!child_valid) { + valid = 0; + if (untracked_cache_has_collapsed_child(ucd, ucd->dirs[i])) + invalidate_self = 1; + } + } + if (invalidate_self && invalidate_ancestors) { + if (!local_valid && ucd->valid && ucd->stat_checked && + ucd->stat_matches && !ucd->exclude_matches) + invalidate_gitignore(uc, ucd); + else + invalidate_directory(uc, ucd); + } + return valid; +} + static void untracked_cache_preload_join( struct untracked_cache_preload *preload) { @@ -409,7 +458,6 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, struct untracked_cache *uc; size_t i; int applied = 0; - int valid = 1; if (!preload) return 0; @@ -427,23 +475,19 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, ucd->stat_matches = 0; ucd->exclude_matches = 0; /* Invalidation performed after the snapshot always wins. */ - if (!task->was_valid || !ucd->valid) { - valid = 0; + if (!task->was_valid || !ucd->valid) continue; - } ucd->stat_checked = task->stat_checked; ucd->stat_matches = task->stat_matches; ucd->exclude_matches = task->exclude_matches; - if (!task->stat_checked || !task->stat_matches || - !task->exclude_matches) - valid = 0; if (task->update_stat_data) ucd->stat_data = task->stat_data; - if (task->stat_matches && !task->exclude_matches) - invalidate_gitignore(uc, ucd); } trace2_data_intmax("dir", istate->repo, - "preload_untracked_cache/valid", valid); + "preload_untracked_cache/valid", + compute_untracked_cache_valid_recursive( + uc, preload->root, + dir_flags & DIR_SHOW_OTHER_DIRECTORIES)); applied = 1; done: trace2_data_intmax("dir", istate->repo, @@ -3063,6 +3107,28 @@ static int read_cached_dir(struct cached_dir *cdir) return -1; } +static void remove_collapsed_untracked_child( + struct untracked_cache *uc, + struct untracked_cache_dir *parent, + const struct untracked_cache_dir *child) +{ + size_t i, len = strlen(child->name); + + for (i = 0; i < parent->untracked_nr; i++) { + char *name = parent->untracked[i]; + + if (strlen(name) != len + 1 || name[len] != '/' || + strncmp(name, child->name, len)) + continue; + free(name); + MOVE_ARRAY(parent->untracked + i, parent->untracked + i + 1, + parent->untracked_nr - i - 1); + parent->untracked_nr--; + uc->dir_invalidated++; + return; + } +} + static void close_cached_dir(struct cached_dir *cdir) { if (cdir->fdir) @@ -3157,6 +3223,23 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, /* check how the file or directory should be treated */ state = treat_path(dir, untracked, &cdir, istate, &path, baselen, pathspec); + if (!cdir.d_name && cdir.ucd && cdir.ucd->check_only && + state < path_untracked && untracked && + untracked_cache_has_collapsed_child(untracked, cdir.ucd) && + (dir->flags & DIR_SHOW_OTHER_DIRECTORIES)) { + /* + * A collapsed directory retains one descendant as its + * untracked witness. Rescan before dropping a stale witness; + * an unvisited sibling may still make the directory untracked. + */ + invalidate_directory(dir->untracked, cdir.ucd); + state = read_directory_recursive( + dir, istate, path.buf, path.len, cdir.ucd, + 1, 0, pathspec); + if (state < path_untracked) + remove_collapsed_untracked_child( + dir->untracked, untracked, cdir.ucd); + } dir->internal.visited_paths++; if (state > dir_state) diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 70cd5b7dfdd2d2..5948a579b5783b 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1077,6 +1077,37 @@ test_expect_success 'preload verifies cached per-directory excludes' ' ) ' +test_expect_success 'recursive preload rescans a vanished collapsed witness' ' + test_create_repo collapsed-witness && + ( + cd collapsed-witness && + test_write_lines "*.ignored" >.gitignore && + git add .gitignore && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + for i in 00 01 + do + mkdir -p "scratch/d$i" && + test_write_lines "$i" >"scratch/d$i/file" || return 1 + done && + echo "?? scratch/" >.git/expect && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + test-tool dump-untracked-cache >.git/cache && + witness=$(sed -n \ + "s#^/scratch/\\(d[0-9][0-9]*\\)/ .*#\\1#p" \ + .git/cache | sed -n 1p) && + test -n "$witness" && + avoid_racy && + rm "scratch/$witness/file" && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual + ) +' + test_expect_success 'status preloads cached-directory validation' ' test_create_repo auto-preload && ( From 4d1d52613d5655d1d8547708662aae75e7d36bbc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:28 -0500 Subject: [PATCH 069/403] fsmonitor: invalidate conversion state for attribute-file events Changing a .gitattributes file can change how tracked content is converted without changing the tracked file's stat data. Invalidating the attribute-file path alone therefore leaves cached conversion state and affected fsmonitor-valid tracked entries falsely reusable. Recognize an exact .gitattributes basename in the refresh callback. Discard cached attribute stacks globally and strongly invalidate only tracked entries beneath that file's parent directory. A root attribute file invalidates all tracked entries; tracked entries in sibling directories remain valid after a nested attribute-file event. Mark the fsmonitor extension changed only when an entry is invalidated. Add Clar unit coverage for unrelated paths, nested-directory scope, root-directory scope, cleared validity, zeroed stat data, and the content-check marker. Register u-fsmonitor-attributes in both Makefile and t/meson.build so the suite is included in both build systems. Signed-off-by: Taylor Blau --- Makefile | 1 + fsmonitor-ll.h | 2 + fsmonitor.c | 34 +++++++++++++ t/meson.build | 1 + t/unit-tests/u-fsmonitor-attributes.c | 72 +++++++++++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 t/unit-tests/u-fsmonitor-attributes.c diff --git a/Makefile b/Makefile index fac3e8879c8377..91090f730b14ae 100644 --- a/Makefile +++ b/Makefile @@ -1538,6 +1538,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate +CLAR_TEST_SUITES += u-fsmonitor-attributes CLAR_TEST_SUITES += u-hash CLAR_TEST_SUITES += u-hashmap CLAR_TEST_SUITES += u-list-objects-filter-options diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index a409b15e68bc51..7f78ad21c8d0b0 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -47,6 +47,8 @@ void tweak_fsmonitor(struct index_state *istate); */ void refresh_fsmonitor(struct index_state *istate); +int fsmonitor_invalidate_attributes_path(struct index_state *istate, + const char *name); /* * Does the received result contain the "trivial" response? */ diff --git a/fsmonitor.c b/fsmonitor.c index 6c119b17bd391e..2fd070b1d5b22a 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -209,6 +209,39 @@ void fsmonitor_invalidate_cache_entry(struct cache_entry *ce) static size_t handle_path_with_trailing_slash( struct index_state *istate, const char *name, int pos); +int fsmonitor_invalidate_attributes_path(struct index_state *istate, + const char *name) +{ + size_t len = strlen(name), base, attr_len = strlen(GITATTRIBUTES_FILE); + size_t invalidated = 0; + unsigned int i; + + while (len && is_dir_sep(name[len - 1])) + len--; + base = len; + while (base && !is_dir_sep(name[base - 1])) + base--; + if (len - base != attr_len || + fspathncmp(name + base, GITATTRIBUTES_FILE, attr_len)) + return 0; + + git_attr_invalidate_all(); + for (i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + + if (base && (ce->ce_namelen < base || + fspathncmp(ce->name, name, base))) + continue; + fsmonitor_invalidate_cache_entry(ce); + invalidated++; + } + if (invalidated) + istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/attributes-scope", base); + return invalidated > 0; +} + /* * Use the name-hash to do a case-insensitive cache-entry lookup with * the pathname and invalidate the cache-entry. @@ -457,6 +490,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) return; } pos = index_name_pos(istate, name, len); + fsmonitor_invalidate_attributes_path(istate, name); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); diff --git a/t/meson.build b/t/meson.build index a25f37d2f5ae7d..c9b35c051949ff 100644 --- a/t/meson.build +++ b/t/meson.build @@ -2,6 +2,7 @@ clar_test_suites = [ 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', + 'unit-tests/u-fsmonitor-attributes.c', 'unit-tests/u-hash.c', 'unit-tests/u-hashmap.c', 'unit-tests/u-list-objects-filter-options.c', diff --git a/t/unit-tests/u-fsmonitor-attributes.c b/t/unit-tests/u-fsmonitor-attributes.c new file mode 100644 index 00000000000000..5a2b7a25f137b3 --- /dev/null +++ b/t/unit-tests/u-fsmonitor-attributes.c @@ -0,0 +1,72 @@ +#include "unit-test.h" +#include "fsmonitor-ll.h" +#include "read-cache-ll.h" +#include "repository.h" + +static void add_entry(struct index_state *istate, size_t pos, + const char *path) +{ + size_t len = strlen(path); + struct cache_entry *ce = make_empty_cache_entry(istate, len); + + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = len; + ce->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE; + memset(&ce->ce_stat_data, 1, sizeof(ce->ce_stat_data)); + memcpy(ce->name, path, len + 1); + istate->cache[pos] = ce; +} + +static int stat_data_is_zero(const struct cache_entry *ce) +{ + struct stat_data zero = { 0 }; + + return !memcmp(&ce->ce_stat_data, &zero, sizeof(zero)); +} + +void test_fsmonitor_attributes__invalidates_only_the_affected_scope(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 3); + istate.cache_alloc = istate.cache_nr = 3; + add_entry(&istate, 0, "a/file"); + add_entry(&istate, 1, "a/sub/file"); + add_entry(&istate, 2, "b/file"); + + cl_assert(!fsmonitor_invalidate_attributes_path( + &istate, "a/not-attributes")); + cl_assert(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, "a/.gitattributes")); + for (size_t i = 0; i < 2; i++) { + cl_assert(!(istate.cache[i]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(!(istate.cache[i]->ce_flags & CE_UPTODATE)); + cl_assert(istate.cache[i]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(stat_data_is_zero(istate.cache[i])); + } + cl_assert(istate.cache[2]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(istate.cache[2]->ce_flags & CE_UPTODATE); + cl_assert(!stat_data_is_zero(istate.cache[2])); + cl_assert(istate.cache_changed & FSMONITOR_CHANGED); + release_index(&istate); +} + +void test_fsmonitor_attributes__root_source_invalidates_every_entry(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_entry(&istate, 0, "a/file"); + add_entry(&istate, 1, "b/file"); + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, ".gitattributes")); + for (size_t i = 0; i < istate.cache_nr; i++) { + cl_assert(!(istate.cache[i]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[i]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + } + release_index(&istate); +} From bb916563ea93e0594f1f7084197155669ab96c45 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:16 -0500 Subject: [PATCH 070/403] status: classify indexes that clean proofs may certify A clean provider response does not establish that every index entry can be represented by an empty status result. Conflicted entries, submodules, sparse entries, intent-to-add entries, and independently trusted stat state can all require ordinary index processing. Introduce a single conservative certifiability check. Require a non-null index checksum and provider-valid ordinary entries. Reject gitlinks, nonzero stages, intent-to-add, skip-worktree, CE_VALID, and unrecognized entry flags while allowing the explicitly supported in-memory flags. Extend the existing index unit suite to exercise accepted ordinary entries and each unsupported entry shape. The classifier does not issue a proof or change status behavior by itself. Signed-off-by: Taylor Blau --- clean-status-index.c | 25 +++++++++++++++ clean-status-index.h | 3 ++ t/unit-tests/u-clean-status-index.c | 49 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/clean-status-index.c b/clean-status-index.c index 0042fb7086ff19..d2303784c9dcd0 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -3,6 +3,7 @@ #include "clean-status-index.h" #include "clean-status-internal.h" #include "hash-framing.h" +#include "object.h" #include "read-cache-ll.h" #include "repository.h" #include "trace2.h" @@ -210,6 +211,30 @@ void clean_status_index_snapshot_release( snapshot->fd = -1; } +int clean_status_index_entries_are_certifiable( + const struct index_state *istate) +{ + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + if (S_ISGITLINK(ce->ce_mode) || ce_stage(ce) || + ce_intent_to_add(ce) || ce_skip_worktree(ce) || + (ce->ce_flags & CE_VALID) || + !(ce->ce_flags & CE_FSMONITOR_VALID) || + (ce->ce_flags & ~(CE_UPTODATE | CE_HASHED | + CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE))) + return 0; + } + return 1; +} + +int clean_status_index_is_certifiable(const struct index_state *istate) +{ + return !is_null_oid(&istate->oid) && + clean_status_index_entries_are_certifiable(istate); +} + static int index_logical_digest(const struct index_state *istate, unsigned int extra_benign_flags, unsigned char *out) diff --git a/clean-status-index.h b/clean-status-index.h index 61b288d3de2e4f..2579b20ee437e1 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -34,6 +34,9 @@ int clean_status_index_snapshot_still_matches_proof_epoch( const struct index_state *istate); void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot); +int clean_status_index_entries_are_certifiable( + const struct index_state *istate); +int clean_status_index_is_certifiable(const struct index_state *istate); int clean_status_index_logical_digest(const struct index_state *istate, unsigned char *out); int clean_status_index_logical_digest_after_status( diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index c13fbc03123299..9f97f683fed803 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -3,6 +3,7 @@ #include "clean-status-index.h" #include "clean-status-internal.h" #include "dir.h" +#include "object.h" #include "read-cache-ll.h" #include "repository.h" #include "strbuf.h" @@ -455,6 +456,54 @@ void test_clean_status_index__binds_the_parsed_source(void) free(worktree); } +void test_clean_status_index__recognizes_certifiable_entries(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct cache_entry *ce; + static const char path[] = "tracked"; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + memset(istate.oid.hash, 1, repo.hash_algo->rawsz); + oid_set_algo(&istate.oid, repo.hash_algo); + ce = make_empty_cache_entry(&istate, sizeof(path) - 1); + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = sizeof(path) - 1; + memcpy(ce->name, path, sizeof(path)); + istate.cache[0] = ce; + + ce->ce_flags = CE_FSMONITOR_VALID; + cl_assert(clean_status_index_is_certifiable(&istate)); + ce->ce_flags |= CE_UPTODATE | CE_HASHED; + cl_assert(clean_status_index_is_certifiable(&istate)); + + oidclr(&istate.oid, repo.hash_algo); + cl_assert(!clean_status_index_is_certifiable(&istate)); + cl_assert(clean_status_index_entries_are_certifiable(&istate)); + memset(istate.oid.hash, 1, repo.hash_algo->rawsz); + + ce->ce_flags = 0; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_VALID; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_UPDATE_IN_BASE; + cl_assert(clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | create_ce_flags(1); + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_INTENT_TO_ADD; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_SKIP_WORKTREE; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_WT_REMOVE; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID; + ce->ce_mode = S_IFGITLINK; + cl_assert(!clean_status_index_is_certifiable(&istate)); + + release_index(&istate); +} + void test_clean_status_index__digests_only_persistent_logical_entries(void) { struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; From 2dff414731c7c7eb6d913e22306a427ebacbf44f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:19:27 -0700 Subject: [PATCH 071/403] fsmonitor: bind daemon queries to the canonical worktree root An fsmonitor socket is selected through the Git directory, so separate worktree paths can reach the same daemon when they share that directory. A client in the second worktree can then consume change history from a daemon that watches the first, incorrectly treating changed files in its own worktree as clean. Hash the canonical worktree path together with its device and inode, plus birth time and generation on Apple platforms. Cache the resulting 64-character SHA-256 identity in the daemon and attach it to every client query. Check the identity before interpreting the requested token; reject missing or mismatched bindings with a cookie-synchronized trivial response that forces the ordinary refresh path. The protocol change must also tolerate a daemon left running by an older Git. Such a daemon treats a bound query as an opaque token and can return a plausible trivial response. After that exact response, query an unbound capability command. If the daemon does not advertise query-v1, serialize replacement through a per-socket restart lock, stop it, and start the invoking Git executable before retrying the bound query. Keep quit, flush, and capability control commands unbound. Bound daemon lifecycle retries, and fail the query instead of trusting history when the root cannot be identified or an incompatible daemon cannot be replaced. Regression tests cover shared-gitdir worktree aliases, replacement of a legacy daemon, and acceptance of a daemon that advertises a capability superset. The replacement test also verifies that the next status neither refreshes tracked entries nor starts another daemon. Signed-off-by: Taylor Blau --- builtin/fsmonitor--daemon.c | 46 ++++++- fsmonitor--daemon.h | 1 + fsmonitor-ipc.c | 251 ++++++++++++++++++++++++++++++++--- fsmonitor-ipc.h | 9 ++ t/helper/test-simple-ipc.c | 54 ++++++++ t/t7527-builtin-fsmonitor.sh | 100 ++++++++++++++ 6 files changed, 437 insertions(+), 24 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 659ce0b2621010..953f68b4fc1185 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -705,18 +705,48 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, int do_trivial = 0; int do_flush = 0; int do_cookie = 0; + int invalid_binding = 0; enum fsmonitor_cookie_item_result cookie_result; + if (strcmp(command, "quit") && + strcmp(command, "flush") && + strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { + const char *identity; + const char *query; + + if (!skip_prefix(command, FSMONITOR_IPC_QUERY_PREFIX, + &identity) || + !(query = strchr(identity, '\n')) || + query - identity != FSMONITOR_IPC_WORKTREE_ID_HEX || + state->worktree_identity.len != FSMONITOR_IPC_WORKTREE_ID_HEX || + memcmp(identity, state->worktree_identity.buf, + FSMONITOR_IPC_WORKTREE_ID_HEX)) { + invalid_binding = 1; + trace2_data_intmax("fsmonitor", the_repository, + "query/worktree-mismatch", 1); + } else { + command = query + 1; + } + } + /* * We expect `command` to be of the form: * - * := quit NUL + * := get-capabilities NUL + * | quit NUL * | flush NUL * | NUL * | NUL */ - if (!strcmp(command, "quit")) { + if (!strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { + static const char capabilities[] = + FSMONITOR_IPC_QUERY_VERSION "\n"; + + return reply(reply_data, capabilities, + sizeof(capabilities) - 1); + + } else if (!strcmp(command, "quit")) { /* * A client has requested over the socket/pipe that the * daemon shutdown. @@ -739,6 +769,11 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, do_flush = 1; do_trivial = 1; + } else if (invalid_binding) { + /* Never trust a token from an unbound or different worktree. */ + do_trivial = 1; + do_cookie = 1; + } else if (!skip_prefix(command, "builtin:", &p)) { /* assume V1 timestamp or garbage */ @@ -1322,6 +1357,12 @@ static int fsmonitor_run_daemon(void) strbuf_init(&state.path_worktree_watch, 0); strbuf_addstr(&state.path_worktree_watch, absolute_path(repo_get_work_tree(the_repository))); + strbuf_init(&state.worktree_identity, 0); + if (fsmonitor_ipc__get_worktree_identity(the_repository, + &state.worktree_identity)) { + err = error(_("could not identify worktree root")); + goto done; + } state.nr_paths_watching = 1; strbuf_init(&state.alias.alias, 0); @@ -1448,6 +1489,7 @@ static int fsmonitor_run_daemon(void) ipc_server_free(state.ipc_server_data); strbuf_release(&state.path_worktree_watch); + strbuf_release(&state.worktree_identity); strbuf_release(&state.path_gitdir_watch); strbuf_release(&state.path_cookie_prefix); strbuf_release(&state.path_ipc); diff --git a/fsmonitor--daemon.h b/fsmonitor--daemon.h index 5cbbec8d940ba7..850188f872b783 100644 --- a/fsmonitor--daemon.h +++ b/fsmonitor--daemon.h @@ -40,6 +40,7 @@ struct fsmonitor_daemon_state { pthread_mutex_t main_lock; struct strbuf path_worktree_watch; + struct strbuf worktree_identity; struct strbuf path_gitdir_watch; struct alias_info alias; int nr_paths_watching; diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 8957091bfccbb2..f6eb03cfd9442f 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -6,6 +6,8 @@ #include "environment.h" #include "exec-cmd.h" #include "gettext.h" +#include "hash.h" +#include "lockfile.h" #include "parse.h" #include "simple-ipc.h" #include "fsmonitor-ipc.h" @@ -14,6 +16,46 @@ #include "strbuf.h" #include "trace2.h" +int fsmonitor_ipc__get_worktree_identity(struct repository *r, + struct strbuf *identity) +{ + static const char hex[] = "0123456789abcdef"; + struct strbuf canonical = STRBUF_INIT; + struct strbuf stable = STRBUF_INIT; + git_SHA256_CTX ctx; + unsigned char hash[GIT_SHA256_RAWSZ]; + struct stat st; + const char *worktree = repo_get_work_tree(r); + int ret = -1; + + if (!worktree || + !strbuf_realpath(&canonical, worktree, 0) || + stat(canonical.buf, &st)) + goto done; + strbuf_addf(&stable, "v1\n%"PRIuMAX":", (uintmax_t)canonical.len); + strbuf_addbuf(&stable, &canonical); + strbuf_addf(&stable, "\n%"PRIuMAX"\n%"PRIuMAX, + (uintmax_t)st.st_dev, (uintmax_t)st.st_ino); +#ifdef __APPLE__ + strbuf_addf(&stable, "\n%"PRIdMAX"\n%ld\n%"PRIu32, + (intmax_t)st.st_birthtimespec.tv_sec, + st.st_birthtimespec.tv_nsec, st.st_gen); +#endif + git_SHA256_Init(&ctx); + git_SHA256_Update(&ctx, stable.buf, stable.len); + git_SHA256_Final(hash, &ctx); + strbuf_reset(identity); + for (size_t i = 0; i < ARRAY_SIZE(hash); i++) { + strbuf_addch(identity, hex[hash[i] >> 4]); + strbuf_addch(identity, hex[hash[i] & 0xf]); + } + ret = 0; +done: + strbuf_release(&stable); + strbuf_release(&canonical); + return ret; +} + #ifndef HAVE_FSMONITOR_DAEMON_BACKEND /* @@ -73,6 +115,7 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) #define FSMONITOR_START_TIMEOUT_KEY "fsmonitor.starttimeout" #define FSMONITOR_START_TIMEOUT_DEFAULT 60 +#define FSMONITOR_RESTART_ATTEMPTS 3 static unsigned int get_start_timeout(void) { @@ -140,44 +183,221 @@ static int spawn_daemon(void) return ret; } +static int try_send_command(const char *command, struct strbuf *answer, + enum ipc_active_state *state_out) +{ + struct ipc_client_connection *connection = NULL; + struct ipc_client_connect_options options + = IPC_CLIENT_CONNECT_OPTIONS_INIT; + enum ipc_active_state state; + int ret = -1; + + strbuf_reset(answer); + options.wait_if_busy = 1; + options.wait_if_not_found = 0; + + state = ipc_client_try_connect(fsmonitor_ipc__get_path(the_repository), + &options, &connection); + if (state == IPC_STATE__LISTENING) { + ret = ipc_client_send_command_to_connection( + connection, command, strlen(command), answer); + ipc_client_close_connection(connection); + } + + if (state_out) + *state_out = state; + return ret; +} + +static int is_trivial_response(const struct strbuf *answer) +{ + const char *nul = memchr(answer->buf, '\0', answer->len); + + return nul && nul != answer->buf && + answer->len == (size_t)(nul - answer->buf) + 3 && + nul[1] == '/' && nul[2] == '\0'; +} + +static int has_capability(const struct strbuf *answer, + const char *capability) +{ + const char *p = answer->buf; + const char *end = answer->buf + answer->len; + size_t capability_len = strlen(capability); + + while (p < end) { + const char *eol = memchr(p, '\n', end - p); + const char *line_end = eol ? eol : end; + + if ((size_t)(line_end - p) == capability_len && + !memcmp(p, capability, capability_len)) + return 1; + if (!eol) + break; + p = eol + 1; + } + return 0; +} + +static int server_supports_bound_queries(void) +{ + struct strbuf answer = STRBUF_INIT; + int ret; + + ret = !try_send_command(FSMONITOR_IPC_CAPABILITY_COMMAND, + &answer, NULL) && + has_capability(&answer, FSMONITOR_IPC_QUERY_VERSION); + strbuf_release(&answer); + return ret; +} + +static int wait_for_daemon_exit(void) +{ + uintmax_t elapsed_ms = 0; + uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; + + while (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (elapsed_ms >= timeout_ms) + return -1; + sleep_millisec(50); + elapsed_ms += 50; + } + return 0; +} + +static int restart_incompatible_daemon(void) +{ + struct strbuf answer = STRBUF_INIT; + struct strbuf lock_path = STRBUF_INIT; + struct lock_file restart_lock = LOCK_INIT; + uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; + long lock_timeout_ms = timeout_ms > LONG_MAX ? + LONG_MAX : (long)timeout_ms; + int have_lock = 0; + int ret = -1; + + /* + * Serialize the re-probe, quit, wait, and spawn sequence. This uses a + * different lock from the one used briefly while binding the socket. + */ + strbuf_addf(&lock_path, "%s.restart", + fsmonitor_ipc__get_path(the_repository)); + if (hold_lock_file_for_update_timeout(&restart_lock, lock_path.buf, + LOCK_NO_DEREF, + lock_timeout_ms) < 0) { + if (server_supports_bound_queries()) + ret = 0; + goto done; + } + have_lock = 1; + + /* Another client may have replaced the daemon while we waited. */ + if (server_supports_bound_queries()) + goto success; + + trace2_data_intmax("fsm_client", NULL, + "query/incompatible-daemon", 1); + if (try_send_command("quit", &answer, NULL)) { + /* + * The connection state describes the failed attempt, not + * necessarily the state after the failure. Re-read it before + * deciding whether there is still a daemon to replace. + */ + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (server_supports_bound_queries()) + ret = 0; + goto done; + } + } + + if (wait_for_daemon_exit()) + goto done; + + /* + * A concurrent client may already have started a replacement. + * The retried bound query will verify its capability if needed. + */ + if (fsmonitor_ipc__get_state() != IPC_STATE__LISTENING && + spawn_daemon()) + goto done; + +success: + ret = 0; + +done: + if (have_lock) + rollback_lock_file(&restart_lock); + strbuf_release(&lock_path); + strbuf_release(&answer); + return ret; +} + int fsmonitor_ipc__send_query(const char *since_token, struct strbuf *answer) { + struct strbuf command = STRBUF_INIT; + struct strbuf identity = STRBUF_INIT; int ret = -1; - int tried_to_spawn = 0; + int lifecycle_attempts = 0; enum ipc_active_state state = IPC_STATE__OTHER_ERROR; struct ipc_client_connection *connection = NULL; struct ipc_client_connect_options options = IPC_CLIENT_CONNECT_OPTIONS_INIT; const char *tok = since_token ? since_token : ""; - size_t tok_len = since_token ? strlen(since_token) : 0; + + trace2_region_enter("fsm_client", "query", NULL); + if (fsmonitor_ipc__get_worktree_identity(the_repository, &identity)) { + trace2_data_intmax("fsm_client", NULL, + "query/worktree-identity-error", 1); + goto done; + } + strbuf_addstr(&command, FSMONITOR_IPC_QUERY_PREFIX); + strbuf_addbuf(&command, &identity); + strbuf_addch(&command, '\n'); + strbuf_addstr(&command, tok); options.wait_if_busy = 1; options.wait_if_not_found = 0; - trace2_region_enter("fsm_client", "query", NULL); trace2_data_string("fsm_client", NULL, "query/command", tok); try_again: + strbuf_reset(answer); state = ipc_client_try_connect(fsmonitor_ipc__get_path(the_repository), &options, &connection); switch (state) { case IPC_STATE__LISTENING: ret = ipc_client_send_command_to_connection( - connection, tok, tok_len, answer); + connection, command.buf, command.len, answer); ipc_client_close_connection(connection); + connection = NULL; trace2_data_intmax("fsm_client", NULL, "query/response-length", answer->len); + if (!ret && is_trivial_response(answer) && + !server_supports_bound_queries()) { + /* + * A daemon predating bound queries treats query-v1 as + * garbage and returns a valid trivial response. Never + * accept that unbound result. Replace the daemon with + * the invoking Git executable and retry instead. + */ + strbuf_reset(answer); + ret = -1; + if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS || + restart_incompatible_daemon()) + goto done; + options.wait_if_not_found = 1; + goto try_again; + } goto done; case IPC_STATE__NOT_LISTENING: case IPC_STATE__PATH_NOT_FOUND: - if (tried_to_spawn) + if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS) goto done; - tried_to_spawn++; if (spawn_daemon()) goto done; @@ -207,6 +427,8 @@ int fsmonitor_ipc__send_query(const char *since_token, done: trace2_region_leave("fsm_client", "query", NULL); + strbuf_release(&identity); + strbuf_release(&command); return ret; } @@ -214,30 +436,15 @@ int fsmonitor_ipc__send_query(const char *since_token, int fsmonitor_ipc__send_command(const char *command, struct strbuf *answer) { - struct ipc_client_connection *connection = NULL; - struct ipc_client_connect_options options - = IPC_CLIENT_CONNECT_OPTIONS_INIT; - int ret; enum ipc_active_state state; const char *c = command ? command : ""; - size_t c_len = command ? strlen(command) : 0; + int ret = try_send_command(c, answer, &state); - strbuf_reset(answer); - - options.wait_if_busy = 1; - options.wait_if_not_found = 0; - - state = ipc_client_try_connect(fsmonitor_ipc__get_path(the_repository), - &options, &connection); if (state != IPC_STATE__LISTENING) { die(_("fsmonitor--daemon is not running")); return -1; } - ret = ipc_client_send_command_to_connection(connection, c, c_len, - answer); - ipc_client_close_connection(connection); - if (ret == -1) { die(_("could not send '%s' command to fsmonitor--daemon"), c); return -1; diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index 8b489da762b047..006ee0750cf134 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -5,6 +5,15 @@ struct repository; +#define FSMONITOR_IPC_QUERY_VERSION "query-v1" +#define FSMONITOR_IPC_QUERY_PREFIX FSMONITOR_IPC_QUERY_VERSION " " +#define FSMONITOR_IPC_CAPABILITY_COMMAND "get-capabilities" +#define FSMONITOR_IPC_WORKTREE_ID_HEX 64 + +/* Hash the canonical worktree root and its stable filesystem identity. */ +int fsmonitor_ipc__get_worktree_identity(struct repository *r, + struct strbuf *identity); + /* * Returns true if built-in file system monitor daemon is defined * for this platform. diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index 442ad6b16f18d8..3be92e4fbd01ca 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -159,9 +159,40 @@ static int app__sendbytes_command(const char *received, size_t received_len, * data is handled properly. */ static int my_app_data = 42; +static int fsmonitor_legacy; +static int fsmonitor_capability_superset; static ipc_server_application_cb test_app_cb; +static int app__fsmonitor_capability_superset( + const char *command, size_t command_len, + ipc_server_reply_cb *reply_cb, + struct ipc_server_reply_data *reply_data) +{ + static const char capability_command[] = "get-capabilities"; + static const char capabilities[] = "query-v1\nquery-v2\n"; + static const char query_prefix[] = "query-v1 "; + static const char token[] = "builtin:test-capable:0"; + const char *query; + size_t query_len; + int ret; + + if (command_len == sizeof(capability_command) - 1 && + !memcmp(command, capability_command, command_len)) + return reply_cb(reply_data, capabilities, + sizeof(capabilities) - 1); + + query = memchr(command, '\n', command_len); + query_len = query ? command_len - (query + 1 - command) : 0; + ret = reply_cb(reply_data, token, sizeof(token)); + if (!ret && + (!starts_with(command, query_prefix) || + query_len != sizeof(token) - 1 || + memcmp(query + 1, token, query_len))) + ret = reply_cb(reply_data, "/", 2); + return ret; +} + /* * This is the "application callback" that sits on top of the * "ipc-server". It completely defines the set of commands supported @@ -201,6 +232,20 @@ static int test_app_cb(void *application_data, return SIMPLE_IPC_QUIT; } + if (fsmonitor_capability_superset) + return app__fsmonitor_capability_superset( + command, command_len, reply_cb, reply_data); + + if (fsmonitor_legacy) { + static const char token[] = "builtin:test-legacy:0"; + int ret; + + ret = reply_cb(reply_data, token, sizeof(token)); + if (!ret && !starts_with(command, "builtin:")) + ret = reply_cb(reply_data, "/", 2); + return ret; + } + if (command_len == 4 && !strncmp(command, "ping", 4)) { const char *answer = "pong"; return reply_cb(reply_data, answer, strlen(answer)); @@ -310,6 +355,10 @@ static int daemon__start_server(void) strvec_push(&cp.args, "run-daemon"); strvec_pushf(&cp.args, "--name=%s", cl_args.path); strvec_pushf(&cp.args, "--threads=%d", cl_args.nr_threads); + if (fsmonitor_legacy) + strvec_push(&cp.args, "--fsmonitor-legacy"); + if (fsmonitor_capability_superset) + strvec_push(&cp.args, "--fsmonitor-capability-superset"); cp.no_stdin = 1; cp.no_stdout = 1; @@ -602,6 +651,11 @@ int cmd__simple_ipc(int argc, const char **argv) OPT_INTEGER(0, "bytecount", &cl_args.bytecount, N_("number of bytes")), OPT_INTEGER(0, "batchsize", &cl_args.batchsize, N_("number of requests per thread")), + OPT_BOOL(0, "fsmonitor-legacy", &fsmonitor_legacy, + N_("emulate the legacy fsmonitor query protocol")), + OPT_BOOL(0, "fsmonitor-capability-superset", + &fsmonitor_capability_superset, + N_("advertise multiple fsmonitor query versions")), /* * The "byte" string here is not marked for translation and diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index b02df8b5ce5cd3..198aca8b1f5e14 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1463,4 +1463,104 @@ test_expect_success MACOS 'implicit startup treats a bad timeout as best effort' ) ' +test_expect_success 'bound query replaces a legacy daemon' ' + test_when_finished \ + "stop_daemon_delete_repo legacy-daemon-upgrade" && + test_create_repo legacy-daemon-upgrade && + ( + cd legacy-daemon-upgrade && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >/dev/null && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 --fsmonitor-legacy && + + GIT_TRACE2_EVENT="$PWD/.git/upgrade.trace" \ + git status >.git/upgrade.out && + test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/upgrade.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep ! "builtin:test-legacy:0" .git/fsmonitor && + test_grep \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/upgrade.trace && + + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git status >.git/warm.out && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <.git/warm.trace && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/warm.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/warm.trace + ) +' + +test_expect_success 'bound query accepts a capability superset' ' + test_when_finished \ + "stop_daemon_delete_repo capability-superset" && + test_create_repo capability-superset && + ( + cd capability-superset && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >/dev/null && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 \ + --fsmonitor-capability-superset && + + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/status.out && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep \ + "^fsmonitor last update builtin:test-capable:0" \ + .git/fsmonitor && + test_grep ! \ + "\"key\":\"query/incompatible-daemon\"" \ + .git/status.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/status.trace + ) +' + +test_expect_success MACOS 'worktree binding rejects same-gitdir aliases' ' + test_when_finished "git -C binding-a fsmonitor--daemon stop 2>/dev/null || :" && + git init --separate-git-dir="$PWD/binding-gitdir" binding-a && + mkdir binding-b && + cp binding-a/.git binding-b/.git && + ( + cd binding-a && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TRACE2_EVENT="$PWD/../binding-daemon.trace" \ + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null + ) && + cp binding-a/tracked binding-b/tracked && + echo changed >>binding-b/tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C binding-b \ + status --porcelain=v2 >binding.expect && + GIT_OPTIONAL_LOCKS=0 git -C binding-b \ + status --porcelain=v2 >binding.actual && + test_cmp binding.expect binding.actual && + test_grep "^1 \.M .* tracked$" binding.actual && + test_grep "\"key\":\"query/worktree-mismatch\",\"value\":\"1\"" \ + binding-daemon.trace && + git -C binding-a fsmonitor--daemon stop +' + test_done From d2da975c1e8974bff31609e30bb1b8d59a82dc40 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:37:23 -0500 Subject: [PATCH 072/403] dir: skip recursively valid empty UNTR subtrees Even after every directory and ignore input has been validated, collapsed-directory traversal still reopens cached subtrees that are known to contain no untracked paths. That walk repeats work the successful preload has already established. Record recursive validation and whether each cached subtree contains untracked output. In collapsed-directory mode, skip reopening a subtree only when its directory, descendants, check-only mode, and ignore inputs remain valid and no cached untracked entry exists. Clear the recursive proof when directory or ignore state is invalidated. The untracked-cache status test verifies that an unchanged empty subtree visits no directories and that a changed descendant still falls back to traversal and reports the new untracked path. Signed-off-by: Taylor Blau --- dir.c | 27 ++++++++++++++++++++++++ dir.h | 3 +++ t/t7063-status-untracked-cache.sh | 34 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/dir.c b/dir.c index cda3dc208cdfd2..6670b5f4ff869b 100644 --- a/dir.c +++ b/dir.c @@ -411,6 +411,7 @@ static int compute_untracked_cache_valid_recursive( else invalidate_directory(uc, ucd); } + ucd->valid_recursive = valid; return valid; } @@ -474,6 +475,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, ucd->stat_checked = 0; ucd->stat_matches = 0; ucd->exclude_matches = 0; + ucd->valid_recursive = 0; /* Invalidation performed after the snapshot always wins. */ if (!task->was_valid || !ucd->valid) continue; @@ -1567,6 +1569,8 @@ static void do_invalidate_gitignore(struct untracked_cache_dir *dir) { int i; dir->valid = 0; + dir->valid_recursive = 0; + dir->has_untracked = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); dir->untracked_nr = 0; @@ -1596,6 +1600,7 @@ static void invalidate_directory(struct untracked_cache *uc, uc->dir_invalidated++; dir->valid = 0; + dir->valid_recursive = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); dir->untracked_nr = 0; @@ -2987,6 +2992,7 @@ static void add_untracked(struct untracked_cache_dir *dir, const char *name) ALLOC_GROW(dir->untracked, dir->untracked_nr + 1, dir->untracked_alloc); dir->untracked[dir->untracked_nr++] = xstrdup(name); + dir->has_untracked = 1; } static int valid_cached_dir(struct dir_struct *dir, @@ -3131,6 +3137,8 @@ static void remove_collapsed_untracked_child( static void close_cached_dir(struct cached_dir *cdir) { + int i; + if (cdir->fdir) closedir(cdir->fdir); /* @@ -3140,6 +3148,12 @@ static void close_cached_dir(struct cached_dir *cdir) if (cdir->untracked) { cdir->untracked->valid = 1; cdir->untracked->recurse = 1; + cdir->untracked->has_untracked = !!cdir->untracked->untracked_nr; + for (i = 0; !cdir->untracked->has_untracked && + i < cdir->untracked->dirs_nr; i++) + cdir->untracked->has_untracked = + cdir->untracked->dirs[i]->recurse && + cdir->untracked->dirs[i]->has_untracked; } } @@ -3211,6 +3225,14 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct strbuf path = STRBUF_INIT; strbuf_add(&path, base, baselen); + if (untracked && dir->internal.untracked_cache_preloaded && + untracked->valid && untracked->valid_recursive && + untracked->check_only == !!check_only && + !untracked->has_untracked && + (dir->flags & DIR_SHOW_OTHER_DIRECTORIES)) { + untracked->recurse = 1; + goto out; + } if (open_cached_dir(&cdir, dir, untracked, istate, &path, check_only)) goto out; @@ -4344,7 +4366,11 @@ static int read_one_dir(struct untracked_cache_dir **untracked_, for (i = 0; i < untracked->dirs_nr; i++) { if (read_one_dir(untracked->dirs + i, rd) < 0) return -1; + if (untracked->dirs[i]->has_untracked) + untracked->has_untracked = 1; } + if (untracked->untracked_nr) + untracked->has_untracked = 1; return 0; } @@ -4486,6 +4512,7 @@ static void invalidate_one_directory(struct untracked_cache *uc, { uc->dir_invalidated++; ucd->valid = 0; + ucd->valid_recursive = 0; for (size_t i = 0; i < ucd->untracked_nr; i++) free(ucd->untracked[i]); ucd->untracked_nr = 0; diff --git a/dir.h b/dir.h index 60b63bbf304782..717e96386ee06c 100644 --- a/dir.h +++ b/dir.h @@ -182,10 +182,13 @@ struct untracked_cache_dir { /* all data except 'dirs' in this struct are good */ unsigned int valid : 1; unsigned int recurse : 1; + /* this subtree contains at least one cached untracked entry */ + unsigned int has_untracked : 1; /* transient results from directory-stat preloading */ unsigned int stat_checked : 1; unsigned int stat_matches : 1; unsigned int exclude_matches : 1; + unsigned int valid_recursive : 1; /* null object ID means this directory does not have .gitignore */ struct object_id exclude_oid; char name[FLEX_ARRAY]; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 5948a579b5783b..1cf25b5088284e 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1077,6 +1077,40 @@ test_expect_success 'preload verifies cached per-directory excludes' ' ) ' +test_expect_success 'recursive preload checks descendant directory mtimes' ' + test_create_repo recursive-preload && + ( + cd recursive-preload && + mkdir -p a/b && + echo tracked >a/b/tracked && + git add a/b/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + avoid_racy && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/pruned.trace" \ + git status --porcelain >.git/pruned && + test_must_be_empty .git/pruned && + test_grep \ + "directories-visited.*value.*0" \ + .git/pruned.trace && + avoid_racy && + echo untracked >a/b/new-untracked && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain >.git/expect && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual + ) +' + test_expect_success 'recursive preload rescans a vanished collapsed witness' ' test_create_repo collapsed-witness && ( From 34483f8324fd5ec102f8a6619d1f8573c3dc565f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:58 -0500 Subject: [PATCH 073/403] fsmonitor: invalidate attributes for matched directory summaries A provider can report a directory move or modification without naming a changed .gitattributes file beneath it. Existing directory handling invalidates tracked entries in the reported cone but can leave cached attribute stacks describing the old conversion rules. Discard cached attribute stacks only after directory handling matches at least one tracked index entry. Record semantic/attributes-cone with the number of matched entries. An unmatched directory keeps its existing case-correction and untracked-path fallback without speculatively flushing attribute state. Extend t/helper/test-read-cache.c to cache an old attribute, process a directory event, and require the new attribute value. Add hook regressions in t/t7519-status-fsmonitor.sh for both an indexed cone and an unmatched directory, including their distinct Trace2 behavior. Signed-off-by: Taylor Blau --- fsmonitor.c | 10 ++++++ t/helper/test-read-cache.c | 45 ++++++++++++++++++++++++ t/t7519-status-fsmonitor.sh | 69 +++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/fsmonitor.c b/fsmonitor.c index 2fd070b1d5b22a..df716a26b85499 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -464,6 +464,16 @@ static size_t handle_path_with_trailing_slash( nr_in_cone++; } + if (nr_in_cone) { + /* + * A matched directory event may stand in for a nested + * attribute-file change. + */ + git_attr_invalidate_all(); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/attributes-cone", nr_in_cone); + } + return nr_in_cone; } diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index f5dae8ecfcc485..c7631a204c8b2a 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -1,9 +1,11 @@ #define USE_THE_REPOSITORY_VARIABLE #include "test-tool.h" +#include "attr.h" #include "config.h" #include "environment.h" #include "fsmonitor.h" +#include "fsmonitor-ll.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" @@ -41,6 +43,45 @@ static int test_fsmonitor_content_recovery(const char *path) return 0; } +static int test_fsmonitor_directory_attributes(void) +{ + struct attr_check *check; + int ret = 1; + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + + check = attr_check_initl("marker", NULL); + git_check_attr(the_repository->index, "tracked-dir/tracked", check); + if (!check->items[0].value || + strcmp(check->items[0].value, "old")) { + error("initial attribute value was not cached"); + goto done; + } + + write_file("tracked-dir/.gitattributes", "tracked marker=new\n"); + /* + * repo_read_index() consumed the normal refresh. Re-arm it after + * caching the pre-event attribute value. + */ + the_repository->index->fsmonitor_has_run_once = 0; + refresh_fsmonitor(the_repository->index); + git_check_attr(the_repository->index, "tracked-dir/tracked", check); + if (!check->items[0].value || + strcmp(check->items[0].value, "new")) { + error("directory event did not invalidate cached attributes"); + goto done; + } + ret = 0; + +done: + attr_check_free(check); + discard_index(the_repository->index); + return ret; +} + int cmd__read_cache(int argc, const char **argv) { int i, cnt = 1; @@ -49,6 +90,10 @@ int cmd__read_cache(int argc, const char **argv) if (argc == 3 && !strcmp(argv[1], "--test-fsmonitor-content-recovery")) return test_fsmonitor_content_recovery(argv[2]); + if (argc == 2 && + !strcmp(argv[1], "--test-fsmonitor-directory-attributes")) + return test_fsmonitor_directory_attributes(); + if (argc > 1 && skip_prefix(argv[1], "--print-and-refresh=", &name)) { argc--; argv++; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index fb2fadc53d5986..691148ae677113 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -657,6 +657,75 @@ test_expect_success 'provider global marker invalidates every tracked entry' ' ) ' +test_expect_success \ + 'directory attribute invalidation requires an indexed cone' ' + test_create_repo directory-attributes && + ( + cd directory-attributes && + mkdir tracked-dir && + test_commit base tracked-dir/tracked && + test_hook --setup fsmonitor-test <<-\EOF && + if test -f .git/report-cone + then + printf "cone-token\0tracked-dir/\0" + elif test -f .git/report-unmatched + then + printf "unmatched-token\0untracked/\0" + else + printf "base-token\0" + fi + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + + > .git/report-cone && + GIT_TRACE2_EVENT="$PWD/.git/cone.trace" \ + GIT_TRACE_FSMONITOR="$PWD/.git/cone.fsm" \ + git status --porcelain=v2 >.git/cone.actual && + test_must_be_empty .git/cone.actual && + test_grep "fsmonitor_refresh_callback.*tracked-dir/" \ + .git/cone.fsm && + test_trace2_data fsmonitor semantic/attributes-cone 1 \ + <.git/cone.trace && + + rm .git/report-cone && + > .git/report-unmatched && + GIT_TRACE2_EVENT="$PWD/.git/unmatched.trace" \ + GIT_TRACE_FSMONITOR="$PWD/.git/unmatched.fsm" \ + git status --porcelain=v2 >.git/unmatched.actual && + test_must_be_empty .git/unmatched.actual && + test_grep "fsmonitor_refresh_callback.*untracked/" \ + .git/unmatched.fsm && + test_grep ! \ + "\"category\":\"fsmonitor\",\"key\":\"semantic/attributes-cone\"" \ + .git/unmatched.trace + ) +' + +test_expect_success 'directory events invalidate cached attributes' ' + test_create_repo directory-attribute-cache && + ( + cd directory-attribute-cache && + mkdir tracked-dir && + test_write_lines "tracked marker=old" \ + >tracked-dir/.gitattributes && + test_write_lines tracked >tracked-dir/tracked && + git add tracked-dir && + git commit -m base && + test_hook --setup fsmonitor-test <<-\EOF && + printf "new-token\0tracked-dir/\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + test-tool read-cache \ + --test-fsmonitor-directory-attributes + ) +' + test_expect_success HARDLINKS,!MINGW,!CYGWIN \ 'multiply-linked files stay fsmonitor-invalid' ' test_when_finished "rm -f hardlink-alias" && From e4f0e5486fda49d08245c62df74d1717004e900c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 02:28:35 -0500 Subject: [PATCH 074/403] status: issue sidecars after a verified full scan An empty status result cannot certify the next invocation unless its tracked and untracked observations, ignore sources, provider token, configuration, repository, HEAD, and named index belong to one completed scan. Publishing a digest before provider-token closure, or reusing cached replacement-ref state, could issue a false clean proof. Retain the standard-exclude digest produced by the complete bulk scan. Keep provider-originated digest state pending until token closure accepts it, and preserve the accepted digest when consuming single-use tracked results. Inspect a fresh, uncached ref store and reject effective replacement refs. Then fingerprint the held local-APFS index and worktree, repository paths, locale, and external attribute state. Issue a sidecar only for the literal, top-level, empty porcelain-v2 command after persistent semantic history, an eligible expanded index, a complete untracked scan, and the HEAD cache tree all agree. Install against the pinned index before rolling back its held index lock; otherwise retain ordinary index-update behavior. Also enable the preceding external-history checkpoint path only for a literal normal status with no pathspec. After the full scan, publish a complete checkpoint for the closed token. A successful save or restore rolls back the acceleration-only index update, preserving another Git implementation's physical index namespace. Optional-lock-free and index-changing commands keep the ordinary path. Add the focused sidecar integration suite and register its source and production code with the relevant Make and Meson builds. Cover prior semantic history, unchanged index contents, exact command shape, rejection of unsupported exact-sidecar inputs, namespace-specific external-history restoration across index re-encoding, and failed checkpoint republication. No early status answer is introduced here. Signed-off-by: Taylor Blau --- Makefile | 1 + builtin/commit.c | 23 ++++ clean-status-sidecar-issue.c | 169 ++++++++++++++++++++++++ clean-status-sidecar.c | 120 +++++++++++++++++ clean-status-sidecar.h | 9 ++ clean-status.h | 7 + dir.c | 31 ++++- dir.h | 1 + meson.build | 1 + preload-index-bulk.c | 18 ++- preload-index-bulk.h | 4 + preload-index.c | 56 +++++++- preload-index.h | 6 + read-cache-ll.h | 6 +- refs.c | 17 +++ refs.h | 6 + replace-object.c | 13 ++ replace-object.h | 7 + t/meson.build | 1 + t/t7508-status.sh | 40 ++++++ t/t7527-builtin-fsmonitor.sh | 6 +- t/t7530-status-clean-sidecar.sh | 227 ++++++++++++++++++++++++++++++++ wt-status.c | 206 ++++++++++++++++++++++++++--- wt-status.h | 13 ++ 24 files changed, 952 insertions(+), 36 deletions(-) create mode 100644 clean-status-sidecar-issue.c create mode 100755 t/t7530-status-clean-sidecar.sh diff --git a/Makefile b/Makefile index 230acf9a86741d..0ee895f3dd106c 100644 --- a/Makefile +++ b/Makefile @@ -1137,6 +1137,7 @@ LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += clean-status-sidecar.o +LIB_OBJS += clean-status-sidecar-issue.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o diff --git a/builtin/commit.c b/builtin/commit.c index 452d56c20a4abd..feff3c8df156d5 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1614,6 +1614,9 @@ struct repository *repo UNUSED) struct clean_status_config_digest clean_digest; unsigned int progress_flag = 0; int fd; + int default_status_command = argc == 1 && (!prefix || !*prefix); + int exact_clean_command = argc == 2 && + !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1696,6 +1699,10 @@ struct repository *repo UNUSED) parse_pathspec(&s.pathspec, 0, PATHSPEC_PREFER_FULL, prefix, argv); + s.allow_clean_status_shortcuts = + default_status_command && !s.pathspec.nr; + if (s.allow_clean_status_shortcuts) + clean_status_enable_external_history(the_repository); if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) @@ -1731,6 +1738,22 @@ struct repository *repo UNUSED) wt_status_collect(&s); + if (exact_clean_command && 0 <= fd && + clean_status_issue_sidecar(&s, &clean_digest, &index_lock)) + fd = -1; + if (0 <= fd) { + int external_restored = + clean_status_external_history_was_restored( + the_repository->index); + int external_saved = + clean_status_save_external_history( + the_repository->index); + + if (external_restored || external_saved) { + rollback_lock_file(&index_lock); + fd = -1; + } + } if (0 <= fd) repo_update_index_if_able(the_repository, &index_lock); diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c new file mode 100644 index 00000000000000..bdf3c058595a27 --- /dev/null +++ b/clean-status-sidecar-issue.c @@ -0,0 +1,169 @@ +#include "git-compat-util.h" +#include "cache-tree.h" +#include "clean-status.h" +#include "clean-status-index.h" +#include "clean-status-internal.h" +#include "clean-status-sidecar.h" +#include "environment.h" +#include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" +#include "fsmonitor-settings.h" +#include "lockfile.h" +#include "object-name.h" +#include "preload-index.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" +#include "wt-status.h" + +static void trace_miss(struct repository *repo, const char *reason) +{ + trace2_data_string("status", repo, "clean-proof/miss", reason); +} + +static int issue_test_barrier(void) +{ + const char *ready = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_READY"); + const char *resume = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_RESUME"); + struct strbuf buf = STRBUF_INIT; + int ret; + + if (!ready && !resume) + return 0; + if (!ready || !resume) + return -1; + write_file(ready, "ready"); + ret = strbuf_read_file(&buf, resume, 1) > 0 ? 0 : -1; + strbuf_release(&buf); + return ret; +} + +static int output_is_certifiable(const struct wt_status *status) +{ + return status->status_format == STATUS_FORMAT_PORCELAIN_V2 && + !status->pathspec.nr && !status->show_branch && + !status->show_stash && !status->show_ignored_mode && + !status->null_termination && !status->verbose && + status->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !status->change.nr && !status->untracked.nr && + !status->ignored.nr; +} + +static int history_is_certifiable(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && + clean_status_has_persistent_fsmonitor_semantic_history(istate) && + clean_status_revalidated_token_matches(istate) && + state->manifest.current_valid && + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL; +} + +static int fsmonitor_state_is_certifiable( + struct repository *repo, const struct index_state *istate) +{ + return !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(repo) == FSMONITOR_MODE_IPC && + !fsmonitor_has_pending_token(istate) && + istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + strlen(istate->fsmonitor_last_update) <= + FSMONITOR_CLEAN_PROOF_TOKEN_MAX && + clean_status_index_is_certifiable(istate); +} + +static int untracked_scan_is_certifiable( + struct wt_status *status, struct object_id *exclude_digest, + struct stat *scanned_worktree) +{ + if (status->untracked_from_preload) + return !preload_index_bulk_standard_excludes_digest( + status->repo->index, exclude_digest, + scanned_worktree); + if (status->untracked_from_token_closure) + return !wt_status_certified_excludes_digest( + status, exclude_digest, scanned_worktree); + return 0; +} + +int clean_status_issue_sidecar( + struct wt_status *status, + const struct clean_status_config_digest *config, + struct lock_file *index_lock) +{ + struct repository *repo = status->repo; + struct index_state *istate = repo->index; + struct clean_status_index_snapshot index = { .fd = -1 }; + struct clean_status_sidecar sidecar = { 0 }; + struct object_id exclude_digest, head_tree; + struct stat scanned_worktree; + unsigned char repo_hash[GIT_MAX_RAWSZ]; + int installed = 0; + + if (!is_lock_file_locked(index_lock) || + !config->finalized || config->filter_configured || + !output_is_certifiable(status)) { + trace_miss(repo, "issue-command-or-output"); + goto done; + } + if (!history_is_certifiable(istate)) { + trace_miss(repo, "issue-coherent-history"); + goto done; + } + if (getenv(INDEX_ENVIRONMENT) || + !fsmonitor_state_is_certifiable(repo, istate) || + !untracked_scan_is_certifiable( + status, &exclude_digest, &scanned_worktree)) { + trace_miss(repo, "issue-scan-or-index-shape"); + goto done; + } + if (issue_test_barrier()) { + trace_miss(repo, "issue-test-barrier"); + goto done; + } + if (!status->attr_source_snapshot || + clean_status_index_snapshot_pin(&index, istate) || + clean_status_repository_fingerprint( + repo, status->attr_source_snapshot, &index, + &scanned_worktree, repo_hash)) { + trace_miss(repo, "issue-pinned-inputs"); + goto done; + } + if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || + !istate->cache_tree || istate->cache_tree->entry_count < 0 || + !oideq(&head_tree, &istate->cache_tree->oid)) { + trace_miss(repo, "issue-head-cache-tree"); + goto done; + } + + sidecar.identity = index.identity; + sidecar.proof.index_version = index.version; + sidecar.proof.cache_nr = index.cache_nr; + oidcpy(&sidecar.proof.index_checksum, &index.checksum); + oidcpy(&sidecar.proof.head_tree, &head_tree); + memcpy(sidecar.proof.config_hash, config->hash, + repo->hash_algo->rawsz); + memcpy(sidecar.proof.repo_hash, repo_hash, + repo->hash_algo->rawsz); + oidcpy(&sidecar.proof.exclude_source_digest, &exclude_digest); + sidecar.token = (const unsigned char *)istate->fsmonitor_last_update; + sidecar.token_len = strlen(istate->fsmonitor_last_update); + + if (clean_status_sidecar_install( + repo->index_file, &sidecar, &index, repo->hash_algo)) { + trace_miss(repo, "issue-sidecar-write"); + goto done; + } + rollback_lock_file(index_lock); + trace2_data_intmax("status", repo, "clean-proof/sidecar", 1); + installed = 1; + +done: + clean_status_index_snapshot_release(&index); + return installed; +} diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index e97873f82fa929..a68d3dc8059367 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -4,12 +4,18 @@ #include #endif +#include "abspath.h" +#include "attr-fingerprint.h" #include "clean-status-index.h" #include "clean-status-sidecar.h" #include "fsmonitor-clean-proof.h" #include "hash-framing.h" #include "lockfile.h" +#include "path.h" +#include "repository.h" +#include "replace-object.h" #include "strbuf.h" +#include "worktree.h" #include "wrapper.h" #define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" @@ -145,6 +151,18 @@ static char *sidecar_path(const char *index_path) return xstrfmt("%s.csts", index_path); } +static int open_nofollow_nonblocking(const char *path, int flags) +{ +#ifdef O_NONBLOCK + return open_nofollow(path, flags | O_NONBLOCK); +#else + (void)path; + (void)flags; + errno = ENOSYS; + return -1; +#endif +} + static int local_apfs_id(int fd MAYBE_UNUSED, struct clean_status_filesystem_id *id) { @@ -230,3 +248,105 @@ int clean_status_sidecar_install( strbuf_release(&encoded); return ret; } + +static int current_worktree_is_main(struct repository *repo) +{ + struct worktree *worktree = get_current_worktree(repo); + int ret = worktree && is_main_worktree(worktree); + + free_worktree(worktree); + return ret; +} + +static int worktree_root_identity( + const struct stat *st, uint64_t *identity MAYBE_UNUSED) +{ + if (!S_ISDIR(st->st_mode)) + return -1; +#ifdef __APPLE__ + identity[0] = st->st_dev; + identity[1] = st->st_ino; + identity[2] = st->st_birthtimespec.tv_sec; + identity[3] = st->st_birthtimespec.tv_nsec; + identity[4] = st->st_gen; + return 0; +#else + return -1; +#endif +} + +int clean_status_repository_fingerprint( + struct repository *repo, + const struct attr_source_snapshot *attrs, + const struct clean_status_index_snapshot *index, + const struct stat *scanned_worktree, + unsigned char *out) +{ + static const char domain[] = "git-clean-status-repository-v1"; + const struct attr_fingerprint *attr_fingerprint = + attr_source_snapshot_fingerprint(attrs); + struct clean_status_filesystem_id index_fsid, worktree_fsid; + struct git_hash_ctx ctx; + struct stat st; + char *worktree = NULL, *gitdir = NULL, *commondir = NULL; + uint64_t root_identity[5]; + uint64_t scanned_root_identity[5]; + uint64_t value; + int worktree_fd = -1, ret = -1; + + if (!attr_fingerprint || attr_fingerprint->sources_present || + !index || index->fd < 0 || !scanned_worktree || + is_bare_repository(repo) || + !repo_get_work_tree(repo) || + !current_worktree_is_main(repo) || + repo_has_replace_refs_uncached(repo)) + goto done; + + worktree = real_pathdup(repo_get_work_tree(repo), 0); + gitdir = real_pathdup(repo_get_git_dir(repo), 0); + commondir = real_pathdup(repo_get_common_dir(repo), 0); + if (!worktree || !gitdir || !commondir) + goto done; + worktree_fd = open_nofollow_nonblocking( + worktree, O_RDONLY | O_CLOEXEC); + if (worktree_fd < 0 || + local_apfs_id(worktree_fd, &worktree_fsid) || + local_apfs_id(index->fd, &index_fsid) || + fstat(worktree_fd, &st) || + worktree_root_identity(&st, root_identity) || + worktree_root_identity( + scanned_worktree, scanned_root_identity) || + memcmp(root_identity, scanned_root_identity, + sizeof(root_identity))) + goto done; + + git_hash_init(&ctx, repo->hash_algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, worktree, strlen(worktree)); + hash_length_delimited(&ctx, gitdir, strlen(gitdir)); + hash_length_delimited(&ctx, commondir, strlen(commondir)); + for (size_t i = 0; i < ARRAY_SIZE(root_identity); i++) { + put_be64(&value, root_identity[i]); + hash_length_delimited(&ctx, &value, sizeof(value)); + } + hash_length_delimited(&ctx, worktree_fsid.value, + sizeof(worktree_fsid.value)); + hash_length_delimited(&ctx, index_fsid.value, + sizeof(index_fsid.value)); + hash_length_delimited(&ctx, attr_fingerprint->content_hash, + repo->hash_algo->rawsz); + hash_optional_cstring(&ctx, setlocale(LC_CTYPE, NULL)); + hash_optional_cstring(&ctx, getenv("LC_ALL")); + hash_optional_cstring(&ctx, getenv("LC_CTYPE")); + hash_optional_cstring(&ctx, getenv("LANG")); + git_hash_final(out, &ctx); + ret = 0; + +done: + if (worktree_fd >= 0) + close(worktree_fd); + free(worktree); + free(gitdir); + free(commondir); + return ret; +} diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index 05e03c952022e5..963d6bccc6b65a 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -5,7 +5,10 @@ #include "hash.h" struct clean_status_index_snapshot; +struct attr_source_snapshot; +struct repository; struct strbuf; +struct stat; #define CLEAN_STATUS_SIDECAR_VERSION 1 @@ -40,5 +43,11 @@ int clean_status_sidecar_install( const char *index_path, const struct clean_status_sidecar *sidecar, const struct clean_status_index_snapshot *snapshot, const struct git_hash_algo *algo); +int clean_status_repository_fingerprint( + struct repository *repo, + const struct attr_source_snapshot *attrs, + const struct clean_status_index_snapshot *index, + const struct stat *scanned_worktree, + unsigned char *out); #endif /* CLEAN_STATUS_SIDECAR_H */ diff --git a/clean-status.h b/clean-status.h index 1cbfd1e0329456..f3db36e3ea21ff 100644 --- a/clean-status.h +++ b/clean-status.h @@ -6,9 +6,11 @@ struct index_state; struct attr_source_snapshot; struct clean_status_proof_epoch; +struct lock_file; struct repository; struct stat; struct strbuf; +struct wt_status; enum clean_status_attr_change { CLEAN_STATUS_ATTR_CONTENT_CHANGED = 1 << 0, @@ -79,6 +81,11 @@ int clean_status_retain_source_index_fd(struct index_state *istate, int fd, int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st); +int clean_status_issue_sidecar( + struct wt_status *status, + const struct clean_status_config_digest *config, + struct lock_file *index_lock); + int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); void clean_status_prepare_fsmonitor_config(struct index_state *istate); diff --git a/dir.c b/dir.c index 4e2e474e083871..c1057362ea573f 100644 --- a/dir.c +++ b/dir.c @@ -933,6 +933,8 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct index_state *istate, const char *path, int len, struct untracked_cache_dir *untracked, int check_only, int stop_at_first_file, const struct pathspec *pathspec); +static int resolve_dtype_with_error(int dtype, struct index_state *istate, + const char *path, int len, int *failed); static int resolve_dtype(int dtype, struct index_state *istate, const char *path, int len); struct dirent *readdir_skip_dot_and_dotdot(DIR *dirp) @@ -3296,6 +3298,12 @@ unsigned char get_dtype(struct dirent *e, struct strbuf *path, static int resolve_dtype(int dtype, struct index_state *istate, const char *path, int len) +{ + return resolve_dtype_with_error(dtype, istate, path, len, NULL); +} + +static int resolve_dtype_with_error(int dtype, struct index_state *istate, + const char *path, int len, int *failed) { struct stat st; @@ -3304,8 +3312,11 @@ static int resolve_dtype(int dtype, struct index_state *istate, dtype = get_index_dtype(istate, path, len); if (dtype != DT_UNKNOWN) return dtype; - if (lstat(path, &st)) + if (lstat(path, &st)) { + if (failed && !is_missing_file_error(errno)) + *failed = 1; return dtype; + } if (S_ISREG(st.st_mode)) return DT_REG; if (S_ISDIR(st.st_mode)) @@ -3361,6 +3372,7 @@ static enum path_treatment treat_path(struct dir_struct *dir, const struct pathspec *pathspec) { int has_path_in_index, dtype, excluded; + int dtype_failed = 0; if (!cdir->d_name) return treat_path_fast(dir, cdir, istate, path, @@ -3372,7 +3384,11 @@ static enum path_treatment treat_path(struct dir_struct *dir, if (simplify_away(path->buf, path->len, pathspec)) return path_none; - dtype = resolve_dtype(cdir->d_type, istate, path->buf, path->len); + dtype = resolve_dtype_with_error( + cdir->d_type, istate, path->buf, path->len, + &dtype_failed); + if (dtype_failed) + dir->internal.traversal_failed = 1; /* Always exclude indexed files */ has_path_in_index = !!index_file_exists(istate, path->buf, path->len, @@ -3523,8 +3539,10 @@ static int open_cached_dir(struct cached_dir *cdir, return 0; c_path = path->len ? path->buf : "."; cdir->fdir = opendir(c_path); - if (!cdir->fdir) + if (!cdir->fdir) { + dir->internal.traversal_failed = 1; warning_errno(_("could not open directory '%s'"), c_path); + } if (dir->untracked) { invalidate_directory(dir->untracked, untracked); dir->untracked->dir_opened++; @@ -3534,13 +3552,16 @@ static int open_cached_dir(struct cached_dir *cdir, return 0; } -static int read_cached_dir(struct cached_dir *cdir) +static int read_cached_dir(struct cached_dir *cdir, struct dir_struct *dir) { struct dirent *de; if (cdir->fdir) { + errno = 0; de = readdir_skip_dot_and_dotdot(cdir->fdir); if (!de) { + if (errno) + dir->internal.traversal_failed = 1; cdir->d_name = NULL; cdir->d_type = DT_UNKNOWN; return -1; @@ -3698,7 +3719,7 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, if (untracked) untracked->check_only = !!check_only; - while (!read_cached_dir(&cdir)) { + while (!read_cached_dir(&cdir, dir)) { /* check how the file or directory should be treated */ state = treat_path(dir, untracked, &cdir, istate, &path, baselen, pathspec); diff --git a/dir.h b/dir.h index 23eed870a0e235..088e06c1ada4d8 100644 --- a/dir.h +++ b/dir.h @@ -365,6 +365,7 @@ struct dir_struct { unsigned visited_paths; unsigned visited_directories; unsigned untracked_cache_preloaded : 1; + unsigned traversal_failed : 1; /* * Optional borrowed proof that covers every exclusion source diff --git a/meson.build b/meson.build index f9170fb500a250..0d2ab0d178df09 100644 --- a/meson.build +++ b/meson.build @@ -342,6 +342,7 @@ libgit_sources = [ 'clean-status-index.c', 'clean-status-manifest.c', 'clean-status-sidecar.c', + 'clean-status-sidecar-issue.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 5756df1faceb90..9aa15920b530b0 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -186,9 +186,11 @@ int preload_bulk_collect(struct index_state *istate, int threads, .untracked = STRING_LIST_INIT_DUP, }; struct preload_bulk_run_result run_result = { 0 }; + struct object_id standard_excludes_digest; struct stat root_stat; const char *start_error, *finish_error = NULL; const char *untracked_reason = NULL; + int standard_excludes_digest_valid = 0; int scan_error = -1; int clean; @@ -239,7 +241,7 @@ int preload_bulk_collect(struct index_state *istate, int threads, CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); - if (!start_error && scan.proof_epoch && + if (!start_error && (scan.proof_epoch || scan.collect_untracked) && (scan.root_fd < 0 || fstat(scan.root_fd, &root_stat))) start_error = "root-stat"; if (!start_error && scan.proof_epoch) @@ -251,6 +253,11 @@ int preload_bulk_collect(struct index_state *istate, int threads, exclude_dir.internal.exclude_source_proof = exclude_proof; setup_standard_excludes(&exclude_dir); + standard_excludes_digest_valid = + !exclude_source_proof_digest( + exclude_proof, + istate->repo->hash_algo, + &standard_excludes_digest); } scan_error = preload_bulk_run_scan(&scan, &run_result); if (!scan_error) @@ -264,7 +271,8 @@ int preload_bulk_collect(struct index_state *istate, int threads, "index", "preload/bulk_excludes", istate->repo); exclude_proof_valid = exclude_source_proof_validate(exclude_proof); - if (!exclude_proof_valid) { + if (!standard_excludes_digest_valid || + !exclude_proof_valid) { run_result.untracked_complete = 0; untracked_reason = "exclude-race"; } @@ -310,6 +318,12 @@ int preload_bulk_collect(struct index_state *istate, int threads, result->can_skip_unseen_preload = scan.can_skip_unseen_preload; result->untracked_complete = run_result.untracked_complete; + if (result->untracked_complete) { + result->standard_excludes_digest_valid = 1; + oidcpy(&result->standard_excludes_digest, + &standard_excludes_digest); + result->scanned_worktree = root_stat; + } scan.tracked_state = NULL; scan.stat_updates = NULL; scan.stat_updates_nr = 0; diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 3a7d0ef84c1c05..ff5583438fd07a 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -2,6 +2,7 @@ #define PRELOAD_INDEX_BULK_H #include "git-compat-util.h" +#include "hash.h" #include "preload-index.h" #include "statinfo.h" #include "strbuf.h" @@ -137,6 +138,9 @@ struct preload_bulk_result { unsigned can_skip_unseen_preload : 1; struct string_list untracked; unsigned untracked_complete : 1; + unsigned standard_excludes_digest_valid : 1; + struct object_id standard_excludes_digest; + struct stat scanned_worktree; }; struct preload_bulk_stat_update { diff --git a/preload-index.c b/preload-index.c index a82063e8fd4149..892f1204676829 100644 --- a/preload-index.c +++ b/preload-index.c @@ -134,7 +134,10 @@ struct preload_bulk_pending { unsigned char *tracked_state; struct preload_bulk_stat_update *stat_updates; size_t stat_updates_nr; + struct object_id standard_excludes_digest; + struct stat scanned_worktree; unsigned provider : 1; + unsigned standard_excludes_digest_valid : 1; }; static int stat_data_is_zero(const struct stat_data *sd) @@ -372,6 +375,13 @@ static void preload_bulk_try(struct index_state *index, result.stat_updates_nr = 0; } } + if (result.standard_excludes_digest_valid) { + pending->provider = provider; + pending->standard_excludes_digest_valid = 1; + oidcpy(&pending->standard_excludes_digest, + &result.standard_excludes_digest); + pending->scanned_worktree = result.scanned_worktree; + } if (result.untracked_complete && index->preload_untracked) { *index->preload_untracked = result.untracked; index->preload_untracked_complete = 1; @@ -394,7 +404,19 @@ static void preload_bulk_finish_state(struct index_state *index, index->preload_bulk_stat_updates_nr = pending->stat_updates_nr; index->preload_bulk_provider_pending = pending->provider; - memset(pending, 0, sizeof(*pending)); + pending->tracked_state = NULL; + pending->stat_updates = NULL; + pending->stat_updates_nr = 0; + } + if (pending->standard_excludes_digest_valid) { + oidcpy(&index->preload_bulk_standard_excludes_digest, + &pending->standard_excludes_digest); + index->preload_bulk_scanned_worktree = + pending->scanned_worktree; + if (pending->provider) + index->preload_bulk_excludes_digest_pending = 1; + else + index->preload_bulk_excludes_digest_valid = 1; } free(pending->tracked_state); free(pending->stat_updates); @@ -410,13 +432,22 @@ static int compare_stat_update(const void *va, const void *vb) } #endif -void preload_index_bulk_result_clear(struct index_state *index) +void preload_index_bulk_result_consume(struct index_state *index) { FREE_AND_NULL(index->preload_bulk_tracked_state); FREE_AND_NULL(index->preload_bulk_stat_updates); index->preload_bulk_tracked_nr = 0; index->preload_bulk_stat_updates_nr = 0; index->preload_bulk_provider_pending = 0; + index->preload_bulk_excludes_digest_pending = 0; +} + +void preload_index_bulk_result_clear(struct index_state *index) +{ + preload_index_bulk_result_consume(index); + index->preload_bulk_excludes_digest_valid = 0; + oidclr(&index->preload_bulk_standard_excludes_digest, + index->repo->hash_algo); } int preload_index_bulk_can_close_provider(struct index_state *index) @@ -446,8 +477,11 @@ int preload_index_bulk_result_accept(struct index_state *index) size_t update_nr = 0; int applied = 0; - if (!index->preload_bulk_provider_pending) + if (!index->preload_bulk_provider_pending && + !index->preload_bulk_excludes_digest_pending) return 0; + if (!index->preload_bulk_provider_pending) + goto accept_digest; if (!index->preload_bulk_tracked_state || index->preload_bulk_tracked_nr != index->cache_nr) return -1; @@ -494,6 +528,11 @@ int preload_index_bulk_result_accept(struct index_state *index) FREE_AND_NULL(index->preload_bulk_stat_updates); index->preload_bulk_stat_updates_nr = 0; index->preload_bulk_provider_pending = 0; +accept_digest: + if (index->preload_bulk_excludes_digest_pending) { + index->preload_bulk_excludes_digest_pending = 0; + index->preload_bulk_excludes_digest_valid = 1; + } trace2_data_intmax("index", index->repo, "preload/bulk_provider_applied", applied); #else @@ -502,6 +541,17 @@ int preload_index_bulk_result_accept(struct index_state *index) return 0; } +int preload_index_bulk_standard_excludes_digest( + const struct index_state *index, struct object_id *digest, + struct stat *scanned_worktree) +{ + if (!index->preload_bulk_excludes_digest_valid) + return -1; + oidcpy(digest, &index->preload_bulk_standard_excludes_digest); + *scanned_worktree = index->preload_bulk_scanned_worktree; + return 0; +} + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags) diff --git a/preload-index.h b/preload-index.h index 7f7fdcca28acb2..87e6d0b89276a7 100644 --- a/preload-index.h +++ b/preload-index.h @@ -2,8 +2,10 @@ #define PRELOAD_INDEX_H struct index_state; +struct object_id; struct pathspec; struct repository; +struct stat; enum preload_bulk_tracked_state { PRELOAD_BULK_TRACKED_UNSEEN = 0, @@ -21,7 +23,11 @@ int repo_read_index_preload(struct repository *, const struct pathspec *pathspec, unsigned refresh_flags); void preload_index_bulk_result_clear(struct index_state *index); +void preload_index_bulk_result_consume(struct index_state *index); int preload_index_bulk_can_close_provider(struct index_state *index); int preload_index_bulk_result_accept(struct index_state *index); +int preload_index_bulk_standard_excludes_digest( + const struct index_state *index, struct object_id *digest, + struct stat *scanned_worktree); #endif /* PRELOAD_INDEX_H */ diff --git a/read-cache-ll.h b/read-cache-ll.h index 5a50c0d6dd4bbf..40be30c9bda066 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -195,7 +195,9 @@ struct index_state { fsmonitor_untracked_extension_invalid : 1, fsmonitor_pending_token_from_provider : 1, preload_untracked_complete : 1, - preload_bulk_provider_pending : 1; + preload_bulk_provider_pending : 1, + preload_bulk_excludes_digest_pending : 1, + preload_bulk_excludes_digest_valid : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; @@ -205,6 +207,8 @@ struct index_state { size_t preload_bulk_tracked_nr; struct preload_bulk_stat_update *preload_bulk_stat_updates; size_t preload_bulk_stat_updates_nr; + struct object_id preload_bulk_standard_excludes_digest; + struct stat preload_bulk_scanned_worktree; /* Borrowed only while refresh_index() performs a provider scan. */ struct clean_status_proof_epoch *preload_bulk_proof_epoch; /* Borrowed for the duration of preload_index(). */ diff --git a/refs.c b/refs.c index 92d5df5b71fa4b..845a5fd3f7587a 100644 --- a/refs.c +++ b/refs.c @@ -2350,6 +2350,23 @@ static struct ref_store *ref_store_init(struct repository *repo, return refs; } +int refs_for_each_replace_ref_uncached(struct repository *repo, + refs_for_each_cb cb, void *cb_data) +{ + struct ref_store *refs; + int ret; + + if (!repo->gitdir) + BUG("attempting to get uncached refs outside of repository"); + + refs = ref_store_init(repo, repo->ref_storage_format, repo->gitdir, + REF_STORE_READ); + ret = refs_for_each_replace_ref(refs, cb, cb_data); + ref_store_release(refs); + free(refs); + return ret; +} + void ref_store_release(struct ref_store *ref_store) { ref_store->be->release(ref_store); diff --git a/refs.h b/refs.h index 9979446d15fd3b..1f80233c5fc472 100644 --- a/refs.h +++ b/refs.h @@ -520,6 +520,12 @@ int refs_for_each_remote_ref(struct ref_store *refs, refs_for_each_cb fn, void *cb_data); int refs_for_each_replace_ref(struct ref_store *refs, refs_for_each_cb fn, void *cb_data); +/* + * Iterate replacement refs through a fresh read-only ref store, without + * consulting caches held by the repository's main ref store. + */ +int refs_for_each_replace_ref_uncached(struct repository *repo, + refs_for_each_cb fn, void *cb_data); /** * Iterate all refs in "prefixes" by partitioning prefixes into disjoint sets diff --git a/replace-object.c b/replace-object.c index 03d0f1f083bed9..29f7c4903e20de 100644 --- a/replace-object.c +++ b/replace-object.c @@ -107,3 +107,16 @@ int replace_refs_enabled(struct repository *r) /* repository has no objects or refs. */ return 0; } + +static int has_replace_ref(const struct reference *ref UNUSED, + void *data UNUSED) +{ + return 1; +} + +int repo_has_replace_refs_uncached(struct repository *r) +{ + if (!replace_refs_enabled(r)) + return 0; + return refs_for_each_replace_ref_uncached(r, has_replace_ref, NULL) != 0; +} diff --git a/replace-object.h b/replace-object.h index 4c9f2a2383d577..595b4598e7e629 100644 --- a/replace-object.h +++ b/replace-object.h @@ -31,6 +31,13 @@ const struct object_id *do_lookup_replace_object(struct repository *r, */ int replace_refs_enabled(struct repository *r); +/* + * Return whether the repository currently has any replacement objects that + * would be honored by lookup_replace_object(). Do not consult the cached + * replacement map. + */ +int repo_has_replace_refs_uncached(struct repository *r); + /* * If object sha1 should be replaced, return the replacement object's * name (replaced recursively, if necessary). The return value is diff --git a/t/meson.build b/t/meson.build index 242c425550a7b5..0f52f5637b3066 100644 --- a/t/meson.build +++ b/t/meson.build @@ -961,6 +961,7 @@ integration_tests = [ 't7527-builtin-fsmonitor.sh', 't7528-signed-commit-ssh.sh', 't7529-preload-index-apfs.sh', + 't7530-status-clean-sidecar.sh', 't7531-semantic-verify.sh', 't7532-preload-index-linux.sh', 't7600-merge.sh', diff --git a/t/t7508-status.sh b/t/t7508-status.sh index 0fd7c79911572e..8059c64940f165 100755 --- a/t/t7508-status.sh +++ b/t/t7508-status.sh @@ -1789,4 +1789,44 @@ test_expect_success EXPENSIVE,SIZE_T_IS_64BIT 'status does not re-read unchanged ) ' +test_expect_success 'status uses only a matching effective cache-tree' ' + test_when_finished "rm -rf cache-tree-status" && + test_create_repo cache-tree-status && + ( + cd cache-tree-status && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + git add tracked && + git commit -m base && + + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status >.git/clean && + test_grep "nothing to commit, working tree clean" \ + .git/clean && + test_trace2_data status index/cache-tree-match 1 \ + <.git/clean.trace && + + test_write_lines staged >tracked && + git add tracked && + git write-tree >.git/staged-tree && + GIT_TRACE2_EVENT="$PWD/.git/staged.trace" \ + git status >.git/staged && + test_grep "Changes to be committed:" .git/staged && + test_grep "modified:.*tracked" .git/staged && + ! test_trace2_data status index/cache-tree-match 1 \ + <.git/staged.trace && + + replacement_tree=$(cat .git/staged-tree) && + git reset --hard HEAD && + head_tree=$(git rev-parse HEAD^{tree}) && + git replace "$head_tree" "$replacement_tree" && + GIT_TRACE2_EVENT="$PWD/.git/replaced.trace" \ + git status >.git/replaced && + test_grep "Changes to be committed:" .git/replaced && + test_grep "modified:.*tracked" .git/replaced && + ! test_trace2_data status index/cache-tree-match 1 \ + <.git/replaced.trace + ) +' + test_done diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 7587626dc0cd7d..81f8bf59c6a896 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1582,10 +1582,8 @@ test_expect_success 'bound query accepts a capability superset' ' GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status >.git/status.out && - test-tool dump-fsmonitor >.git/fsmonitor && - test_grep \ - "^fsmonitor last update builtin:test-capable:0" \ - .git/fsmonitor && + test_trace2_data fsm_client query/command \ + "builtin:test-capable:0" <.git/status.trace && test_grep ! \ "\"key\":\"query/incompatible-daemon\"" \ .git/status.trace && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh new file mode 100755 index 00000000000000..e3197d42ca3925 --- /dev/null +++ b/t/t7530-status-clean-sidecar.sh @@ -0,0 +1,227 @@ +#!/bin/sh + +test_description='exact clean status sidecars' + +. ./test-lib.sh + +test_lazy_prereq LOCAL_APFS ' + test_have_prereq MACOS && + /bin/df -t apfs "$TRASH_DIRECTORY" >/dev/null +' + +if ! test_have_prereq FSMONITOR_DAEMON,LOCAL_APFS,MACOS +then + skip_all='clean status sidecars require local APFS and the macOS fsmonitor daemon' + test_done +fi + +test_lazy_prereq DURABLE_FSMONITOR ' + test_create_repo durable-fsmonitor-probe || return 1 + ( + cd durable-fsmonitor-probe && + test_commit base tracked && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git status --porcelain=v2 >/dev/null && + test-tool dump-fsmonitor >token && + grep "^fsmonitor last update builtin:" token + result=$? + git fsmonitor--daemon stop >/dev/null 2>&1 || : + exit $result + ) +' + +stop_daemon () { + git -C "$1" fsmonitor--daemon stop 2>/dev/null || : +} + +setup_repo () { + repo=$1 && + test_create_repo "$repo" && + test_commit -C "$repo" base tracked && + test-tool chmtime -120 "$repo/tracked" && + git -C "$repo" update-index --refresh && + git -C "$repo" config core.fsmonitor true && + git -C "$repo" fsmonitor--daemon start --start-timeout=10 +} + +bulk_status () { + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + git "$@" +} + +prime_semantic_history () { + repo=$1 && + bulk_status -C "$repo" status --porcelain=2 >actual.1 && + test_must_be_empty actual.1 && + bulk_status -C "$repo" status --porcelain=2 >actual.2 && + test_must_be_empty actual.2 && + test_grep FSCF "$repo/.git/index" +} + +test_expect_success DURABLE_FSMONITOR \ + 'exact clean status installs a sidecar without rewriting the index' ' + test_when_finished "stop_daemon sidecar-issue" && + setup_repo sidecar-issue && + test_env GIT_TRACE2_EVENT="$PWD/first-scan.trace" \ + bulk_status -C sidecar-issue status --porcelain=v2 \ + >actual.first && + test_must_be_empty actual.first && + test_path_is_missing sidecar-issue/.git/index.csts && + test_grep "\"value\":\"issue-coherent-history\"" first-scan.trace && + + prime_semantic_history sidecar-issue && + git -C sidecar-issue config core.autocrlf false && + cp sidecar-issue/.git/index index.before && + + test_env GIT_TRACE2_EVENT="$PWD/issue.trace" \ + bulk_status -C sidecar-issue status --porcelain=v2 >actual && + test_must_be_empty actual && + test_cmp index.before sidecar-issue/.git/index && + test_path_is_file sidecar-issue/.git/index.csts && + test_grep \ + "\"key\":\"preload/bulk_untracked_complete\",\"value\":\"1\"" \ + issue.trace && + test_grep "\"key\":\"preload/bulk_provider_applied\"" issue.trace && + test_grep "\"key\":\"clean-proof/sidecar\"" issue.trace && + test_grep ! "\"label\":\"do_write_index\"" issue.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'only an exact empty output installs a sidecar' ' + test_when_finished "stop_daemon sidecar-shape" && + setup_repo sidecar-shape && + prime_semantic_history sidecar-shape && + + bulk_status -C sidecar-shape status --porcelain=2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-shape/.git/index.csts && + + bulk_status -C sidecar-shape status --porcelain=v2 --branch >actual && + test_grep "^# branch.oid " actual && + test_path_is_missing sidecar-shape/.git/index.csts && + + echo changed >sidecar-shape/tracked && + bulk_status -C sidecar-shape status --porcelain=v2 >actual && + test_grep "^1 .M " actual && + test_path_is_missing sidecar-shape/.git/index.csts +' + +test_expect_success DURABLE_FSMONITOR \ + 'external attributes, untracked cache, and alternate indexes are rejected' ' + test_when_finished "stop_daemon sidecar-inputs" && + setup_repo sidecar-inputs && + prime_semantic_history sidecar-inputs && + + test_write_lines "tracked -text" \ + >sidecar-inputs/.git/info/attributes && + bulk_status -C sidecar-inputs status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-inputs/.git/index.csts && + + rm sidecar-inputs/.git/info/attributes && + git -C sidecar-inputs config core.untrackedCache true && + bulk_status -C sidecar-inputs status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-inputs/.git/index.csts && + + cp sidecar-inputs/.git/index sidecar-inputs/.git/alternate-index && + test_env GIT_INDEX_FILE="$PWD/sidecar-inputs/.git/alternate-index" \ + bulk_status -C sidecar-inputs status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-inputs/.git/alternate-index.csts +' + +test_expect_success DURABLE_FSMONITOR \ + 'normal status restores namespace-specific history outside the index' ' + test_when_finished "stop_daemon external-history" && + setup_repo external-history && + git -C external-history config core.untrackedCache true && + git -C external-history config status.renameLimit 100 && + git -C external-history update-index \ + --index-version=4 --force-write-index && + prime_semantic_history external-history && + test "$(git -C external-history \ + update-index --show-index-version)" = 4 && + test_grep FSMN external-history/.git/index && + test_grep UNTR external-history/.git/index && + test_grep FSCF external-history/.git/index && + test_grep FSUC external-history/.git/index && + git -C external-history ls-files --stage >baseline.stage && + cp external-history/.git/index namespace-a-v4.index && + + # Namespace B recovers once, but leaves namespace A in the main index. + git -C external-history config status.renameLimit 200 && + cp external-history/.git/index seed.before && + test_env GIT_TRACE2_EVENT="$PWD/external-seed.trace" \ + git -C external-history status >actual.seed && + test_grep "nothing to commit, working tree clean" actual.seed && + test_cmp seed.before external-history/.git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + external-sidecars && + test_line_count = 1 external-sidecars && + sidecar=$(cat external-sidecars) && + + # Namespace A rewrites the same entries in a different physical format. + cp "$sidecar" sidecar.before-rewrite && + git -C external-history config status.renameLimit 100 && + git -C external-history update-index \ + --index-version=2 --force-write-index && + test "$(git -C external-history \ + update-index --show-index-version)" = 2 && + ! cmp namespace-a-v4.index external-history/.git/index && + git -C external-history ls-files --stage >namespace-a-v2.stage && + test_cmp baseline.stage namespace-a-v2.stage && + test_grep FSMN external-history/.git/index && + test_grep UNTR external-history/.git/index && + test_grep FSCF external-history/.git/index && + test_grep FSUC external-history/.git/index && + test_cmp sidecar.before-rewrite "$sidecar" && + + git -C external-history config status.renameLimit 200 && + test_cmp sidecar.before-rewrite "$sidecar" && + cp external-history/.git/index namespace-a-v2.index && + test_env GIT_TRACE2_EVENT="$PWD/external-restore.trace" \ + git -C external-history status >actual.restore && + test_grep "nothing to commit, working tree clean" actual.restore && + test_cmp namespace-a-v2.index external-history/.git/index && + test_trace2_data fsmonitor history/external-restored 1 \ + flush.out && + : >"$sidecar.lock" && + test_when_finished "rm -f \"$sidecar.lock\"" && + cp external-history/.git/index locked.before && + test_env GIT_TRACE2_EVENT="$PWD/external-locked.trace" \ + git -C external-history status >actual.locked && + test_grep "nothing to commit, working tree clean" actual.locked && + test_cmp locked.before external-history/.git/index && + test_trace2_data fsmonitor history/external-restored 1 \ + repo->index; + struct object_id reference_tree; + struct strbuf reference = STRBUF_INIT; + int matches = 0; + + if (!s->allow_clean_status_shortcuts || s->is_initial || + getenv(INDEX_ENVIRONMENT) || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + !istate->cache_tree || + istate->cache_tree->entry_count < 0 || + (unsigned int)istate->cache_tree->entry_count != + istate->cache_nr) + return 0; + + /* + * A replacement below the root can change the effective tree without + * changing the root object name stored in the commit. + */ + if (replace_refs_enabled(s->repo)) { + prepare_replace_object(s->repo); + if (oidmap_get_size(&s->repo->objects->replace_map)) + return 0; + } + + strbuf_addf(&reference, "%s^{tree}", s->reference); + if (!repo_get_oid_tree(s->repo, reference.buf, &reference_tree) && + oideq(&istate->cache_tree->oid, &reference_tree)) + matches = 1; + strbuf_release(&reference); + return matches; +} + static void wt_status_collect_changes_index(struct wt_status *s) { struct rev_info rev; struct setup_revision_opt opt; + if (wt_status_cache_tree_matches_reference(s)) { + trace2_data_intmax("status", s->repo, + "index/cache-tree-match", 1); + return; + } + repo_init_revisions(s->repo, &rev, NULL); memset(&opt, 0, sizeof(opt)); opt.def = s->is_initial ? empty_tree_oid_hex(s->repo->hash_algo) : s->reference; @@ -879,6 +928,109 @@ static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; } +struct wt_status_exclude_context { + int root_fd; +}; + +static void wt_status_release_exclude_proof(struct wt_status *s) +{ + exclude_source_proof_release(s->certify_exclude_proof); + s->certify_exclude_proof = NULL; + if (s->certify_exclude_context) { + if (s->certify_exclude_context->root_fd >= 0) + close(s->certify_exclude_context->root_fd); + FREE_AND_NULL(s->certify_exclude_context); + } + oidclr(&s->certify_exclude_digest, s->repo->hash_algo); + s->certify_exclude_digest_valid = 0; + s->certify_untracked_scan_failed = 0; +} + +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + +static int wt_status_open_exclude_parent(void *data, const char *path) +{ + struct wt_status_exclude_context *context = data; + int flags = O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC | + O_NOFOLLOW; + + if (is_absolute_path(path)) + return open(path, flags); + return openat(context->root_fd, path, flags); +} + +static void wt_status_prepare_exclude_proof( + struct wt_status *s, struct dir_struct *dir) +{ + struct wt_status_exclude_context *context; + const char *worktree; + + if (!s->certify_clean_status) + return; + if (!s->certify_exclude_proof) { + worktree = repo_get_work_tree(s->repo); + if (!worktree) + return; + CALLOC_ARRAY(context, 1); + context->root_fd = open_nofollow( + worktree, + O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC); + if (context->root_fd < 0) { + free(context); + return; + } + s->certify_exclude_context = context; + s->certify_exclude_proof = exclude_source_proof_create( + s->repo->index, context, + wt_status_open_exclude_parent); + } + dir->internal.exclude_source_proof = + s->certify_exclude_proof; +} + +static void wt_status_record_exclude_digest(struct wt_status *s) +{ + if (s->certify_exclude_digest_valid || + !s->certify_exclude_proof) + return; + s->certify_exclude_digest_valid = + !exclude_source_proof_digest( + s->certify_exclude_proof, + s->repo->hash_algo, + &s->certify_exclude_digest); +} + +#else + +static void wt_status_prepare_exclude_proof( + struct wt_status *s UNUSED, struct dir_struct *dir UNUSED) +{ +} + +static void wt_status_record_exclude_digest(struct wt_status *s UNUSED) +{ +} + +#endif + +int wt_status_certified_excludes_digest( + struct wt_status *s, struct object_id *digest, + struct stat *scanned_worktree) +{ + if (s->certify_untracked_scan_failed || + !s->certify_exclude_digest_valid || + !s->certify_exclude_proof || + !s->certify_exclude_context || + s->certify_exclude_context->root_fd < 0 || + !exclude_source_proof_validate( + s->certify_exclude_proof) || + fstat(s->certify_exclude_context->root_fd, + scanned_worktree)) + return -1; + oidcpy(digest, &s->certify_exclude_digest); + return 0; +} + static int wt_status_begin_attr_snapshot(struct wt_status *s) { int ret; @@ -931,6 +1083,9 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) wt_status_begin_attr_snapshot(s); /* Record the provider token before either filesystem traversal. */ refresh_fsmonitor(istate); + if (s->certify_clean_status && + !fsmonitor_has_pending_token(istate)) + fsmonitor_reopen_token(istate); if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) @@ -1000,10 +1155,6 @@ static int wt_status_collect_untracked_1( if (!s->show_untracked_files) return 0; - if (s->untracked_from_preload && - !istate->untracked && - !s->show_ignored_mode) - return 0; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -1016,12 +1167,16 @@ static int wt_status_collect_untracked_1( dir.untracked = istate->untracked; } + wt_status_prepare_exclude_proof(s, &dir); setup_standard_excludes(&dir); + wt_status_record_exclude_digest(s); wt_status_finish_untracked_cache_preload(s); dir.internal.untracked_cache_preloaded = s->untracked_cache_preloaded; fill_directory(&dir, istate, &s->pathspec); + if (s->certify_clean_status && dir.internal.traversal_failed) + s->certify_untracked_scan_failed = 1; used_untracked_cache = dir.untracked && dir.untracked == istate->untracked; @@ -1099,6 +1254,8 @@ static int wt_status_collect_untracked(struct wt_status *s) { if (s->untracked_from_token_closure && !s->show_ignored_mode) return 1; + if (s->untracked_from_preload && !s->show_ignored_mode) + return 0; return wt_status_collect_untracked_1( s, &s->untracked, &s->ignored); } @@ -1157,6 +1314,22 @@ static void wt_status_publish_staged_untracked( closure->staged_untracked_ready = 0; } +static int wt_status_untracked_cache_valid( + const struct wt_status_token_closure *closure) +{ + const struct index_state *istate = closure->status->repo->index; + + return closure->untracked_ready && + istate->untracked && istate->untracked->root; +} + +static void wt_status_record_bulk_untracked( + struct wt_status_token_closure *closure) +{ + if (closure->status->repo->index->preload_untracked_complete) + closure->untracked_proof_complete = 1; +} + static int fsmonitor_token_requires_rescan(enum fsmonitor_token_result result) { return result == FSMONITOR_TOKEN_CHANGED || @@ -1225,22 +1398,6 @@ static void wt_status_refresh_for_token( istate->preload_bulk_proof_epoch = NULL; } -static int wt_status_untracked_cache_valid( - const struct wt_status_token_closure *closure) -{ - const struct index_state *istate = closure->status->repo->index; - - return closure->untracked_ready && - istate->untracked && istate->untracked->root; -} - -static void wt_status_record_bulk_untracked( - struct wt_status_token_closure *closure) -{ - if (closure->status->repo->index->preload_untracked_complete) - closure->untracked_proof_complete = 1; -} - static int wt_status_close_ordinary_fsmonitor_token( struct wt_status_token_closure *closure, int refreshed_before_closure) @@ -1592,6 +1749,12 @@ void wt_status_invalidate_refresh(struct wt_status *s) { struct index_state *istate = s->repo->index; + if (s->untracked_from_token_closure) { + string_list_clear(&s->untracked, 0); + string_list_clear(&s->ignored, 0); + s->untracked_from_token_closure = 0; + } + wt_status_release_exclude_proof(s); wt_status_release_attr_snapshot(s); if (!s->pathspec.nr && !istate->split_index && fsmonitor_reopen_token(istate)) @@ -1667,6 +1830,7 @@ void wt_status_collect_free_buffers(struct wt_status *s) { untracked_cache_preload_release(s->untracked_cache_preload); s->untracked_cache_preload = NULL; + wt_status_release_exclude_proof(s); wt_status_release_attr_snapshot(s); wt_status_state_free_buffers(&s->state); } diff --git a/wt-status.h b/wt-status.h index 99b005cb8b5cea..afee3b4ad9840c 100644 --- a/wt-status.h +++ b/wt-status.h @@ -7,7 +7,10 @@ #include "remote.h" struct repository; +struct stat; struct attr_source_snapshot; +struct exclude_source_proof; +struct wt_status_exclude_context; struct worktree; struct untracked_cache_preload; @@ -140,6 +143,8 @@ struct wt_status { /* These are computed during processing of the individual sections */ int committable; int workdir_dirty; + unsigned allow_clean_status_shortcuts : 1; + unsigned certify_clean_status : 1; unsigned untracked_from_token_closure : 1; unsigned untracked_from_preload : 1; unsigned bulk_update_index_stat : 1; @@ -152,8 +157,13 @@ struct wt_status { uint32_t untracked_in_ms; struct untracked_cache_preload *untracked_cache_preload; struct attr_source_snapshot *attr_source_snapshot; + struct exclude_source_proof *certify_exclude_proof; + struct wt_status_exclude_context *certify_exclude_context; + struct object_id certify_exclude_digest; unsigned untracked_cache_preloaded : 1; unsigned attr_snapshot_failed : 1; + unsigned certify_exclude_digest_valid : 1; + unsigned certify_untracked_scan_failed : 1; }; size_t wt_status_locate_end(const char *s, size_t len); @@ -171,6 +181,9 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked); void wt_status_invalidate_refresh(struct wt_status *s); +int wt_status_certified_excludes_digest( + struct wt_status *s, struct object_id *digest, + struct stat *scanned_worktree); /* * Collect all changes between the two trees. Changes will be displayed as if From cc17eae02a5fb40b1c646e75ffbdd49d6c0fadbc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 075/403] path-namespace: compare complete filesystem object identities Comparing only a pathname, device, and inode does not establish that two observations still describe the same unchanged filesystem object. Users that reopen a path need a reusable comparison covering the represented metadata fields. Represent an object's stat identity as a zero-initialized, fixed-width array containing device, inode, mode, link count, ownership, size, and modification and change timestamps. Include nanoseconds where available and add birth time and generation on Apple platforms. Provide comparison helpers and register both their library source and Clar unit suite in the Makefile and Meson builds. The unit tests check identity equality and reject changes to each represented stat field. No exclude-file validation or production caller is introduced here. Signed-off-by: Taylor Blau --- Makefile | 2 + meson.build | 1 + path-namespace.c | 47 ++++++++++++++++++++++ path-namespace.h | 18 +++++++++ t/meson.build | 1 + t/unit-tests/u-path-namespace.c | 69 +++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+) create mode 100644 path-namespace.c create mode 100644 path-namespace.h create mode 100644 t/unit-tests/u-path-namespace.c diff --git a/Makefile b/Makefile index fac3e8879c8377..edac13257671b1 100644 --- a/Makefile +++ b/Makefile @@ -1255,6 +1255,7 @@ LIB_OBJS += parse-options.o LIB_OBJS += patch-delta.o LIB_OBJS += patch-ids.o LIB_OBJS += path.o +LIB_OBJS += path-namespace.o LIB_OBJS += path-walk.o LIB_OBJS += pathspec.o LIB_OBJS += pkt-line.o @@ -1546,6 +1547,7 @@ CLAR_TEST_SUITES += u-odb-inmemory CLAR_TEST_SUITES += u-oid-array CLAR_TEST_SUITES += u-oidmap CLAR_TEST_SUITES += u-oidtree +CLAR_TEST_SUITES += u-path-namespace CLAR_TEST_SUITES += u-prio-queue CLAR_TEST_SUITES += u-reftable-basics CLAR_TEST_SUITES += u-reftable-block diff --git a/meson.build b/meson.build index 7073d5844d2531..a777f4722a2061 100644 --- a/meson.build +++ b/meson.build @@ -460,6 +460,7 @@ libgit_sources = [ 'patch-delta.c', 'patch-ids.c', 'path.c', + 'path-namespace.c', 'path-walk.c', 'pathspec.c', 'pkt-line.c', diff --git a/path-namespace.c b/path-namespace.c new file mode 100644 index 00000000000000..48fc0aae434ef7 --- /dev/null +++ b/path-namespace.c @@ -0,0 +1,47 @@ +#include "git-compat-util.h" +#include "path-namespace.h" + +void path_stat_identity_init(struct path_stat_identity *identity, + const struct stat *st) +{ + memset(identity, 0, sizeof(*identity)); + identity->fields[0] = st->st_dev; + identity->fields[1] = st->st_ino; + identity->fields[2] = st->st_mode; + identity->fields[3] = st->st_nlink; + identity->fields[4] = st->st_uid; + identity->fields[5] = st->st_gid; + identity->fields[6] = st->st_size; + identity->fields[7] = st->st_mtime; +#ifdef __APPLE__ + identity->fields[8] = st->st_mtimespec.tv_nsec; +#else + identity->fields[8] = ST_MTIME_NSEC(*st); +#endif + identity->fields[9] = st->st_ctime; +#ifdef __APPLE__ + identity->fields[10] = st->st_ctimespec.tv_nsec; +#else + identity->fields[10] = ST_CTIME_NSEC(*st); +#endif +#ifdef __APPLE__ + identity->fields[11] = st->st_birthtimespec.tv_sec; + identity->fields[12] = st->st_birthtimespec.tv_nsec; + identity->fields[13] = st->st_gen; +#endif +} + +int path_stat_identity_equal(const struct path_stat_identity *a, + const struct path_stat_identity *b) +{ + return !memcmp(a, b, sizeof(*a)); +} + +int path_namespace_stat_equal(const struct stat *a, const struct stat *b) +{ + struct path_stat_identity first, second; + + path_stat_identity_init(&first, a); + path_stat_identity_init(&second, b); + return path_stat_identity_equal(&first, &second); +} diff --git a/path-namespace.h b/path-namespace.h new file mode 100644 index 00000000000000..0a93683e0b2de1 --- /dev/null +++ b/path-namespace.h @@ -0,0 +1,18 @@ +#ifndef PATH_NAMESPACE_H +#define PATH_NAMESPACE_H + +struct stat; + +#define PATH_STAT_IDENTITY_FIELDS 14 + +struct path_stat_identity { + uint64_t fields[PATH_STAT_IDENTITY_FIELDS]; +}; + +void path_stat_identity_init(struct path_stat_identity *identity, + const struct stat *st); +int path_stat_identity_equal(const struct path_stat_identity *a, + const struct path_stat_identity *b); +int path_namespace_stat_equal(const struct stat *a, const struct stat *b); + +#endif /* PATH_NAMESPACE_H */ diff --git a/t/meson.build b/t/meson.build index a25f37d2f5ae7d..d8734eae5ff77f 100644 --- a/t/meson.build +++ b/t/meson.build @@ -10,6 +10,7 @@ clar_test_suites = [ 'unit-tests/u-oid-array.c', 'unit-tests/u-oidmap.c', 'unit-tests/u-oidtree.c', + 'unit-tests/u-path-namespace.c', 'unit-tests/u-prio-queue.c', 'unit-tests/u-reftable-basics.c', 'unit-tests/u-reftable-block.c', diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c new file mode 100644 index 00000000000000..702104597b1df9 --- /dev/null +++ b/t/unit-tests/u-path-namespace.c @@ -0,0 +1,69 @@ +#include "unit-test.h" + +#include "path-namespace.h" + +#define ASSERT_STAT_FIELD_MATTERS(base, changed, field) do { \ + (changed) = (base); \ + (changed).field = !(base).field; \ + cl_assert(!path_namespace_stat_equal(&(base), &(changed))); \ +} while (0) + +void test_path_namespace__stat_identity(void) +{ + struct stat st = { 0 }; + struct path_stat_identity first, second; + size_t i; + + st.st_dev = 1; + st.st_ino = 2; + st.st_mode = S_IFREG | 0644; + st.st_nlink = 3; + st.st_uid = 4; + st.st_gid = 5; + st.st_size = 6; + st.st_mtime = 7; + st.st_ctime = 8; + + path_stat_identity_init(&first, &st); + path_stat_identity_init(&second, &st); + cl_assert(path_stat_identity_equal(&first, &second)); + cl_assert(path_namespace_stat_equal(&st, &st)); + + for (i = 0; i < PATH_STAT_IDENTITY_FIELDS; i++) { + second = first; + second.fields[i]++; + cl_assert(!path_stat_identity_equal(&first, &second)); + } +} + +void test_path_namespace__stat_fields(void) +{ + struct stat st, changed; + + cl_must_pass(stat(".", &st)); + changed = st; + cl_assert(path_namespace_stat_equal(&st, &changed)); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_dev); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ino); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mode); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_nlink); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_uid); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_gid); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_size); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mtime); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ctime); +#ifndef NO_NSEC +#ifdef USE_ST_TIMESPEC + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mtimespec.tv_nsec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ctimespec.tv_nsec); +#else + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mtim.tv_nsec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ctim.tv_nsec); +#endif +#endif +#ifdef __APPLE__ + ASSERT_STAT_FIELD_MATTERS(st, changed, st_birthtimespec.tv_sec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_birthtimespec.tv_nsec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_gen); +#endif +} From a40cd9e544d24190f6806b202d75f10f26d68dc3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:16:38 -0500 Subject: [PATCH 076/403] status: load bounded clean-status sidecars An installed sidecar cannot be inspected safely by opening an untrusted adjacent path without bounds. A symbolic link, named pipe, oversized record, or growing file could redirect the read, block status, or consume unbounded memory. Open the named sidecar without following symbolic links and request a nonblocking descriptor. Accept only a regular file of at most 8192 bytes, read exactly its recorded size, reject an additional byte, and parse its checksummed contents into caller-owned storage. Clear failed records and release storage explicitly. Platforms without nonblocking support fail closed. Extend the registered store unit suite to cover owned token storage under both object formats, symbolic links, FIFOs, and an oversized 8193-byte record. The loader is testable at this boundary; it does not yet bypass index deserialization. Signed-off-by: Taylor Blau --- clean-status-sidecar.c | 44 +++++++++++ clean-status-sidecar.h | 16 +++- t/unit-tests/u-clean-status-store.c | 116 ++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index a68d3dc8059367..95db486a47b289 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -19,6 +19,7 @@ #include "wrapper.h" #define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" +#define CLEAN_STATUS_SIDECAR_MAX_SIZE 8192 #define CLEAN_STATUS_FILESYSTEM_ID_SIZE 16 struct clean_status_filesystem_id { @@ -163,6 +164,49 @@ static int open_nofollow_nonblocking(const char *path, int flags) #endif } +int clean_status_sidecar_load( + const char *index_path, const struct git_hash_algo *algo, + struct clean_status_sidecar_record *record) +{ + struct stat st; + char extra; + char *path = sidecar_path(index_path); + int fd = -1, ret = -1; + size_t size; + + memset(&record->sidecar, 0, sizeof(record->sidecar)); + strbuf_reset(&record->storage); + fd = open_nofollow_nonblocking(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || fstat(fd, &st) || !S_ISREG(st.st_mode) || + st.st_size < 0 || st.st_size > CLEAN_STATUS_SIDECAR_MAX_SIZE) + goto done; + size = xsize_t(st.st_size); + strbuf_grow(&record->storage, size); + strbuf_setlen(&record->storage, size); + if ((size_t)read_in_full(fd, record->storage.buf, size) != size || + read(fd, &extra, 1) != 0 || + clean_status_sidecar_parse(&record->sidecar, + record->storage.buf, + record->storage.len, algo)) + goto done; + ret = 0; + +done: + if (ret) + strbuf_reset(&record->storage); + if (fd >= 0) + close(fd); + free(path); + return ret; +} + +void clean_status_sidecar_record_release( + struct clean_status_sidecar_record *record) +{ + strbuf_release(&record->storage); + memset(&record->sidecar, 0, sizeof(record->sidecar)); +} + static int local_apfs_id(int fd MAYBE_UNUSED, struct clean_status_filesystem_id *id) { diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index 963d6bccc6b65a..8149acbe5b86bb 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -3,11 +3,11 @@ #include "clean-status-identity.h" #include "hash.h" +#include "strbuf.h" struct clean_status_index_snapshot; struct attr_source_snapshot; struct repository; -struct strbuf; struct stat; #define CLEAN_STATUS_SIDECAR_VERSION 1 @@ -29,12 +29,26 @@ struct clean_status_sidecar { size_t token_len; }; +struct clean_status_sidecar_record { + struct clean_status_sidecar sidecar; + struct strbuf storage; +}; + +#define CLEAN_STATUS_SIDECAR_RECORD_INIT { \ + .storage = STRBUF_INIT, \ +} + int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, const void *data, size_t len, const struct git_hash_algo *algo); int clean_status_sidecar_write(struct strbuf *out, const struct clean_status_sidecar *sidecar, const struct git_hash_algo *algo); +int clean_status_sidecar_load( + const char *index_path, const struct git_hash_algo *algo, + struct clean_status_sidecar_record *record); +void clean_status_sidecar_record_release( + struct clean_status_sidecar_record *record); int clean_status_sidecar_pin_source( const char *index_path, const struct clean_status_sidecar *sidecar, const struct git_hash_algo *algo, diff --git a/t/unit-tests/u-clean-status-store.c b/t/unit-tests/u-clean-status-store.c index ac25bb225e3c2e..4c2e5739aaceea 100644 --- a/t/unit-tests/u-clean-status-store.c +++ b/t/unit-tests/u-clean-status-store.c @@ -82,6 +82,122 @@ static struct strbuf sidecar_path(struct store_fixture *fixture) return path; } +#ifdef O_NONBLOCK +static void write_fixture_sidecar(struct store_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct strbuf encoded = STRBUF_INIT; + struct strbuf path = sidecar_path(fixture); + + cl_assert_equal_i(clean_status_sidecar_write( + &encoded, &fixture->sidecar, algo), 0); + write_file_buf(path.buf, encoded.buf, encoded.len); + strbuf_release(&path); + strbuf_release(&encoded); +} + +static void assert_loads_sidecar(const struct git_hash_algo *algo) +{ + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct store_fixture fixture; + + fixture_init(&fixture, algo); + write_fixture_sidecar(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), 0); + cl_assert(clean_status_identity_equal( + &record.sidecar.identity, &fixture.sidecar.identity)); + cl_assert_equal_i(record.sidecar.proof.index_version, + fixture.sidecar.proof.index_version); + cl_assert_equal_i(record.sidecar.proof.cache_nr, + fixture.sidecar.proof.cache_nr); + cl_assert(oideq(&record.sidecar.proof.index_checksum, + &fixture.sidecar.proof.index_checksum)); + cl_assert(oideq(&record.sidecar.proof.head_tree, + &fixture.sidecar.proof.head_tree)); + cl_assert(!memcmp(record.sidecar.proof.config_hash, + fixture.sidecar.proof.config_hash, algo->rawsz)); + cl_assert(!memcmp(record.sidecar.proof.repo_hash, + fixture.sidecar.proof.repo_hash, algo->rawsz)); + cl_assert(oideq(&record.sidecar.proof.exclude_source_digest, + &fixture.sidecar.proof.exclude_source_digest)); + cl_assert_equal_i(record.sidecar.token_len, fixture.sidecar.token_len); + cl_assert(!memcmp(record.sidecar.token, fixture.sidecar.token, + record.sidecar.token_len)); + cl_assert(record.sidecar.token >= + (const unsigned char *)record.storage.buf); + cl_assert(record.sidecar.token + record.sidecar.token_len <= + (const unsigned char *)record.storage.buf + + record.storage.len); + + clean_status_sidecar_record_release(&record); + fixture_release(&fixture); +} +#endif + +void test_clean_status_store__loads_owned_sidecars_in_both_object_formats(void) +{ +#ifdef O_NONBLOCK + assert_loads_sidecar(&hash_algos[GIT_HASH_SHA1]); + assert_loads_sidecar(&hash_algos[GIT_HASH_SHA256]); +#else + cl_skip(); +#endif +} + +void test_clean_status_store__rejects_nonregular_sidecars(void) +{ +#if defined(O_NONBLOCK) && !defined(GIT_WINDOWS_NATIVE) + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct store_fixture fixture; + struct strbuf path, target = STRBUF_INIT; + + fixture_init(&fixture, algo); + path = sidecar_path(&fixture); + strbuf_addf(&target, "%s/target", fixture.directory); + write_file(target.buf, "target"); + cl_assert_equal_i(symlink(target.buf, path.buf), 0); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), -1); + cl_assert_equal_i(unlink(path.buf), 0); + cl_assert_equal_i(mkfifo(path.buf, 0600), 0); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), -1); + + clean_status_sidecar_record_release(&record); + strbuf_release(&target); + strbuf_release(&path); + fixture_release(&fixture); +#else + cl_skip(); +#endif +} + +void test_clean_status_store__rejects_oversized_sidecars(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct store_fixture fixture; + struct strbuf oversized = STRBUF_INIT; + struct strbuf path; + + fixture_init(&fixture, algo); + path = sidecar_path(&fixture); + strbuf_addchars(&oversized, 'x', 8193); + write_file_buf(path.buf, oversized.buf, oversized.len); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), -1); + + clean_status_sidecar_record_release(&record); + strbuf_release(&oversized); + strbuf_release(&path); + fixture_release(&fixture); +} + static void require_local_apfs(const char *path MAYBE_UNUSED) { #ifdef __APPLE__ From 101756fd7cb1d0a7d0a8f2273951eb6271bdc7f0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:47:44 -0500 Subject: [PATCH 077/403] preload-index: queue bounded bulk directory scans Ordinary index preload assigns existing paths directly to workers. A physical directory walk instead discovers new tasks while it runs, so an unbounded queue can exhaust descriptors or strand tasks when worker creation fails. Add a directory-task queue that retains parent and child identities, budgets descriptors against RLIMIT_NOFILE, and tracks queued as well as in-flight work. Reserve at most 128 task descriptors, leave up to 16 for the rest of the process, and run a worker synchronously when extra threads cannot start. Register the common queue for Darwin in Make, CMake, and Meson. No bulk scan is invoked from preload_index(), so existing behavior is unchanged. Signed-off-by: Taylor Blau --- Makefile | 8 + config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 6 + meson.build | 3 + preload-index-bulk-thread.c | 232 ++++++++++++++++++++++++++++ preload-index-bulk.h | 76 +++++++++ 6 files changed, 326 insertions(+) create mode 100644 preload-index-bulk-thread.c create mode 100644 preload-index-bulk.h diff --git a/Makefile b/Makefile index edac13257671b1..f9bd42aba71222 100644 --- a/Makefile +++ b/Makefile @@ -413,6 +413,9 @@ include shared.mak # `compat/fsmonitor/fsm-health-.c` files # that implement the `fsm_listen__*()` and `fsm_health__*()` routines. # +# If a platform supports bulk worktree scans during index preload, set +# PRELOAD_INDEX_BULK_BACKEND to the name of its backend. +# # If your platform has OS-specific ways to tell if a repo is incompatible with # fsmonitor (whether the hook or IPC daemon version), set FSMONITOR_OS_SETTINGS # to the "" of the corresponding `compat/fsmonitor/fsm-settings-.c` @@ -1378,6 +1381,11 @@ LIB_OBJS += worktree.o LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o +ifdef PRELOAD_INDEX_BULK_BACKEND +PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o +PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) +endif +LIB_OBJS += $(PRELOAD_INDEX_BULK_OBJS) LIB_OBJS += wt-status.o LIB_OBJS += xdiff-interface.o LIB_OBJS += xdiff/xdiffi.o diff --git a/config.mak.uname b/config.mak.uname index 9ebd240378ca59..af3f927d8b8a50 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -162,6 +162,7 @@ ifeq ($(uname_S),Darwin) USE_ENHANCED_BASIC_REGULAR_EXPRESSIONS = YesPlease HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/darwin/procinfo.o + PRELOAD_INDEX_BULK_BACKEND = darwin ifeq ($(uname_M),arm64) HOMEBREW_PREFIX = /opt/homebrew diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index a57c4b464fa456..1eb0a9fc0ef646 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -103,6 +103,7 @@ macro(parse_makefile_for_sources list_var makefile regex) file(STRINGS ${makefile} ${list_var} REGEX "^${regex} \\+=(.*)") string(REPLACE "${regex} +=" "" ${list_var} ${${list_var}}) string(REPLACE "$(COMPAT_OBJS)" "" ${list_var} ${${list_var}}) #remove "$(COMPAT_OBJS)" This is only for libgit. + string(REPLACE "$(PRELOAD_INDEX_BULK_OBJS)" "" ${list_var} ${${list_var}}) string(STRIP ${${list_var}} ${list_var}) #remove trailing/leading whitespaces string(REPLACE ".o" ".c;" ${list_var} ${${list_var}}) #change .o to .c, ; is for converting the string into a list list(TRANSFORM ${list_var} STRIP) #remove trailing/leading whitespaces for each element in list @@ -664,6 +665,11 @@ include_directories(${CMAKE_BINARY_DIR}) #libgit parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS") +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + list(APPEND libgit_SOURCES + preload-index-bulk-thread.c) +endif() + list(TRANSFORM libgit_SOURCES PREPEND "${CMAKE_SOURCE_DIR}/") list(TRANSFORM compat_SOURCES PREPEND "${CMAKE_SOURCE_DIR}/") diff --git a/meson.build b/meson.build index a777f4722a2061..68cd5819811f3e 100644 --- a/meson.build +++ b/meson.build @@ -1345,6 +1345,9 @@ elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' compat_sources += 'compat/darwin/procinfo.c' + libgit_sources += [ + 'preload-index-bulk-thread.c', + ] else compat_sources += 'compat/stub/procinfo.c' endif diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c new file mode 100644 index 00000000000000..0a73d5a1fdeafd --- /dev/null +++ b/preload-index-bulk-thread.c @@ -0,0 +1,232 @@ +#include "git-compat-util.h" + +#include + +#include "preload-index-bulk.h" + +#define PRELOAD_INDEX_BULK_OPEN_FD_CAP 128 +#define PRELOAD_INDEX_BULK_OPEN_FD_RESERVE 16 + +static void queue_set_failed(struct preload_bulk_queue *queue) +{ + pthread_mutex_lock(&queue->mutex); + queue->failed = 1; + pthread_mutex_unlock(&queue->mutex); +} + +static void enqueue_task(struct preload_bulk_scan *scan, + struct preload_bulk_task *task) +{ + struct preload_bulk_queue *queue = &scan->queue; + + pthread_mutex_lock(&queue->mutex); + task->next = queue->head; + queue->head = task; + queue->pending++; + pthread_cond_signal(&queue->cond); + pthread_mutex_unlock(&queue->mutex); +} + +static int reserve_open_fd(struct preload_bulk_queue *queue) +{ + int reserved = 0; + + pthread_mutex_lock(&queue->mutex); + if (queue->open_fds < queue->open_fd_limit) { + queue->open_fds++; + reserved = 1; + } + pthread_mutex_unlock(&queue->mutex); + return reserved; +} + +static void release_open_fd(struct preload_bulk_queue *queue) +{ + pthread_mutex_lock(&queue->mutex); + if (!queue->open_fds) + BUG("bulk preload open-fd count underflow"); + queue->open_fds--; + pthread_mutex_unlock(&queue->mutex); +} + +void preload_bulk_schedule_directory( + struct preload_bulk_worker *worker, int parent_fd, + const struct preload_bulk_dir_identity *parent_identity, + const struct preload_bulk_dir_identity *child_identity, + const char *name, const char *path, size_t path_len) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_task *task; + + FLEX_ALLOC_MEM(task, path, path, path_len); + if (parent_identity) { + task->parent_identity = *parent_identity; + task->has_parent_identity = 1; + } + if (child_identity) { + task->child_identity = *child_identity; + task->has_child_identity = 1; + } + task->fd = -1; + if (reserve_open_fd(&scan->queue)) { + task->reserved_fd = 1; + task->fd = scan->backend->open_dir_at(worker, parent_fd, name); + if (task->fd < 0) { + int saved_errno = errno; + + task->reserved_fd = 0; + release_open_fd(&scan->queue); + if (saved_errno == EXDEV) { + free(task); + return; + } + if (saved_errno != EMFILE && saved_errno != ENFILE) { + free(task); + queue_set_failed(&scan->queue); + return; + } + } + } + enqueue_task(scan, task); +} + +static size_t preload_bulk_open_fd_limit(void) +{ + struct rlimit limit; + rlim_t value; + + if (getrlimit(RLIMIT_NOFILE, &limit)) + return 1; + if (limit.rlim_cur == RLIM_INFINITY) + return PRELOAD_INDEX_BULK_OPEN_FD_CAP; + if (limit.rlim_cur <= PRELOAD_INDEX_BULK_OPEN_FD_RESERVE) + return 1; + value = limit.rlim_cur - PRELOAD_INDEX_BULK_OPEN_FD_RESERVE; + if (value > PRELOAD_INDEX_BULK_OPEN_FD_CAP) + value = PRELOAD_INDEX_BULK_OPEN_FD_CAP; + return value; +} + +static int queue_init(struct preload_bulk_queue *queue) +{ + memset(queue, 0, sizeof(*queue)); +#if HAVE_THREADS + if (pthread_mutex_init(&queue->mutex, NULL)) + return -1; + if (pthread_cond_init(&queue->cond, NULL)) { + pthread_mutex_destroy(&queue->mutex); + return -1; + } +#endif + queue->open_fd_limit = preload_bulk_open_fd_limit(); + return 0; +} + +static void queue_release(struct preload_bulk_queue *queue) +{ + if (queue->head || queue->pending || queue->open_fds) + BUG("releasing non-empty bulk preload queue"); +#if HAVE_THREADS + pthread_cond_destroy(&queue->cond); + pthread_mutex_destroy(&queue->mutex); +#endif + memset(queue, 0, sizeof(*queue)); +} + +static void *preload_bulk_worker_main(void *data) +{ + struct preload_bulk_worker *worker = data; + struct preload_bulk_queue *queue = &worker->scan->queue; + + for (;;) { + struct preload_bulk_task *task; + int failed, reserved_fd; + + pthread_mutex_lock(&queue->mutex); + while (!queue->head && queue->pending) + pthread_cond_wait(&queue->cond, &queue->mutex); + if (!queue->pending) { + pthread_mutex_unlock(&queue->mutex); + break; + } + task = queue->head; + queue->head = task->next; + pthread_mutex_unlock(&queue->mutex); + + failed = + worker->scan->backend->scan_directory(worker, task); + reserved_fd = task->reserved_fd; + free(task); + + pthread_mutex_lock(&queue->mutex); + if (failed) + queue->failed = 1; + if (reserved_fd) { + if (!queue->open_fds) + BUG("bulk preload open-fd count underflow"); + queue->open_fds--; + } + if (!queue->pending) + BUG("bulk preload task count underflow"); + queue->pending--; + if (!queue->pending) + pthread_cond_broadcast(&queue->cond); + pthread_mutex_unlock(&queue->mutex); + } + return NULL; +} + +int preload_bulk_run_scan(struct preload_bulk_scan *scan, + struct preload_bulk_run_result *result) +{ + struct preload_bulk_task *root_task; + int failed, started_threads = 1; + + if (scan->threads < 1) + BUG("bulk preload scan requires at least one worker"); + memset(result, 0, sizeof(*result)); + if (queue_init(&scan->queue)) + return -1; + CALLOC_ARRAY(scan->workers, scan->threads); + for (int i = 0; i < scan->threads; i++) + scan->workers[i].scan = scan; + + FLEX_ALLOC_STR(root_task, path, "."); + if (!reserve_open_fd(&scan->queue)) + BUG("bulk preload queue cannot reserve its root descriptor"); + root_task->reserved_fd = 1; + root_task->fd = fcntl(scan->root_fd, F_DUPFD_CLOEXEC, 0); + if (root_task->fd < 0) { + release_open_fd(&scan->queue); + free(root_task); + free(scan->workers); + scan->workers = NULL; + queue_release(&scan->queue); + return -1; + } + enqueue_task(scan, root_task); + + for (int i = 1; i < scan->threads; i++) { + int err = pthread_create(&scan->workers[i].thread, NULL, + preload_bulk_worker_main, + &scan->workers[i]); + + if (err) + break; + scan->workers[i].started = 1; + started_threads++; + } + preload_bulk_worker_main(&scan->workers[0]); + for (int i = 1; i < scan->threads; i++) + if (scan->workers[i].started && + pthread_join(scan->workers[i].thread, NULL)) + BUG("unable to join bulk preload worker"); + + result->threads = started_threads; + failed = scan->queue.failed; + + free(scan->workers); + scan->workers = NULL; + queue_release(&scan->queue); + return failed ? -1 : 0; +} diff --git a/preload-index-bulk.h b/preload-index-bulk.h new file mode 100644 index 00000000000000..64cb000474413a --- /dev/null +++ b/preload-index-bulk.h @@ -0,0 +1,76 @@ +#ifndef PRELOAD_INDEX_BULK_H +#define PRELOAD_INDEX_BULK_H + +#include "git-compat-util.h" +#include "thread-utils.h" + +struct preload_bulk_dir_identity { + struct stat stat; + unsigned complete : 1; +}; + +struct preload_bulk_task { + struct preload_bulk_task *next; + struct preload_bulk_dir_identity parent_identity; + struct preload_bulk_dir_identity child_identity; + int fd; + unsigned reserved_fd : 1; + unsigned has_parent_identity : 1; + unsigned has_child_identity : 1; + char path[FLEX_ARRAY]; +}; + +struct preload_bulk_queue { + pthread_mutex_t mutex; + pthread_cond_t cond; + struct preload_bulk_task *head; + /* + * pending includes queued and in-flight tasks. open_fds counts only + * descriptor reservations held by tasks. + */ + size_t pending; + size_t open_fds; + size_t open_fd_limit; + int failed; +}; + +struct preload_bulk_scan; + +struct preload_bulk_worker { + struct preload_bulk_scan *scan; + pthread_t thread; + unsigned started : 1; +}; + +struct preload_bulk_backend { + int (*open_dir_at)(struct preload_bulk_worker *worker, int parent_fd, + const char *name); + /* + * Consume task->fd when it is non-negative, and close it before + * returning. + */ + int (*scan_directory)(struct preload_bulk_worker *worker, + struct preload_bulk_task *task); +}; + +struct preload_bulk_scan { + const struct preload_bulk_backend *backend; + struct preload_bulk_queue queue; + struct preload_bulk_worker *workers; + int root_fd; + int threads; +}; + +struct preload_bulk_run_result { + int threads; +}; + +void preload_bulk_schedule_directory( + struct preload_bulk_worker *worker, int parent_fd, + const struct preload_bulk_dir_identity *parent_identity, + const struct preload_bulk_dir_identity *child_identity, + const char *name, const char *path, size_t path_len); +int preload_bulk_run_scan(struct preload_bulk_scan *scan, + struct preload_bulk_run_result *result); + +#endif /* PRELOAD_INDEX_BULK_H */ From 7fd4ec89fc12d4806b46b52e786584c098390e24 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:44:02 -0700 Subject: [PATCH 078/403] fsmonitor: validate FSMN before publishing it The index reader consumed an optional FSMN token and EWAH bitmap before checking their complete framing. A truncated or duplicate record could publish partial monitor state; an impossible bitmap length could allocate out of bounds or cover nonexistent index entries. Validate both FSMN versions against the extension bounds, cap version-2 tokens at 4 KiB, and check EWAH word counts, run lengths, padding, and the final running-length word. Reject a bitmap wider than a non-split index. Publish the token and bitmap only after every check succeeds, and clear all existing FSMN state on failure. Extend the read-cache helper to exercise valid records, duplicates, truncation, invalid literal and set-bit runs, nonzero padding, and an invalid final running-length-word pointer. Register the helper regression in t/t7519-status-fsmonitor.sh. Malformed optional state falls back without making the worktree appear clean. Signed-off-by: Taylor Blau --- fsmonitor.c | 113 ++++++++++++++++++++++++++++--- read-cache-ll.h | 3 +- t/helper/test-read-cache.c | 130 ++++++++++++++++++++++++++++++++++++ t/t7519-status-fsmonitor.sh | 4 ++ 4 files changed, 238 insertions(+), 12 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index df716a26b85499..ebec5620dbb630 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -7,6 +7,7 @@ #include "dir.h" #include "environment.h" #include "ewah/ewok.h" +#include "ewah/ewok_rlw.h" #include "fsmonitor.h" #include "fsmonitor-ipc.h" #include "name-hash.h" @@ -17,6 +18,7 @@ #define INDEX_EXTENSION_VERSION1 (1) #define INDEX_EXTENSION_VERSION2 (2) +#define FSMONITOR_TOKEN_MAX (4096) #define HOOK_INTERFACE_VERSION1 (1) #define HOOK_INTERFACE_VERSION2 (2) @@ -40,6 +42,46 @@ static void fsmonitor_ewah_callback(size_t pos, void *is) ce->ce_flags &= ~CE_FSMONITOR_VALID; } +static int fsmonitor_ewah_is_valid(struct ewah_bitmap *bitmap) +{ + size_t pointer = 0, expanded_words = 0; + size_t logical_words = bitmap->bit_size / BITS_IN_EWORD + + !!(bitmap->bit_size % BITS_IN_EWORD); + size_t padding = bitmap->bit_size % BITS_IN_EWORD; + eword_t *last_rlw = NULL; + + while (pointer < bitmap->buffer_size) { + eword_t *rlw = &bitmap->buffer[pointer]; + size_t running_words = rlw_get_running_len(rlw); + size_t literal_words = rlw_get_literal_words(rlw); + size_t i; + + last_rlw = rlw; + if (literal_words > bitmap->buffer_size - pointer - 1) + return 0; + if (running_words > logical_words - expanded_words) + return 0; + expanded_words += running_words; + if (rlw_get_run_bit(rlw) && running_words && padding && + expanded_words == logical_words) + return 0; + if (literal_words > logical_words - expanded_words) + return 0; + for (i = 0; i < literal_words; i++) { + eword_t literal = bitmap->buffer[pointer + 1 + i]; + + if (padding && + expanded_words + i + 1 == logical_words && + literal >> padding) + return 0; + } + expanded_words += literal_words; + pointer += 1 + literal_words; + } + + return expanded_words == logical_words && bitmap->rlw == last_rlw; +} + static int fsmonitor_hook_version(void) { int hook_version; @@ -60,44 +102,81 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, unsigned long sz) { const char *index = data; + const char *end = index + sz; + const char *nul; uint32_t hdr_version; uint32_t ewah_size; + uint32_t ewah_words; + uint32_t ewah_rlw; struct ewah_bitmap *fsmonitor_dirty; int ret; uint64_t timestamp; struct strbuf last_update = STRBUF_INIT; - if (sz < sizeof(uint32_t) + 1 + sizeof(uint32_t)) - return error("corrupt fsmonitor extension (too short)"); + if (istate->fsmonitor_extension_seen) + goto invalid; + istate->fsmonitor_extension_seen = 1; + if (end - index < sizeof(uint32_t)) + goto invalid; hdr_version = get_be32(index); index += sizeof(uint32_t); if (hdr_version == INDEX_EXTENSION_VERSION1) { + if (end - index < sizeof(uint64_t)) + goto invalid; timestamp = get_be64(index); strbuf_addf(&last_update, "%"PRIu64"", timestamp); index += sizeof(uint64_t); } else if (hdr_version == INDEX_EXTENSION_VERSION2) { - strbuf_addstr(&last_update, index); - index += last_update.len + 1; + nul = memchr(index, '\0', end - index); + if (!nul || nul == index || nul - index > FSMONITOR_TOKEN_MAX) + goto invalid; + strbuf_add(&last_update, index, nul - index); + index = nul + 1; } else { - return error("bad fsmonitor version %d", hdr_version); + goto invalid; } - istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); - + if (end - index < sizeof(uint32_t)) + goto invalid; ewah_size = get_be32(index); index += sizeof(uint32_t); + if (ewah_size != end - index || ewah_size < 3 * sizeof(uint32_t)) + goto invalid; + + /* Reject impossible EWAH lengths before its parser allocates memory. */ + ewah_words = get_be32(index + sizeof(uint32_t)); + if (ewah_words > (ewah_size - 3 * sizeof(uint32_t)) / + sizeof(eword_t) || + 3 * sizeof(uint32_t) + (size_t)ewah_words * sizeof(eword_t) != + ewah_size) + goto invalid; + ewah_rlw = get_be32(index + ewah_size - sizeof(uint32_t)); + if (ewah_rlw >= ewah_words) + goto invalid; fsmonitor_dirty = ewah_new(); ret = ewah_read_mmap(fsmonitor_dirty, index, ewah_size); if (ret != ewah_size) { ewah_free(fsmonitor_dirty); - return error("failed to parse ewah bitmap reading fsmonitor index extension"); + goto invalid; + } + if (!fsmonitor_ewah_is_valid(fsmonitor_dirty)) { + ewah_free(fsmonitor_dirty); + goto invalid; + } + if (!istate->split_index && + fsmonitor_dirty->bit_size > istate->cache_nr) { + ewah_free(fsmonitor_dirty); + goto invalid; } - istate->fsmonitor_dirty = fsmonitor_dirty; - if (!istate->split_index) - assert_index_minimum(istate, istate->fsmonitor_dirty->bit_size); + /* Publish only after the complete optional extension is validated. */ + FREE_AND_NULL(istate->fsmonitor_last_update); + if (istate->fsmonitor_dirty) + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); + istate->fsmonitor_dirty = fsmonitor_dirty; trace2_data_string("index", NULL, "extension/fsmn/read/token", istate->fsmonitor_last_update); @@ -105,6 +184,18 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, "read fsmonitor extension successful '%s'", istate->fsmonitor_last_update); return 0; + +invalid: + istate->fsmonitor_extension_seen = 1; + FREE_AND_NULL(istate->fsmonitor_last_update); + if (istate->fsmonitor_dirty) { + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; + } + strbuf_release(&last_update); + trace2_data_intmax("fsmonitor", istate->repo, + "extension/invalid", 1); + return 0; } void fill_fsmonitor_bitmap(struct index_state *istate) diff --git a/read-cache-ll.h b/read-cache-ll.h index ed7d7aac38257e..d728e050e5fb23 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -182,7 +182,8 @@ struct index_state { drop_cache_tree : 1, updated_workdir : 1, updated_skipworktree : 1, - fsmonitor_has_run_once : 1; + fsmonitor_has_run_once : 1, + fsmonitor_extension_seen : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index c7631a204c8b2a..7034e30c80d2ac 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -4,11 +4,62 @@ #include "attr.h" #include "config.h" #include "environment.h" +#include "ewah/ewok.h" +#include "ewah/ewok_rlw.h" #include "fsmonitor.h" #include "fsmonitor-ll.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" +#include "strbuf.h" + +static void wrap_fsmn_ewah(struct strbuf *out, const struct strbuf *ewah) +{ + uint32_t value; + + put_be32(&value, 2); + strbuf_add(out, &value, sizeof(value)); + strbuf_addstr(out, "token"); + strbuf_addch(out, '\0'); + put_be32(&value, ewah->len); + strbuf_add(out, &value, sizeof(value)); + strbuf_addbuf(out, ewah); +} + +static void make_valid_fsmn(struct strbuf *out) +{ + struct ewah_bitmap *dirty = ewah_new(); + struct strbuf ewah = STRBUF_INIT; + + ewah_set(dirty, 0); + ewah_serialize_strbuf(dirty, &ewah); + wrap_fsmn_ewah(out, &ewah); + ewah_free(dirty); + strbuf_release(&ewah); +} + +static void make_raw_fsmn(struct strbuf *out, uint32_t bit_size, + const eword_t *words, uint32_t word_count, + uint32_t rlw) +{ + struct strbuf ewah = STRBUF_INIT; + uint32_t value; + uint32_t i; + + put_be32(&value, bit_size); + strbuf_add(&ewah, &value, sizeof(value)); + put_be32(&value, word_count); + strbuf_add(&ewah, &value, sizeof(value)); + for (i = 0; i < word_count; i++) { + eword_t word = htonll(words[i]); + + strbuf_add(&ewah, &word, sizeof(word)); + } + put_be32(&value, rlw); + strbuf_add(&ewah, &value, sizeof(value)); + wrap_fsmn_ewah(out, &ewah); + strbuf_release(&ewah); +} static int test_fsmonitor_content_recovery(const char *path) { @@ -43,6 +94,83 @@ static int test_fsmonitor_content_recovery(const char *path) return 0; } +static int fsmn_failed_closed(const struct index_state *istate) +{ + return istate->fsmonitor_extension_seen && + !istate->fsmonitor_last_update && !istate->fsmonitor_dirty; +} + +static int check_invalid_fsmn(const struct strbuf *encoded, + const char *description) +{ + struct index_state invalid = INDEX_STATE_INIT(the_repository); + + invalid.cache_nr = 1; + invalid.fsmonitor_last_update = xstrdup("old"); + invalid.fsmonitor_dirty = ewah_new(); + read_fsmonitor_extension(&invalid, encoded->buf, encoded->len); + if (!fsmn_failed_closed(&invalid)) + return error("%s FSMN was published", description); + return 0; +} + +static int test_fsmn_parser(void) +{ + struct index_state duplicate = INDEX_STATE_INIT(the_repository); + struct index_state truncated = INDEX_STATE_INIT(the_repository); + struct strbuf encoded = STRBUF_INIT; + struct strbuf malformed = STRBUF_INIT; + eword_t words[2] = { 0 }; + + duplicate.cache_nr = truncated.cache_nr = 1; + make_valid_fsmn(&encoded); + read_fsmonitor_extension(&duplicate, encoded.buf, encoded.len); + if (!duplicate.fsmonitor_last_update || + strcmp(duplicate.fsmonitor_last_update, "token") || + !duplicate.fsmonitor_dirty) + return error("valid FSMN was not published"); + read_fsmonitor_extension(&duplicate, encoded.buf, encoded.len); + if (!fsmn_failed_closed(&duplicate)) + return error("duplicate FSMN did not fail closed"); + + truncated.fsmonitor_last_update = xstrdup("old"); + truncated.fsmonitor_dirty = ewah_new(); + read_fsmonitor_extension(&truncated, encoded.buf, encoded.len - 1); + if (!fsmn_failed_closed(&truncated)) + return error("truncated FSMN was partially published"); + + rlw_set_literal_words(&words[0], 1); + make_raw_fsmn(&malformed, 1, words, 1, 0); + if (check_invalid_fsmn(&malformed, "out-of-bounds literal")) + return 1; + strbuf_reset(&malformed); + + words[0] = 0; + rlw_set_run_bit(&words[0], 1); + rlw_set_running_len(&words[0], 1); + make_raw_fsmn(&malformed, 1, words, 1, 0); + if (check_invalid_fsmn(&malformed, "oversized set-bit run")) + return 1; + strbuf_reset(&malformed); + + words[0] = words[1] = 0; + rlw_set_literal_words(&words[0], 1); + words[1] = 2; + make_raw_fsmn(&malformed, 1, words, 2, 0); + if (check_invalid_fsmn(&malformed, "set padding bit")) + return 1; + strbuf_reset(&malformed); + + words[1] = 1; + make_raw_fsmn(&malformed, 1, words, 2, 1); + if (check_invalid_fsmn(&malformed, "non-final RLW")) + return 1; + + strbuf_release(&malformed); + strbuf_release(&encoded); + return 0; +} + static int test_fsmonitor_directory_attributes(void) { struct attr_check *check; @@ -90,6 +218,8 @@ int cmd__read_cache(int argc, const char **argv) if (argc == 3 && !strcmp(argv[1], "--test-fsmonitor-content-recovery")) return test_fsmonitor_content_recovery(argv[2]); + if (argc == 2 && !strcmp(argv[1], "--test-fsmn-parser")) + return test_fsmn_parser(); if (argc == 2 && !strcmp(argv[1], "--test-fsmonitor-directory-attributes")) return test_fsmonitor_directory_attributes(); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 691148ae677113..f257a05f92930e 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -60,6 +60,10 @@ test_lazy_prereq HARDLINKS ' ln hardlink-a hardlink-b ' +test_expect_success 'FSMN parser fails closed' ' + test-tool read-cache --test-fsmn-parser +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From 16740fa703238bac65a842b6f804149d9662528c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:12 -0500 Subject: [PATCH 079/403] exclude: support nonblocking proof captures Validating a sidecar must recapture standard excludes before status can trust an empty result. Opening an exclude source that has become a named pipe may otherwise block the supposedly cheap validation. Add an explicit nonblocking flag to exclude-source proof creation and carry it into the existing anchored source-open operation. Reject unknown flags, request nonblocking captures for sidecar issuance, and update the existing bulk-scan and unit-test callers to pass zero, preserving their current blocking and symbolic-link policies. Add a focused FIFO unit test showing that an opted-in proof captures and validates an empty pipe without waiting. The later early-status consumer can reuse nonblocking capture without changing ordinary exclude handling. Signed-off-by: Taylor Blau --- exclude-source-proof.c | 11 ++++++++--- exclude-source-proof.h | 6 +++++- preload-index-bulk.c | 2 +- t/unit-tests/u-exclude-source-proof.c | 28 +++++++++++++++++++++++---- wt-status.c | 3 ++- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/exclude-source-proof.c b/exclude-source-proof.c index 1a2ebd5f87190b..83ee1626d93746 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -29,6 +29,7 @@ struct exclude_source_proof { struct strintmap entries_by_path[2]; size_t nr; size_t alloc; + unsigned nonblocking : 1; unsigned invalid : 1; }; @@ -187,7 +188,7 @@ static struct exclude_source_capture *capture_begin( struct exclude_source_proof *exclude_source_proof_create( struct index_state *istate, void *open_data, - exclude_source_open_parent_fn open_parent) + exclude_source_open_parent_fn open_parent, unsigned flags) { struct exclude_source_proof *proof; @@ -195,13 +196,16 @@ struct exclude_source_proof *exclude_source_proof_create( proof->istate = istate; proof->open_data = open_data; proof->open_parent = open_parent; + proof->nonblocking = + !!(flags & EXCLUDE_SOURCE_PROOF_NONBLOCKING); strintmap_init_with_options(&proof->entries_by_path[0], -1, NULL, 0); strintmap_init_with_options(&proof->entries_by_path[1], -1, NULL, 0); if (!EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN || !istate || !istate->repo || !istate->repo->hash_algo || - !open_parent) + !open_parent || + (flags & ~EXCLUDE_SOURCE_PROOF_NONBLOCKING)) proof->invalid = 1; return proof; } @@ -220,7 +224,8 @@ int exclude_source_capture_open(struct exclude_source_capture *capture) return -1; } return open_source_at(capture->parent_fd, capture->relative, - capture->nofollow, 0); + capture->nofollow, + capture->proof->nonblocking); } int exclude_source_capture_absent(struct exclude_source_capture *capture) diff --git a/exclude-source-proof.h b/exclude-source-proof.h index f1c03b4a3cbf5f..ab9b626c664380 100644 --- a/exclude-source-proof.h +++ b/exclude-source-proof.h @@ -19,9 +19,13 @@ struct stat; typedef int (*exclude_source_open_parent_fn)(void *data, const char *path); +enum exclude_source_proof_flags { + EXCLUDE_SOURCE_PROOF_NONBLOCKING = (1 << 0), +}; + struct exclude_source_proof *exclude_source_proof_create( struct index_state *istate, void *open_data, - exclude_source_open_parent_fn open_parent); + exclude_source_open_parent_fn open_parent, unsigned flags); struct exclude_source_capture *exclude_source_capture_begin( struct exclude_source_proof *proof, const char *path, int nofollow); diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 9aa15920b530b0..92ce36e8fe4850 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -249,7 +249,7 @@ int preload_bulk_collect(struct index_state *istate, int threads, if (!start_error) { if (scan.collect_untracked) { exclude_proof = exclude_source_proof_create( - istate, &scan, open_exclude_parent); + istate, &scan, open_exclude_parent, 0); exclude_dir.internal.exclude_source_proof = exclude_proof; setup_standard_excludes(&exclude_dir); diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index e579dbcd51247a..be1f2d046599b1 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -29,7 +29,7 @@ static int open_parent(void *data UNUSED, const char *path) static struct exclude_source_proof *new_proof(void) { return exclude_source_proof_create( - &istate, NULL, open_parent); + &istate, NULL, open_parent, 0); } static char *make_path(const char *name) @@ -201,7 +201,7 @@ void test_exclude_source_proof__rejects_conflicting_observations(void) void test_exclude_source_proof__digest_deduplicates_and_ignores_identity(void) { struct exclude_source_proof *first_proof = - exclude_source_proof_create(&istate, NULL, open_parent); + exclude_source_proof_create(&istate, NULL, open_parent, 0); struct exclude_source_proof *second_proof; struct object_id first, second; char *parent = make_path("parent"); @@ -216,7 +216,7 @@ void test_exclude_source_proof__digest_deduplicates_and_ignores_identity(void) cl_must_pass(unlink(source)); write_file_buf(source, "content", 7); second_proof = - exclude_source_proof_create(&istate, NULL, open_parent); + exclude_source_proof_create(&istate, NULL, open_parent, 0); record_file(second_proof, source); record_file(second_proof, source); cl_must_pass(exclude_source_proof_digest( @@ -249,7 +249,7 @@ void test_exclude_source_proof__rejects_open_failure(void) void test_exclude_source_proof__fails_closed_without_parent_opener(void) { struct exclude_source_proof *proof = - exclude_source_proof_create(&istate, NULL, NULL); + exclude_source_proof_create(&istate, NULL, NULL, 0); cl_assert(!exclude_source_capture_begin(proof, "/dev/null", 0)); cl_assert(!exclude_source_proof_validate(proof)); @@ -410,6 +410,25 @@ void test_exclude_source_proof__rejects_nonempty_fifo_replacement(void) free(parent); } +void test_exclude_source_proof__captures_fifo_without_blocking(void) +{ + struct exclude_source_proof *proof = + exclude_source_proof_create( + &istate, NULL, open_parent, + EXCLUDE_SOURCE_PROOF_NONBLOCKING); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + cl_must_pass(mkfifo(source, 0600)); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + #else #define EMPTY_TEST(name) void name(void) {} @@ -432,5 +451,6 @@ SKIP_TEST(test_exclude_source_proof__reresolves_absent_source_parent) SKIP_TEST(test_exclude_source_proof__accepts_dev_null) SKIP_TEST(test_exclude_source_proof__accepts_empty_fifo_replacement) SKIP_TEST(test_exclude_source_proof__rejects_nonempty_fifo_replacement) +SKIP_TEST(test_exclude_source_proof__captures_fifo_without_blocking) #endif diff --git a/wt-status.c b/wt-status.c index 5eb618ec0f4e04..4de9940765ceeb 100644 --- a/wt-status.c +++ b/wt-status.c @@ -982,7 +982,8 @@ static void wt_status_prepare_exclude_proof( s->certify_exclude_context = context; s->certify_exclude_proof = exclude_source_proof_create( s->repo->index, context, - wt_status_open_exclude_parent); + wt_status_open_exclude_parent, + EXCLUDE_SOURCE_PROOF_NONBLOCKING); } dir->internal.exclude_source_proof = s->certify_exclude_proof; From eec942ed4795439816226de08a70f1c3f09ebab2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:48:20 -0500 Subject: [PATCH 080/403] preload-index: classify sparse-aware bulk stat observations A bulk directory worker must locate each observed tracked path and decide whether a directory has tracked descendants. Plain pathname ordering cannot answer either question correctly for sparse indexes. Add sparse-aware entry and descendant lookups with unseen, clean, content-check, and fallback states. Compare observed metadata with ie_match_stat(), and make duplicate observations fall back through an atomic compare-and-exchange or the existing queue mutex. Skip staged, intent-to-add, skip-worktree, removed, and otherwise ineligible entries. Register the index classifier in Make, CMake, and Meson without introducing deletion outcomes or content proofs. Signed-off-by: Taylor Blau --- Makefile | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + preload-index-bulk-index.c | 101 ++++++++++++++++++++++++++++ preload-index-bulk.h | 10 +++ preload-index.h | 7 ++ 6 files changed, 121 insertions(+) create mode 100644 preload-index-bulk-index.c diff --git a/Makefile b/Makefile index f9bd42aba71222..51059dadddf171 100644 --- a/Makefile +++ b/Makefile @@ -1382,6 +1382,7 @@ LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o ifdef PRELOAD_INDEX_BULK_BACKEND +PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-index.o PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) endif diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 1eb0a9fc0ef646..a9e4e6aaa9d10c 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -667,6 +667,7 @@ parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") list(APPEND libgit_SOURCES + preload-index-bulk-index.c preload-index-bulk-thread.c) endif() diff --git a/meson.build b/meson.build index 68cd5819811f3e..1f8667e551a9c9 100644 --- a/meson.build +++ b/meson.build @@ -1346,6 +1346,7 @@ elif host_machine.system() == 'windows' elif host_machine.system() == 'darwin' compat_sources += 'compat/darwin/procinfo.c' libgit_sources += [ + 'preload-index-bulk-index.c', 'preload-index-bulk-thread.c', ] else diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c new file mode 100644 index 00000000000000..3c8bfad7c2a631 --- /dev/null +++ b/preload-index-bulk-index.c @@ -0,0 +1,101 @@ +#include "git-compat-util.h" +#include "preload-index-bulk.h" +#include "read-cache-ll.h" + +#ifndef __has_builtin +#define __has_builtin(x) 0 +#endif + +int preload_bulk_index_position(struct preload_bulk_scan *scan, + const char *path, size_t path_len) +{ + if (path_len > INT_MAX) + return -1; + return index_name_pos_sparse(scan->istate, path, path_len); +} + +int preload_bulk_index_pos_has_tracked_descendants( + struct preload_bulk_scan *scan, const char *path, size_t path_len, + int pos) +{ + struct index_state *istate = scan->istate; + const struct cache_entry *ce; + + if (pos >= 0) + return 0; + pos = -pos - 1; + while ((unsigned int)pos < istate->cache_nr) { + ce = istate->cache[pos]; + if (ce_namelen(ce) < path_len || + memcmp(ce->name, path, path_len)) + return 0; + if (ce_namelen(ce) == path_len) { + pos++; + continue; + } + if (ce->name[path_len] == '/') + return 1; + if ((unsigned char)ce->name[path_len] > '/') + return 0; + pos++; + } + return 0; +} + +static int record_tracked_state(struct preload_bulk_worker *worker, int pos, + unsigned char state) +{ + struct preload_bulk_scan *scan = worker->scan; + int recorded = 1; + +#if GIT_GNUC_PREREQ(4, 7) || \ + (__has_builtin(__atomic_compare_exchange_n) && \ + __has_builtin(__atomic_store_n)) + unsigned char expected = PRELOAD_BULK_TRACKED_UNSEEN; + + if (!__atomic_compare_exchange_n(&scan->tracked_state[pos], &expected, + state, 0, __ATOMIC_RELAXED, + __ATOMIC_RELAXED)) { + __atomic_store_n(&scan->tracked_state[pos], + PRELOAD_BULK_TRACKED_FALLBACK, + __ATOMIC_RELAXED); + recorded = 0; + } +#else + pthread_mutex_lock(&scan->queue.mutex); + if (scan->tracked_state[pos] != PRELOAD_BULK_TRACKED_UNSEEN) { + state = PRELOAD_BULK_TRACKED_FALLBACK; + recorded = 0; + } + scan->tracked_state[pos] = state; + pthread_mutex_unlock(&scan->queue.mutex); +#endif + return recorded; +} + +static int tracked_entry_is_eligible(const struct cache_entry *ce) +{ + return !ce_stage(ce) && + !ce_intent_to_add(ce) && + !ce_skip_worktree(ce) && + !(ce->ce_flags & (CE_VALID | CE_REMOVE)) && + (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)); +} + +void preload_bulk_record_tracked( + struct preload_bulk_worker *worker, int pos, const struct stat *st) +{ + struct preload_bulk_scan *scan = worker->scan; + struct cache_entry *ce = scan->istate->cache[pos]; + unsigned int changed; + unsigned char state; + + if (!tracked_entry_is_eligible(ce)) + return; + changed = ie_match_stat( + scan->istate, ce, (struct stat *)st, + CE_MATCH_RACY_IS_DIRTY | CE_MATCH_IGNORE_FSMONITOR); + state = changed ? PRELOAD_BULK_TRACKED_CONTENT_CHECK : + PRELOAD_BULK_TRACKED_CLEAN; + record_tracked_state(worker, pos, state); +} diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 64cb000474413a..64f5e9cc9a167d 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -2,6 +2,7 @@ #define PRELOAD_INDEX_BULK_H #include "git-compat-util.h" +#include "preload-index.h" #include "thread-utils.h" struct preload_bulk_dir_identity { @@ -54,9 +55,11 @@ struct preload_bulk_backend { }; struct preload_bulk_scan { + struct index_state *istate; const struct preload_bulk_backend *backend; struct preload_bulk_queue queue; struct preload_bulk_worker *workers; + unsigned char *tracked_state; int root_fd; int threads; }; @@ -70,6 +73,13 @@ void preload_bulk_schedule_directory( const struct preload_bulk_dir_identity *parent_identity, const struct preload_bulk_dir_identity *child_identity, const char *name, const char *path, size_t path_len); +int preload_bulk_index_position(struct preload_bulk_scan *scan, + const char *path, size_t path_len); +int preload_bulk_index_pos_has_tracked_descendants( + struct preload_bulk_scan *scan, const char *path, size_t path_len, + int pos); +void preload_bulk_record_tracked( + struct preload_bulk_worker *worker, int pos, const struct stat *st); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); diff --git a/preload-index.h b/preload-index.h index 251b1ed88e9820..4b21e22b6afb19 100644 --- a/preload-index.h +++ b/preload-index.h @@ -5,6 +5,13 @@ struct index_state; struct pathspec; struct repository; +enum preload_bulk_tracked_state { + PRELOAD_BULK_TRACKED_UNSEEN = 0, + PRELOAD_BULK_TRACKED_CLEAN, + PRELOAD_BULK_TRACKED_CONTENT_CHECK, + PRELOAD_BULK_TRACKED_FALLBACK, +}; + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags); From 30377a18732d9072a4d6fe00a0fb3590aa5e71ee Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:46:00 -0700 Subject: [PATCH 081/403] fsmonitor: add an untracked-cache token extension FSMN identifies the token associated with tracked fsmonitor state, but the independently serialized UNTR extension cannot identify the provider boundary associated with its directory snapshot. The mere presence of both extensions cannot prove that their states agree. Define and document FSUC as a versioned optional index extension containing one NUL-terminated provider token. Register its reader with index-extension dispatch; reject empty tokens, tokens longer than 4 KiB, duplicate records, unsupported versions, truncation, and trailing data before publishing state. Provide the matching serializer and release the retained token with the index. Add a read-cache helper regression for a valid record, serializer round trip, duplicate, and truncated record. Register that helper in t/t7519-status-fsmonitor.sh. The format is independently testable; deciding when its token authenticates UNTR is a separate change. Signed-off-by: Taylor Blau --- Documentation/gitformat-index.adoc | 13 ++++ fsmonitor-ll.h | 5 ++ fsmonitor.c | 48 ++++++++++++ read-cache-ll.h | 5 +- read-cache.c | 5 ++ t/helper/test-read-cache.c | 116 +++++++++++++++++++++-------- t/t7519-status-fsmonitor.sh | 4 + 7 files changed, 162 insertions(+), 34 deletions(-) diff --git a/Documentation/gitformat-index.adoc b/Documentation/gitformat-index.adoc index f6a427cb495990..aaa9c29b4653b8 100644 --- a/Documentation/gitformat-index.adoc +++ b/Documentation/gitformat-index.adoc @@ -366,6 +366,19 @@ The remaining data of each directory block is grouped by type: - An ewah bitmap, the n-th bit indicates whether the n-th index entry is not CE_FSMONITOR_VALID. +== File System Monitor untracked-cache token + + The file system monitor untracked-cache token records the provider + token associated with an untracked-cache snapshot. The signature for + this extension is { 'F', 'S', 'U', 'C' }. + + The extension consists of: + + - 32-bit version number: the current version is 1. + + - A NUL-terminated string containing the opaque file system monitor + token associated with the untracked-cache data. + == End of Index Entry The End of Index Entry (EOIE) is used to locate the end of the variable diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 7f78ad21c8d0b0..1028e630e9a912 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -15,6 +15,11 @@ extern struct trace_key trace_fsmonitor; */ int read_fsmonitor_extension(struct index_state *istate, const void *data, unsigned long sz); +int read_fsmonitor_untracked_extension(struct index_state *istate, + const void *data, unsigned long sz); +void write_fsmonitor_untracked_extension(struct strbuf *sb, + struct index_state *istate); + /* * Fill the fsmonitor_dirty ewah bits with their state from the index, * before it is split during writing. diff --git a/fsmonitor.c b/fsmonitor.c index ebec5620dbb630..26d00b5d912dfb 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -198,6 +198,54 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, return 0; } +#define FSMONITOR_UNTRACKED_EXTENSION_VERSION 1 + +int read_fsmonitor_untracked_extension(struct index_state *istate, + const void *data, unsigned long sz) +{ + const char *p = data; + const char *nul; + uint32_t version; + + if (istate->fsmonitor_untracked_extension_seen) + goto invalid; + istate->fsmonitor_untracked_extension_seen = 1; + if (sz < sizeof(version) + 2) + goto invalid; + version = get_be32(p); + p += sizeof(version); + sz -= sizeof(version); + if (version != FSMONITOR_UNTRACKED_EXTENSION_VERSION) + goto invalid; + nul = memchr(p, '\0', sz); + if (!nul || nul == p || (size_t)(nul - p + 1) != sz || + nul - p > FSMONITOR_TOKEN_MAX) + goto invalid; + + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = xstrdup(p); + return 0; + +invalid: + istate->fsmonitor_untracked_extension_seen = 1; + istate->fsmonitor_untracked_extension_invalid = 1; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/invalid-extension", 1); + return 0; +} + +void write_fsmonitor_untracked_extension(struct strbuf *sb, + struct index_state *istate) +{ + uint32_t version; + + put_be32(&version, FSMONITOR_UNTRACKED_EXTENSION_VERSION); + strbuf_add(sb, &version, sizeof(version)); + strbuf_addstr(sb, istate->fsmonitor_last_update); + strbuf_addch(sb, '\0'); +} + void fill_fsmonitor_bitmap(struct index_state *istate) { unsigned int i, skipped = 0; diff --git a/read-cache-ll.h b/read-cache-ll.h index d728e050e5fb23..f71889101e2775 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -183,13 +183,16 @@ struct index_state { updated_workdir : 1, updated_skipworktree : 1, fsmonitor_has_run_once : 1, - fsmonitor_extension_seen : 1; + fsmonitor_extension_seen : 1, + fsmonitor_untracked_extension_seen : 1, + fsmonitor_untracked_extension_invalid : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; struct object_id oid; struct untracked_cache *untracked; char *fsmonitor_last_update; + char *fsmonitor_untracked_token; struct ewah_bitmap *fsmonitor_dirty; struct mem_pool *ce_mem_pool; struct progress *progress; diff --git a/read-cache.c b/read-cache.c index b853d79c72aa5f..43d4b15d54ad7e 100644 --- a/read-cache.c +++ b/read-cache.c @@ -71,6 +71,7 @@ #define CACHE_EXT_LINK 0x6c696e6b /* "link" */ #define CACHE_EXT_UNTRACKED 0x554E5452 /* "UNTR" */ #define CACHE_EXT_FSMONITOR 0x46534D4E /* "FSMN" */ +#define CACHE_EXT_FSMONITOR_UNTRACKED 0x46535543 /* "FSUC" */ #define CACHE_EXT_ENDOFINDEXENTRIES 0x454F4945 /* "EOIE" */ #define CACHE_EXT_INDEXENTRYOFFSETTABLE 0x49454F54 /* "IEOT" */ #define CACHE_EXT_SPARSE_DIRECTORIES 0x73646972 /* "sdir" */ @@ -1777,6 +1778,9 @@ static int read_index_extension(struct index_state *istate, case CACHE_EXT_FSMONITOR: read_fsmonitor_extension(istate, data, sz); break; + case CACHE_EXT_FSMONITOR_UNTRACKED: + read_fsmonitor_untracked_extension(istate, data, sz); + break; case CACHE_EXT_ENDOFINDEXENTRIES: case CACHE_EXT_INDEXENTRYOFFSETTABLE: /* already handled in do_read_index() */ @@ -2466,6 +2470,7 @@ void release_index(struct index_state *istate) free_name_hash(istate); cache_tree_free(&(istate->cache_tree)); free(istate->fsmonitor_last_update); + free(istate->fsmonitor_untracked_token); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 7034e30c80d2ac..4698265f5c090f 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -13,6 +13,87 @@ #include "setup.h" #include "strbuf.h" +static int test_fsmonitor_content_recovery(const char *path) +{ + struct index_state *istate; + struct cache_entry *ce; + struct stat_data empty = { 0 }; + struct stat st; + int pos; + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + istate = the_repository->index; + pos = index_name_pos(istate, path, strlen(path)); + if (pos < 0) + return error("path is not indexed: %s", path); + ce = istate->cache[pos]; + if (lstat(path, &st)) + return error_errno("unable to stat indexed path"); + + fsmonitor_invalidate_cache_entry(ce); + if (memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("invalidation did not poison cached stat data"); + if (ie_match_stat_with_content_check(istate, ce, &st, 0)) + return error("clean content did not match"); + if (!memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("verified clean entry retained poisoned stat data"); + if (!(ce->ce_flags & CE_UPDATE_IN_BASE) || + !(istate->cache_changed & CE_ENTRY_CHANGED)) + return error("verified stat refresh was not marked for persistence"); + return 0; +} + +static int fsuc_failed_closed(const struct index_state *istate) +{ + return istate->fsmonitor_untracked_extension_seen && + istate->fsmonitor_untracked_extension_invalid && + !istate->fsmonitor_untracked_token; +} + +static int test_fsuc_parser(void) +{ + struct index_state duplicate = INDEX_STATE_INIT(the_repository); + struct index_state truncated = INDEX_STATE_INIT(the_repository); + struct strbuf encoded = STRBUF_INIT; + struct strbuf written = STRBUF_INIT; + uint32_t version; + + put_be32(&version, 1); + strbuf_add(&encoded, &version, sizeof(version)); + strbuf_addstr(&encoded, "token"); + strbuf_addch(&encoded, '\0'); + read_fsmonitor_untracked_extension( + &duplicate, encoded.buf, encoded.len); + if (duplicate.fsmonitor_untracked_extension_invalid || + !duplicate.fsmonitor_untracked_token || + strcmp(duplicate.fsmonitor_untracked_token, "token")) + return error("valid FSUC was not published"); + + duplicate.fsmonitor_last_update = xstrdup("token"); + write_fsmonitor_untracked_extension(&written, &duplicate); + if (written.len != encoded.len || + memcmp(written.buf, encoded.buf, encoded.len)) + return error("FSUC did not round-trip"); + read_fsmonitor_untracked_extension( + &duplicate, encoded.buf, encoded.len); + if (!fsuc_failed_closed(&duplicate)) + return error("duplicate FSUC did not fail closed"); + + truncated.fsmonitor_untracked_token = xstrdup("old"); + read_fsmonitor_untracked_extension( + &truncated, encoded.buf, sizeof(version)); + if (!fsuc_failed_closed(&truncated)) + return error("truncated FSUC was partially published"); + + free(duplicate.fsmonitor_last_update); + strbuf_release(&written); + strbuf_release(&encoded); + return 0; +} + static void wrap_fsmn_ewah(struct strbuf *out, const struct strbuf *ewah) { uint32_t value; @@ -61,39 +142,6 @@ static void make_raw_fsmn(struct strbuf *out, uint32_t bit_size, strbuf_release(&ewah); } -static int test_fsmonitor_content_recovery(const char *path) -{ - struct index_state *istate; - struct cache_entry *ce; - struct stat_data empty = { 0 }; - struct stat st; - int pos; - - setup_git_directory(the_repository); - repo_config(the_repository, git_default_config, NULL); - if (repo_read_index(the_repository) < 0) - return error("unable to read test index"); - istate = the_repository->index; - pos = index_name_pos(istate, path, strlen(path)); - if (pos < 0) - return error("path is not indexed: %s", path); - ce = istate->cache[pos]; - if (lstat(path, &st)) - return error_errno("unable to stat indexed path"); - - fsmonitor_invalidate_cache_entry(ce); - if (memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) - return error("invalidation did not poison cached stat data"); - if (ie_match_stat_with_content_check(istate, ce, &st, 0)) - return error("clean content did not match"); - if (!memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) - return error("verified clean entry retained poisoned stat data"); - if (!(ce->ce_flags & CE_UPDATE_IN_BASE) || - !(istate->cache_changed & CE_ENTRY_CHANGED)) - return error("verified stat refresh was not marked for persistence"); - return 0; -} - static int fsmn_failed_closed(const struct index_state *istate) { return istate->fsmonitor_extension_seen && @@ -218,6 +266,8 @@ int cmd__read_cache(int argc, const char **argv) if (argc == 3 && !strcmp(argv[1], "--test-fsmonitor-content-recovery")) return test_fsmonitor_content_recovery(argv[2]); + if (argc == 2 && !strcmp(argv[1], "--test-fsuc-parser")) + return test_fsuc_parser(); if (argc == 2 && !strcmp(argv[1], "--test-fsmn-parser")) return test_fsmn_parser(); if (argc == 2 && diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index f257a05f92930e..f29bea912efd18 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -64,6 +64,10 @@ test_expect_success 'FSMN parser fails closed' ' test-tool read-cache --test-fsmn-parser ' +test_expect_success 'FSUC parser fails closed' ' + test-tool read-cache --test-fsuc-parser +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From 61f7705f80af869583e6e2670ec58b84b2e24485 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:26 -0500 Subject: [PATCH 082/403] status: answer exact clean status before index deserialization An issued clean-status sidecar has no latency benefit while status still deserializes the index before checking it. Moving the check earlier is safe only if the recorded proof is revalidated around an empty builtin-fsmonitor delta. Attempt the sidecar only for the literal top-level porcelain-v2 command on an eligible main worktree. Load the bounded record, pin the named local-APFS index, recapture excludes without blocking, and check configuration, attributes, repository identity, HEAD, and provider mode. Query the builtin provider directly from the stored token. Keep the attribute and exclude proofs alive across that query. Recheck configuration, HEAD, fresh replacement-ref and repository state, attribute contents and namespace, exclude-source identity, and both the held and named index before accepting an empty delta. Return without deserializing index entries only when every check succeeds; otherwise continue through ordinary status. Unsupported anchored-open platforms take that ordinary path. Register the fast-path source with Make and Meson. Extend the existing sidecar integration suite for read-only hits, dirty worktree shapes, loose, packed, and custom replacement refs, sidecar and exclude FIFOs, changed configuration, attributes, HEAD, null-checksum indexes, and post-query replacement or exclude races. Signed-off-by: Taylor Blau --- Makefile | 1 + builtin/commit.c | 12 + clean-status-fast.c | 252 ++++++++++++++++ clean-status-internal.h | 2 - clean-status.h | 5 + fsmonitor.c | 2 +- fsmonitor.h | 2 + meson.build | 1 + t/t7519-status-fsmonitor.sh | 70 +++++ t/t7530-status-clean-sidecar.sh | 507 +++++++++++++++++++++++++++++++- wt-status.c | 43 +++ wt-status.h | 1 + 12 files changed, 888 insertions(+), 10 deletions(-) create mode 100644 clean-status-fast.c diff --git a/Makefile b/Makefile index 0ee895f3dd106c..46601baeddb8a8 100644 --- a/Makefile +++ b/Makefile @@ -1137,6 +1137,7 @@ LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += clean-status-sidecar.o +LIB_OBJS += clean-status-fast.o LIB_OBJS += clean-status-sidecar-issue.o LIB_OBJS += color.o LIB_OBJS += column.o diff --git a/builtin/commit.c b/builtin/commit.c index feff3c8df156d5..d9bf5270a0e030 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1617,6 +1617,7 @@ struct repository *repo UNUSED) int default_status_command = argc == 1 && (!prefix || !*prefix); int exact_clean_command = argc == 2 && !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); + int exact_clean_query; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1703,6 +1704,17 @@ struct repository *repo UNUSED) default_status_command && !s.pathspec.nr; if (s.allow_clean_status_shortcuts) clean_status_enable_external_history(the_repository); + exact_clean_query = exact_clean_command && + status_format == STATUS_FORMAT_PORCELAIN_V2 && + !s.pathspec.nr && !s.show_branch && !s.show_stash && + !s.show_ignored_mode && !s.null_termination && !s.verbose && + s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES; + s.certify_clean_status = exact_clean_query; + if (exact_clean_query && + clean_status_try_sidecar(the_repository, &clean_digest)) { + wt_status_collect_free_buffers(&s); + return 0; + } if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) diff --git a/clean-status-fast.c b/clean-status-fast.c new file mode 100644 index 00000000000000..f41951079f2094 --- /dev/null +++ b/clean-status-fast.c @@ -0,0 +1,252 @@ +#include "git-compat-util.h" +#include "abspath.h" +#include "attr-fingerprint.h" +#include "clean-status.h" +#include "clean-status-index.h" +#include "clean-status-sidecar.h" +#include "dir.h" +#include "environment.h" +#include "exclude-source-proof.h" +#include "fsmonitor.h" +#include "fsmonitor-settings.h" +#include "object-name.h" +#include "repository.h" +#include "trace2.h" +#include "worktree.h" +#include "wrapper.h" + +#if !EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + +int clean_status_try_sidecar( + struct repository *repo UNUSED, + const struct clean_status_config_digest *config UNUSED) +{ + return 0; +} + +#else + +struct fast_exclude_context { + int root_fd; +}; + +static void trace_miss(struct repository *repo, const char *reason) +{ + trace2_data_string("status", repo, "clean-proof/miss", reason); +} + +static int open_exclude_parent(void *data, const char *path) +{ + struct fast_exclude_context *context = data; + int flags = O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC; + +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + if (is_absolute_path(path)) + return open(path, flags); + return openat(context->root_fd, path, flags); +} + +static int capture_standard_excludes( + struct repository *repo, struct fast_exclude_context *context, + struct exclude_source_proof **proof, struct object_id *digest) +{ + struct dir_struct dir = DIR_INIT; + int ret; + + *proof = exclude_source_proof_create( + repo->index, context, open_exclude_parent, + EXCLUDE_SOURCE_PROOF_NONBLOCKING); + dir.internal.exclude_source_proof = *proof; + setup_standard_excludes(&dir); + ret = exclude_source_proof_digest(*proof, repo->hash_algo, digest); + dir_clear(&dir); + return ret; +} + +static int attr_snapshot_still_matches( + struct repository *repo, const struct attr_source_snapshot *snapshot) +{ + const struct attr_fingerprint *expected = + attr_source_snapshot_fingerprint(snapshot); + struct attr_fingerprint current; + + return expected && + !attr_fingerprint_repository(repo, ¤t) && + current.sources_present == expected->sources_present && + !memcmp(current.content_hash, expected->content_hash, + repo->hash_algo->rawsz) && + !memcmp(current.namespace_hash, expected->namespace_hash, + repo->hash_algo->rawsz); +} + +static int fast_path_test_barrier(void) +{ + const char *ready = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_READY"); + const char *resume = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_RESUME"); + struct strbuf buf = STRBUF_INIT; + int fd; + int ret; + + if (!ready && !resume) + return 0; + if (!ready || !resume) + return -1; + fd = open(resume, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + write_file(ready, "ready"); + ret = strbuf_read(&buf, fd, 1) > 0 ? 0 : -1; + close(fd); + strbuf_release(&buf); + return ret; +} + +static int current_worktree_is_main(struct repository *repo) +{ + struct worktree *worktree = get_current_worktree(repo); + int ret = worktree && is_main_worktree(worktree); + + free_worktree(worktree); + return ret; +} + +int clean_status_try_sidecar( + struct repository *repo, + const struct clean_status_config_digest *config) +{ + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct clean_status_index_snapshot index = { .fd = -1 }; + struct attr_source_snapshot *attrs = NULL; + struct exclude_source_proof *excludes = NULL; + struct fast_exclude_context exclude_context = { .root_fd = -1 }; + struct fsmonitor_query_result query = FSMONITOR_QUERY_RESULT_INIT; + struct clean_status_config_digest fresh_config; + struct object_id exclude_digest, head_tree; + struct stat scanned_worktree; + unsigned char repo_hash[GIT_MAX_RAWSZ]; + char *query_token = NULL; + int ret = 0; + + if (!config->finalized || config->filter_configured || + getenv(INDEX_ENVIRONMENT) || is_bare_repository(repo) || + !repo_get_work_tree(repo) || + !current_worktree_is_main(repo) || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC) { + trace_miss(repo, "fast-repository-shape"); + goto done; + } + if (clean_status_sidecar_load( + repo->index_file, repo->hash_algo, &record)) { + trace_miss(repo, "fast-sidecar-missing-or-corrupt"); + goto done; + } + if (clean_status_sidecar_pin_source( + repo->index_file, &record.sidecar, repo->hash_algo, + &index)) { + trace_miss(repo, "fast-index-mismatch"); + goto done; + } + if (memcmp(config->hash, record.sidecar.proof.config_hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-config-changed"); + goto done; + } + if (attr_source_snapshot_repository(repo, &attrs)) { + trace_miss(repo, "fast-attributes"); + goto done; + } + exclude_context.root_fd = open_nofollow( + repo_get_work_tree(repo), + O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC); + if (exclude_context.root_fd < 0 || + fstat(exclude_context.root_fd, &scanned_worktree) || + capture_standard_excludes( + repo, &exclude_context, &excludes, &exclude_digest) || + !oideq(&exclude_digest, + &record.sidecar.proof.exclude_source_digest)) { + trace_miss(repo, "fast-excludes"); + goto done; + } + if (clean_status_repository_fingerprint( + repo, attrs, &index, &scanned_worktree, repo_hash)) { + trace_miss(repo, "fast-repository-unavailable"); + goto done; + } + if (memcmp(repo_hash, record.sidecar.proof.repo_hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-repository-input"); + goto done; + } + if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || + !oideq(&head_tree, &record.sidecar.proof.head_tree)) { + trace_miss(repo, "fast-head-changed"); + goto done; + } + + query_token = xmemdupz( + record.sidecar.token, record.sidecar.token_len); + if (query_builtin_fsmonitor(query_token, &query) != + FSMONITOR_QUERY_DELTA || + query.paths.len) { + trace_miss(repo, "fast-provider-changed"); + goto done; + } + if (fast_path_test_barrier()) { + trace_miss(repo, "fast-test-barrier"); + goto done; + } + + if (clean_status_config_read_repository(repo, &fresh_config) || + fresh_config.filter_configured || + memcmp(fresh_config.hash, config->hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-config-raced"); + goto done; + } + if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || + !oideq(&head_tree, &record.sidecar.proof.head_tree)) { + trace_miss(repo, "fast-head-raced"); + goto done; + } + if (clean_status_repository_fingerprint( + repo, attrs, &index, &scanned_worktree, repo_hash) || + memcmp(repo_hash, record.sidecar.proof.repo_hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-repository-raced"); + goto done; + } + if (!attr_snapshot_still_matches(repo, attrs)) { + trace_miss(repo, "fast-attributes-raced"); + goto done; + } + if (!exclude_source_proof_validate(excludes)) { + trace_miss(repo, "fast-excludes-raced"); + goto done; + } + if (!clean_status_index_snapshot_still_matches_path( + &index, repo->index_file, repo->hash_algo)) { + trace_miss(repo, "fast-index-raced"); + goto done; + } + + trace2_data_intmax("status", repo, "clean-proof/hit", 1); + ret = 1; + +done: + free(query_token); + fsmonitor_query_result_release(&query); + if (exclude_context.root_fd >= 0) + close(exclude_context.root_fd); + exclude_source_proof_release(excludes); + attr_source_snapshot_free(attrs); + clean_status_index_snapshot_release(&index); + clean_status_sidecar_record_release(&record); + return ret; +} + +#endif /* EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN */ diff --git a/clean-status-internal.h b/clean-status-internal.h index 62f73acfcd00cf..f37fdcad4ca79e 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -47,7 +47,5 @@ struct clean_status_state { }; struct clean_status_state *clean_status_get_state(struct index_state *istate); -int clean_status_revalidated_token_matches( - const struct index_state *istate); #endif /* CLEAN_STATUS_INTERNAL_H */ diff --git a/clean-status.h b/clean-status.h index f3db36e3ea21ff..8aea521a3bb4bf 100644 --- a/clean-status.h +++ b/clean-status.h @@ -51,6 +51,8 @@ void clean_status_release_proof_epoch( int clean_status_fsmonitor_config_mismatch(const struct index_state *istate); int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); +int clean_status_revalidated_token_matches( + const struct index_state *istate); int clean_status_has_persistent_fsmonitor_semantic_history( const struct index_state *istate); @@ -85,6 +87,9 @@ int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, struct lock_file *index_lock); +int clean_status_try_sidecar( + struct repository *repo, + const struct clean_status_config_digest *config); int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); diff --git a/fsmonitor.c b/fsmonitor.c index ee15d75bab4ca5..e8d91d6681cc09 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -839,7 +839,7 @@ enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( return FSMONITOR_QUERY_ERROR; } -static enum fsmonitor_query_outcome query_builtin_fsmonitor( +enum fsmonitor_query_outcome query_builtin_fsmonitor( const char *since_token, struct fsmonitor_query_result *result) { const char *test_sequence = diff --git a/fsmonitor.h b/fsmonitor.h index e6c617bec77f04..136f4769c36fc2 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -37,6 +37,8 @@ struct fsmonitor_query_result { void fsmonitor_query_result_release(struct fsmonitor_query_result *result); enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( const struct strbuf *raw, struct fsmonitor_query_result *result); +enum fsmonitor_query_outcome query_builtin_fsmonitor( + const char *since_token, struct fsmonitor_query_result *result); /* * A pathname monitor cannot prove that every name for a multiply-linked diff --git a/meson.build b/meson.build index 0d2ab0d178df09..e584fe0f7b36dd 100644 --- a/meson.build +++ b/meson.build @@ -342,6 +342,7 @@ libgit_sources = [ 'clean-status-index.c', 'clean-status-manifest.c', 'clean-status-sidecar.c', + 'clean-status-fast.c', 'clean-status-sidecar-issue.c', 'color.c', 'column.c', diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 66c5a3a28bfdf3..a82dd36019f7f6 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -594,6 +594,76 @@ prepare_builtin_closure_repo () { ) } +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'bare status reuses a current tracked fsmonitor proof' ' + test_when_finished "rm -rf builtin-tracked-clean" && + prepare_builtin_closure_repo builtin-tracked-clean && + ( + cd builtin-tracked-clean && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/prime && + test_must_be_empty .git/prime && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status >.git/clean && + test_grep "nothing to commit, working tree clean" \ + .git/clean && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/clean.trace && + test_trace2_data status index/cache-tree-match 1 \ + <.git/clean.trace && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/exact.trace" \ + git status --porcelain=v2 >.git/exact && + test_must_be_empty .git/exact && + ! test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/exact.trace && + test_grep \ + "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/exact.trace && + + test_write_lines changed >tracked && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/dirty.trace" \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + ! test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/dirty.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout -- tracked && + test_write_lines staged >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git write-tree >.git/staged-tree && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/staged-prime && + test_grep "^M tracked$" .git/staged-prime && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/staged.trace" \ + git status >.git/staged && + test_grep "Changes to be committed:" .git/staged && + test_grep "modified:.*tracked" .git/staged && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/staged.trace && + ! test_trace2_data status index/cache-tree-match 1 \ + <.git/staged.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin closure initializes a new untracked cache' ' test_when_finished "rm -rf builtin-closure-new-uc" && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index e3197d42ca3925..93b3705a089173 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -60,6 +60,138 @@ prime_semantic_history () { test_grep FSCF "$repo/.git/index" } +issue_sidecar () { + repo=$1 && + prime_semantic_history "$repo" && + git -C "$repo" config core.autocrlf false && + bulk_status -C "$repo" status --porcelain=v2 >actual.issue && + test_must_be_empty actual.issue && + test_path_is_file "$repo/.git/index.csts" +} + +assert_fallback_matches_oracle () { + repo=$1 && + sidecar_trace=$2 && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C "$repo" \ + status --porcelain=v2 >expect && + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/$sidecar_trace" \ + git -C "$repo" status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep ! "\"key\":\"clean-proof/hit\"" "$sidecar_trace" +} + +assert_custom_replace_fallback_matches_oracle () { + repo=$1 && + sidecar_trace=$2 && + GIT_REPLACE_REF_BASE=refs/status-replace/ \ + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C "$repo" \ + status --porcelain=v2 >expect && + GIT_REPLACE_REF_BASE=refs/status-replace/ \ + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/$sidecar_trace" \ + git -C "$repo" status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep ! "\"key\":\"clean-proof/hit\"" "$sidecar_trace" +} + +replacement_tree () { + repo=$1 && + blob=$(printf "replacement\n" | + git -C "$repo" hash-object -w --stdin) && + printf "100644 blob %s\ttracked\n" "$blob" | + git -C "$repo" mktree +} + +cleanup_fast_race () { + if test -n "$status_pid" + then + kill "$status_pid" 2>/dev/null || : + wait "$status_pid" 2>/dev/null || : + fi && + status_pid= && + exec 9>&- && + rm -f "$ready" "$resume" +} + +wait_for_fast_ready () { + for i in $(test_seq 1 1000) + do + test "$(cat "$ready" 2>/dev/null)" = ready && return 0 + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +start_fast_raced_status () { + repo=$1 && + ready=$TRASH_DIRECTORY/$repo.fast-ready && + resume=$TRASH_DIRECTORY/$repo.fast-resume && + race_trace=$TRASH_DIRECTORY/$repo.fast-trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + : >"$ready" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_READY="$ready" \ + GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + git -C "$repo" status --porcelain=v2 \ + >raced.actual 9>&- & + status_pid=$! + } && + wait_for_fast_ready +} + +start_issue_raced_status () { + repo=$1 && + ready=$TRASH_DIRECTORY/$repo.issue-ready && + resume=$TRASH_DIRECTORY/$repo.issue-resume && + race_trace=$TRASH_DIRECTORY/$repo.issue-trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + : >"$ready" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + test_env \ + GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_READY="$ready" \ + GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + bulk_status -C "$repo" status --porcelain=v2 \ + >raced.actual 9>&- & + status_pid=$! + } && + wait_for_fast_ready +} + +stop_after_fast_fallback () { + for i in $(test_seq 1 1000) + do + if grep -q "\"value\":\"fast-excludes-raced\"" \ + "$race_trace" + then + kill "$status_pid" 2>/dev/null || return 1 + wait "$status_pid" 2>/dev/null || : + status_pid= + return 0 + fi + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +finish_fast_raced_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= +} + test_expect_success DURABLE_FSMONITOR \ 'exact clean status installs a sidecar without rewriting the index' ' test_when_finished "stop_daemon sidecar-issue" && @@ -85,7 +217,160 @@ test_expect_success DURABLE_FSMONITOR \ issue.trace && test_grep "\"key\":\"preload/bulk_provider_applied\"" issue.trace && test_grep "\"key\":\"clean-proof/sidecar\"" issue.trace && - test_grep ! "\"label\":\"do_write_index\"" issue.trace + test_grep ! "\"label\":\"do_write_index\"" issue.trace && + + GIT_TRACE2_EVENT="$PWD/hit.trace" \ + git -C sidecar-issue status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" hit.trace && + test_grep ! "\"label\":\"do_read_index\"" hit.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'exact clean status certifies an existing untracked cache' ' + test_when_finished "stop_daemon sidecar-untracked-cache" && + setup_repo sidecar-untracked-cache && + git -C sidecar-untracked-cache config core.untrackedCache true && + git -C sidecar-untracked-cache config core.autocrlf false && + bulk_status -C sidecar-untracked-cache status --porcelain=2 \ + >actual.1 && + test_must_be_empty actual.1 && + bulk_status -C sidecar-untracked-cache status --porcelain=2 \ + >actual.2 && + test_must_be_empty actual.2 && + test_grep UNTR sidecar-untracked-cache/.git/index && + + test_env GIT_TRACE2_EVENT="$PWD/untracked-cache-issue.trace" \ + bulk_status -C sidecar-untracked-cache \ + status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_file sidecar-untracked-cache/.git/index.csts && + test_grep ! \ + "\"key\":\"preload/bulk_useful\"" \ + untracked-cache-issue.trace && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + untracked-cache-issue.trace >untracked-cache-read-directory && + test_line_count = 1 untracked-cache-read-directory && + test_grep "\"key\":\"proof_valid\",\"value\":\"1\"" \ + untracked-cache-issue.trace && + test_grep FSUC sidecar-untracked-cache/.git/index && + + GIT_TRACE2_EVENT="$PWD/untracked-cache-hit.trace" \ + git -C sidecar-untracked-cache status --porcelain=v2 \ + >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" \ + untracked-cache-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" \ + untracked-cache-hit.trace +' + +test_expect_success POSIXPERM,DURABLE_FSMONITOR \ + 'an incomplete untracked traversal cannot issue a sidecar' ' + test_when_finished "stop_daemon sidecar-unreadable" && + setup_repo sidecar-unreadable && + git -C sidecar-unreadable config core.untrackedCache true && + git -C sidecar-unreadable config core.autocrlf false && + prime_semantic_history sidecar-unreadable && + mkdir sidecar-unreadable/hidden && + test_when_finished "chmod u+rwx sidecar-unreadable/hidden" && + test_write_lines untracked >sidecar-unreadable/hidden/untracked && + chmod a-r sidecar-unreadable/hidden && + + test_env GIT_TRACE2_EVENT="$PWD/unreadable.trace" \ + bulk_status -C sidecar-unreadable \ + status --porcelain=v2 >actual 2>err && + test_must_be_empty actual && + test_grep "could not open directory .hidden/." err && + test_path_is_missing sidecar-unreadable/.git/index.csts && + test_grep "\"value\":\"issue-scan-or-index-shape\"" \ + unreadable.trace && + + chmod u+rwx sidecar-unreadable/hidden && + git -C sidecar-unreadable status --porcelain=v2 >actual && + test_grep "^? hidden/" actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'a replaced worktree root cannot inherit a sidecar' ' + test_when_finished "stop_daemon sidecar-root-race" && + test_when_finished "stop_daemon sidecar-root-race.scanned" && + test_when_finished "cleanup_fast_race" && + setup_repo sidecar-root-race && + git -C sidecar-root-race config core.untrackedCache true && + prime_semantic_history sidecar-root-race && + git -C sidecar-root-race config core.autocrlf false && + cp -R sidecar-root-race sidecar-root-race.replacement && + test_write_lines replacement-only \ + >sidecar-root-race.replacement/replacement-only && + rm -f sidecar-root-race.replacement/.git/index.csts && + + start_issue_raced_status sidecar-root-race && + mv sidecar-root-race sidecar-root-race.scanned && + mv sidecar-root-race.replacement sidecar-root-race && + finish_fast_raced_status && + + test_must_be_empty raced.actual && + test_path_is_missing sidecar-root-race/.git/index.csts && + test_grep \ + "\"category\":\"dir\",\"label\":\"read_directory\"" \ + "$race_trace" && + test_grep ! \ + "\"key\":\"preload/bulk_untracked_complete\",\"value\":\"1\"" \ + "$race_trace" && + test_grep "\"key\":\"proof_valid\",\"value\":\"1\"" "$race_trace" && + test_grep "\"value\":\"issue-pinned-inputs\"" "$race_trace" +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'an existing per-directory exclude FIFO cannot block sidecar issuance' ' + test_when_finished "stop_daemon sidecar-issue-fifo" && + setup_repo sidecar-issue-fifo && + test_when_finished "rm -f sidecar-issue-fifo/.gitignore" && + git -C sidecar-issue-fifo config core.untrackedCache true && + git -C sidecar-issue-fifo config core.autocrlf false && + prime_semantic_history sidecar-issue-fifo && + mkfifo sidecar-issue-fifo/.gitignore && + + test_env GIT_TRACE2_EVENT="$PWD/issue-fifo.trace" \ + bulk_status -C sidecar-issue-fifo \ + status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_file sidecar-issue-fifo/.git/index.csts && + test_grep "\"key\":\"clean-proof/sidecar\"" issue-fifo.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'a clean exact status replaces a stale sidecar' ' + test_when_finished "stop_daemon sidecar-reissue" && + setup_repo sidecar-reissue && + git -C sidecar-reissue config core.untrackedCache true && + issue_sidecar sidecar-reissue && + test_write_lines untracked >sidecar-reissue/untracked && + git -C sidecar-reissue status --porcelain=2 >dirty && + test_grep "^? untracked$" dirty && + rm sidecar-reissue/untracked && + + test_env GIT_TRACE2_EVENT="$PWD/reissue.trace" \ + bulk_status -C sidecar-reissue status --porcelain=v2 \ + >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/sidecar\"" reissue.trace && + test_grep ! \ + "\"key\":\"preload/bulk_useful\"" \ + reissue.trace && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + reissue.trace >reissue-read-directory && + test_line_count = 1 reissue-read-directory && + test_grep ! "\"label\":\"do_write_index\"" reissue.trace && + + GIT_TRACE2_EVENT="$PWD/reissued-hit.trace" \ + git -C sidecar-reissue status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" reissued-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" reissued-hit.trace ' test_expect_success DURABLE_FSMONITOR \ @@ -109,7 +394,7 @@ test_expect_success DURABLE_FSMONITOR \ ' test_expect_success DURABLE_FSMONITOR \ - 'external attributes, untracked cache, and alternate indexes are rejected' ' + 'external attributes and alternate indexes are rejected' ' test_when_finished "stop_daemon sidecar-inputs" && setup_repo sidecar-inputs && prime_semantic_history sidecar-inputs && @@ -121,11 +406,6 @@ test_expect_success DURABLE_FSMONITOR \ test_path_is_missing sidecar-inputs/.git/index.csts && rm sidecar-inputs/.git/info/attributes && - git -C sidecar-inputs config core.untrackedCache true && - bulk_status -C sidecar-inputs status --porcelain=v2 >actual && - test_must_be_empty actual && - test_path_is_missing sidecar-inputs/.git/index.csts && - cp sidecar-inputs/.git/index sidecar-inputs/.git/alternate-index && test_env GIT_INDEX_FILE="$PWD/sidecar-inputs/.git/alternate-index" \ bulk_status -C sidecar-inputs status --porcelain=v2 >actual && @@ -133,6 +413,219 @@ test_expect_success DURABLE_FSMONITOR \ test_path_is_missing sidecar-inputs/.git/alternate-index.csts ' +test_expect_success DURABLE_FSMONITOR \ + 'a fast hit remains read-only without optional locks' ' + test_when_finished "stop_daemon sidecar-read-only" && + setup_repo sidecar-read-only && + issue_sidecar sidecar-read-only && + cp sidecar-read-only/.git/index.csts sidecar.before && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/read-only.trace" \ + git -C sidecar-read-only status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" read-only.trace && + test_grep ! "\"label\":\"do_read_index\"" read-only.trace && + test_cmp sidecar.before sidecar-read-only/.git/index.csts +' + +test_expect_success DURABLE_FSMONITOR \ + 'provider changes fall back for each dirty worktree shape' ' + test_when_finished "stop_daemon sidecar-modified" && + test_when_finished "stop_daemon sidecar-deleted" && + test_when_finished "stop_daemon sidecar-renamed" && + test_when_finished "stop_daemon sidecar-untracked" && + + setup_repo sidecar-modified && + issue_sidecar sidecar-modified && + echo changed >sidecar-modified/tracked && + assert_fallback_matches_oracle sidecar-modified modified.trace && + test_grep "^1 .M " actual && + + setup_repo sidecar-deleted && + issue_sidecar sidecar-deleted && + rm sidecar-deleted/tracked && + assert_fallback_matches_oracle sidecar-deleted deleted.trace && + test_grep "^1 .D " actual && + + setup_repo sidecar-renamed && + issue_sidecar sidecar-renamed && + mv sidecar-renamed/tracked sidecar-renamed/renamed && + assert_fallback_matches_oracle sidecar-renamed renamed.trace && + test_grep "^1 .D " actual && + test_grep "^? renamed" actual && + + setup_repo sidecar-untracked && + issue_sidecar sidecar-untracked && + echo untracked >sidecar-untracked/new-file && + assert_fallback_matches_oracle sidecar-untracked untracked.trace && + test_grep "^? new-file" actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'loose and packed replace refs invalidate a sidecar' ' + test_when_finished "stop_daemon sidecar-replace" && + setup_repo sidecar-replace && + issue_sidecar sidecar-replace && + old_tree=$(git -C sidecar-replace rev-parse "HEAD^{tree}") && + new_tree=$(replacement_tree sidecar-replace) && + git -C sidecar-replace replace "$old_tree" "$new_tree" && + + assert_fallback_matches_oracle sidecar-replace replace-loose.trace && + test_grep "^1 M. " actual && + git -C sidecar-replace pack-refs --all && + test_path_is_missing \ + "sidecar-replace/.git/refs/replace/$old_tree" && + assert_fallback_matches_oracle sidecar-replace replace-packed.trace && + test_grep "^1 M. " actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'a custom replace namespace invalidates a sidecar' ' + test_when_finished "stop_daemon sidecar-custom-replace" && + setup_repo sidecar-custom-replace && + issue_sidecar sidecar-custom-replace && + old_tree=$(git -C sidecar-custom-replace rev-parse "HEAD^{tree}") && + new_tree=$(replacement_tree sidecar-custom-replace) && + GIT_REPLACE_REF_BASE=refs/status-replace/ \ + git -C sidecar-custom-replace update-ref \ + "refs/status-replace/$old_tree" "$new_tree" && + + assert_custom_replace_fallback_matches_oracle \ + sidecar-custom-replace replace-custom.trace && + test_grep "^1 M. " actual +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'a sidecar FIFO cannot block or supply a hit' ' + test_when_finished "stop_daemon sidecar-fifo" && + test_when_finished "rm -f sidecar-fifo/.git/index.csts" && + setup_repo sidecar-fifo && + issue_sidecar sidecar-fifo && + rm sidecar-fifo/.git/index.csts && + mkfifo sidecar-fifo/.git/index.csts && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/fifo.trace" \ + git -C sidecar-fifo status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep ! "\"key\":\"clean-proof/hit\"" fifo.trace && + test_grep "\"value\":\"fast-sidecar-missing-or-corrupt\"" fifo.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'non-provider proof inputs invalidate a sidecar' ' + test_when_finished "stop_daemon sidecar-metadata" && + setup_repo sidecar-metadata && + issue_sidecar sidecar-metadata && + + git -C sidecar-metadata config status.relativePaths false && + assert_fallback_matches_oracle sidecar-metadata config.trace && + test_grep "\"value\":\"fast-config-changed\"" config.trace && + git -C sidecar-metadata config --unset status.relativePaths && + + cp sidecar-metadata/.git/info/exclude info-exclude && + test_write_lines ignored >sidecar-metadata/.git/info/exclude && + assert_fallback_matches_oracle sidecar-metadata exclude.trace && + test_grep "\"value\":\"fast-excludes\"" exclude.trace && + mv info-exclude sidecar-metadata/.git/info/exclude && + + test_write_lines "tracked ident" \ + >sidecar-metadata/.git/info/attributes && + assert_fallback_matches_oracle sidecar-metadata attributes.trace && + test_grep "\"value\":\"fast-repository-unavailable\"" attributes.trace && + rm sidecar-metadata/.git/info/attributes && + + blob=$(printf "different\n" | + git -C sidecar-metadata hash-object -w --stdin) && + tree=$(printf "100644 blob %s\ttracked\n" "$blob" | + git -C sidecar-metadata mktree) && + commit=$(printf "different tree\n" | + git -C sidecar-metadata commit-tree "$tree" -p HEAD) && + git -C sidecar-metadata update-ref HEAD "$commit" && + assert_fallback_matches_oracle sidecar-metadata head.trace && + test_grep "\"value\":\"fast-head-changed\"" head.trace && + test_grep "^1 M. " actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'a v4 skipHash index is not certified' ' + test_when_finished "stop_daemon sidecar-v4" && + setup_repo sidecar-v4 && + prime_semantic_history sidecar-v4 && + git -C sidecar-v4 config index.version 4 && + git -C sidecar-v4 config index.skipHash true && + git -C sidecar-v4 update-index --force-write-index && + git -C sidecar-v4 config core.autocrlf false && + + dd if=/dev/zero of=zeros bs=20 count=1 2>/dev/null && + tail -c 20 sidecar-v4/.git/index >trailer && + test_cmp_bin zeros trailer && + test_env GIT_TRACE2_EVENT="$PWD/v4.trace" \ + bulk_status -C sidecar-v4 status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-v4/.git/index.csts && + test_grep ! "\"key\":\"clean-proof/hit\"" v4.trace +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'an existing exclude FIFO cannot block fast-path capture' ' + test_when_finished "stop_daemon sidecar-exclude-fifo" && + setup_repo sidecar-exclude-fifo && + exclude_file=$(mktemp \ + "${TMPDIR:-/tmp}/git-status-exclude-fifo.XXXXXX") && + test_when_finished "rm -f \"$exclude_file\"" && + git -C sidecar-exclude-fifo config core.excludesFile \ + "$exclude_file" && + issue_sidecar sidecar-exclude-fifo && + rm "$exclude_file" && + mkfifo "$exclude_file" && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/exclude-fifo.trace" \ + git -C sidecar-exclude-fifo status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" exclude-fifo.trace +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'a raced exclude FIFO cannot block sidecar validation' ' + test_when_finished "stop_daemon sidecar-exclude-race" && + test_when_finished "cleanup_fast_race" && + setup_repo sidecar-exclude-race && + exclude_file=$(mktemp \ + "${TMPDIR:-/tmp}/git-status-exclude-race.XXXXXX") && + test_when_finished "rm -f \"$exclude_file\"" && + test_write_lines ignored >"$exclude_file" && + git -C sidecar-exclude-race config core.excludesFile \ + "$exclude_file" && + issue_sidecar sidecar-exclude-race && + + start_fast_raced_status sidecar-exclude-race && + rm "$exclude_file" && + mkfifo "$exclude_file" && + printf "resume\n" >&9 && + exec 9>&- && + stop_after_fast_fallback && + test_must_be_empty raced.actual && + test_grep ! "\"key\":\"clean-proof/hit\"" "$race_trace" && + test_grep "\"value\":\"fast-excludes-raced\"" "$race_trace" +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'a replace ref created after the provider query prevents a hit' ' + test_when_finished "stop_daemon sidecar-replace-race" && + test_when_finished "cleanup_fast_race" && + setup_repo sidecar-replace-race && + issue_sidecar sidecar-replace-race && + old_tree=$(git -C sidecar-replace-race rev-parse "HEAD^{tree}") && + new_tree=$(replacement_tree sidecar-replace-race) && + + start_fast_raced_status sidecar-replace-race && + git -C sidecar-replace-race update-ref \ + "refs/replace/$old_tree" "$new_tree" && + finish_fast_raced_status && + test_grep ! "\"key\":\"clean-proof/hit\"" "$race_trace" && + test_grep "\"value\":\"fast-repository-raced\"" "$race_trace" +' + test_expect_success DURABLE_FSMONITOR \ 'normal status restores namespace-specific history outside the index' ' test_when_finished "stop_daemon external-history" && diff --git a/wt-status.c b/wt-status.c index 4de9940765ceeb..e5d2e958206058 100644 --- a/wt-status.c +++ b/wt-status.c @@ -12,6 +12,7 @@ #include "dir.h" #include "commit.h" #include "clean-status.h" +#include "clean-status-index.h" #include "diff.h" #include "environment.h" #include "exclude-source-proof.h" @@ -716,6 +717,11 @@ static void wt_status_collect_changes_worktree(struct wt_status *s) size_t direct_nr; struct rev_info rev; + if (s->tracked_from_fsmonitor) { + preload_index_bulk_result_consume(s->repo->index); + return; + } + direct = wt_status_collect_preload_changes(s, &direct_nr); repo_init_revisions(s->repo, &rev, NULL); setup_revisions(0, NULL, &rev, NULL); @@ -1137,6 +1143,7 @@ static void wt_status_finish_untracked_cache_preload(struct wt_status *s) if (!index_invalidated) return; + s->tracked_from_fsmonitor = 0; preload_index_bulk_result_clear(istate); trace2_data_intmax("status", s->repo, "fsmonitor/exclude-index-invalidated", @@ -1620,6 +1627,26 @@ wt_status_close_semantic_fsmonitor_token( return WT_STATUS_TOKEN_CLOSURE_ACCEPTED; } +static int wt_status_tracked_fsmonitor_state_is_current( + struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + + return s->allow_clean_status_shortcuts && + !s->certify_clean_status && !s->pathspec.nr && + !getenv(INDEX_ENVIRONMENT) && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && + is_fsmonitor_refreshed(istate) && + !fsmonitor_has_pending_token(istate) && + istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + *istate->fsmonitor_last_update && + clean_status_revalidated_token_matches(istate) && + !clean_status_manifest_global_fallback(istate) && + !clean_status_worktree_manifest_needs_refresh(istate); +} + static int wt_status_close_fsmonitor_token( struct wt_status *s, struct semantic_verify_proof *proof, unsigned int refresh_flags, int require_untracked, @@ -1644,6 +1671,20 @@ static int wt_status_close_fsmonitor_token( wt_status_discard_semantic_verify( s, &proof, "provider-unavailable"); + if (!refreshed_before_closure && attr_inputs_match && + wt_status_tracked_fsmonitor_state_is_current(s) && + clean_status_index_entries_are_certifiable(istate)) { + s->tracked_from_fsmonitor = 1; + trace2_data_intmax( + "status", s->repo, + "fsmonitor/tracked-clean", 1); + return 0; + } + if (refreshed_before_closure && attr_inputs_match && + s->tracked_from_fsmonitor && + wt_status_tracked_fsmonitor_state_is_current(s)) + return closure.refresh_result; + s->tracked_from_fsmonitor = 0; if (!refreshed_before_closure && attr_inputs_match) return refresh_index( istate, refresh_flags, &s->pathspec, @@ -1661,6 +1702,7 @@ static int wt_status_close_fsmonitor_token( return closure.refresh_result; } + s->tracked_from_fsmonitor = 0; closure.can_prime = require_untracked && istate->untracked && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && @@ -1750,6 +1792,7 @@ void wt_status_invalidate_refresh(struct wt_status *s) { struct index_state *istate = s->repo->index; + s->tracked_from_fsmonitor = 0; if (s->untracked_from_token_closure) { string_list_clear(&s->untracked, 0); string_list_clear(&s->ignored, 0); diff --git a/wt-status.h b/wt-status.h index afee3b4ad9840c..6f5300fe8e5481 100644 --- a/wt-status.h +++ b/wt-status.h @@ -145,6 +145,7 @@ struct wt_status { int workdir_dirty; unsigned allow_clean_status_shortcuts : 1; unsigned certify_clean_status : 1; + unsigned tracked_from_fsmonitor : 1; unsigned untracked_from_token_closure : 1; unsigned untracked_from_preload : 1; unsigned bulk_update_index_stat : 1; From d35fdbad860c5443ee4229ca34a995648ee5861a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 07:26:38 -0500 Subject: [PATCH 083/403] preload-index: bind APFS scans to a stable worktree root A pathname-based directory walk can cross into a replacement worktree or another mount after the scan begins. Metadata from that namespace cannot safely certify entries from the original worktree. Open the worktree directory with O_NOFOLLOW, accept only a local APFS root, and capture its device, filesystem identity, and stat data. Reopen the configured worktree at completion and compare its identity using the namespace helper supplied by S01/P08. Register the Darwin root helpers in Make, CMake, and Meson. Their presence does not yet activate bulk preload. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin-root.c | 100 ++++++++++++++++++++++++ compat/preload-index/bulk-darwin.h | 24 ++++++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 5 +- meson.build | 5 +- preload-index-bulk.h | 2 + 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 compat/preload-index/bulk-darwin-root.c create mode 100644 compat/preload-index/bulk-darwin.h diff --git a/compat/preload-index/bulk-darwin-root.c b/compat/preload-index/bulk-darwin-root.c new file mode 100644 index 00000000000000..f12f730761c99c --- /dev/null +++ b/compat/preload-index/bulk-darwin-root.c @@ -0,0 +1,100 @@ +#include "git-compat-util.h" + +#include + +#include "compat/preload-index/bulk-darwin.h" +#include "path-namespace.h" +#include "repository.h" +#include "preload-index-bulk.h" + +static int same_fsid(const fsid_t *a, const fsid_t *b) +{ + return !memcmp(a, b, sizeof(*a)); +} + +static int stat_local_apfs(int fd, struct stat *st, struct statfs *fs) +{ + if (fstat(fd, st) || fstatfs(fd, fs)) + return -1; + if (!S_ISDIR(st->st_mode) || !(fs->f_flags & MNT_LOCAL) || + strcmp(fs->f_fstypename, "apfs")) { + errno = EXDEV; + return -1; + } + return 0; +} + +int preload_bulk_darwin_fd_on_root_mount(struct preload_bulk_scan *scan, + int fd, struct stat *st_out) +{ + struct preload_bulk_darwin_data *data = scan->platform_data; + struct statfs fs; + struct stat st; + + if (stat_local_apfs(fd, &st, &fs)) + return -1; + if (st.st_dev != data->root_stat.st_dev || + !same_fsid(&fs.f_fsid, &data->root_fsid)) { + errno = EXDEV; + return -1; + } + if (st_out) + *st_out = st; + return 0; +} + +const char *preload_bulk_darwin_open_root(struct preload_bulk_scan *scan) +{ + struct preload_bulk_darwin_data *data; + struct statfs fs; + struct stat st; + + CALLOC_ARRAY(data, 1); + scan->platform_data = data; + scan->root_fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (scan->root_fd < 0 || + stat_local_apfs(scan->root_fd, &st, &fs)) + return "unsupported-filesystem"; + return NULL; +} + +const char *preload_bulk_darwin_snapshot_root(struct preload_bulk_scan *scan) +{ + struct preload_bulk_darwin_data *data = scan->platform_data; + struct statfs fs; + struct stat st; + + if (stat_local_apfs(scan->root_fd, &st, &fs)) + return "unsupported-filesystem"; + data->root_stat = st; + data->root_fsid = fs.f_fsid; + return NULL; +} + +const char *preload_bulk_darwin_validate_root(struct preload_bulk_scan *scan) +{ + struct preload_bulk_darwin_data *data = scan->platform_data; + struct stat root_after; + int fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + + if (fd < 0 || + preload_bulk_darwin_fd_on_root_mount(scan, fd, &root_after) || + !path_namespace_stat_equal(&data->root_stat, &root_after)) { + if (fd >= 0) + close(fd); + return "namespace-race"; + } + close(fd); + return NULL; +} + +void preload_bulk_darwin_release(struct preload_bulk_scan *scan) +{ + if (scan->root_fd >= 0) { + close(scan->root_fd); + scan->root_fd = -1; + } + FREE_AND_NULL(scan->platform_data); +} diff --git a/compat/preload-index/bulk-darwin.h b/compat/preload-index/bulk-darwin.h new file mode 100644 index 00000000000000..d96ed99240c3d7 --- /dev/null +++ b/compat/preload-index/bulk-darwin.h @@ -0,0 +1,24 @@ +#ifndef PRELOAD_INDEX_BULK_DARWIN_H +#define PRELOAD_INDEX_BULK_DARWIN_H + +#ifdef __APPLE__ + +#include + +struct preload_bulk_scan; + +struct preload_bulk_darwin_data { + struct stat root_stat; + fsid_t root_fsid; +}; + +int preload_bulk_darwin_fd_on_root_mount(struct preload_bulk_scan *scan, + int fd, struct stat *st_out); +const char *preload_bulk_darwin_open_root(struct preload_bulk_scan *scan); +const char *preload_bulk_darwin_snapshot_root(struct preload_bulk_scan *scan); +const char *preload_bulk_darwin_validate_root(struct preload_bulk_scan *scan); +void preload_bulk_darwin_release(struct preload_bulk_scan *scan); + +#endif /* __APPLE__ */ + +#endif /* PRELOAD_INDEX_BULK_DARWIN_H */ diff --git a/config.mak.uname b/config.mak.uname index af3f927d8b8a50..31f4524d1158ab 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -163,6 +163,7 @@ ifeq ($(uname_S),Darwin) HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/darwin/procinfo.o PRELOAD_INDEX_BULK_BACKEND = darwin + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-darwin-root.o ifeq ($(uname_M),arm64) HOMEBREW_PREFIX = /opt/homebrew diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index a9e4e6aaa9d10c..67fb3f2d9ba7c8 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -275,7 +275,10 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") - list(APPEND compat_SOURCES compat/darwin/procinfo.c) + add_compile_definitions(USE_ST_TIMESPEC) + list(APPEND compat_SOURCES + compat/darwin/procinfo.c + compat/preload-index/bulk-darwin-root.c) endif() if(CMAKE_SYSTEM_NAME STREQUAL "Windows") diff --git a/meson.build b/meson.build index 1f8667e551a9c9..9134428deb5d31 100644 --- a/meson.build +++ b/meson.build @@ -1344,7 +1344,10 @@ if host_machine.system() == 'linux' elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' - compat_sources += 'compat/darwin/procinfo.c' + compat_sources += [ + 'compat/darwin/procinfo.c', + 'compat/preload-index/bulk-darwin-root.c', + ] libgit_sources += [ 'preload-index-bulk-index.c', 'preload-index-bulk-thread.c', diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 64f5e9cc9a167d..9e8b085160a873 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -55,8 +55,10 @@ struct preload_bulk_backend { }; struct preload_bulk_scan { + struct repository *repo; struct index_state *istate; const struct preload_bulk_backend *backend; + void *platform_data; struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; From 8ed382452d75569c9844dbcd2839ad19282d2078 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:47:29 -0700 Subject: [PATCH 084/403] fsmonitor: bind untracked-cache state to its token A well-formed FSMN bitmap and a well-formed FSUC record still do not prove that a populated untracked-cache root and tracked entries were observed at the same provider boundary. Trusting mismatched tokens can suppress the directory validation needed to detect a change. After all index extensions have been read, trust a populated untracked-cache root only when a valid on-disk FSMN token matches its FSUC token. An absent cache or root needs no token pairing. Clear the untracked proof when either extension is invalid, and write FSUC beside FSMN only when an untracked cache, a current FSMN token, and valid untracked state are present. Extend the existing read-cache parser regression to check matching and mismatched tokens and to verify that a rejected FSMN clears tracked-token validity. An invalid pair continues through ordinary untracked-cache validation. Signed-off-by: Taylor Blau --- fsmonitor-ll.h | 1 + fsmonitor.c | 16 ++++++++++++++++ read-cache-ll.h | 2 ++ read-cache.c | 17 +++++++++++++++++ t/helper/test-read-cache.c | 22 ++++++++++++++++++++-- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 1028e630e9a912..8591a166665bd5 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -19,6 +19,7 @@ int read_fsmonitor_untracked_extension(struct index_state *istate, const void *data, unsigned long sz); void write_fsmonitor_untracked_extension(struct strbuf *sb, struct index_state *istate); +void prepare_fsmonitor_untracked(struct index_state *istate); /* * Fill the fsmonitor_dirty ewah bits with their state from the index, diff --git a/fsmonitor.c b/fsmonitor.c index 26d00b5d912dfb..7b90f80405909c 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -177,6 +177,7 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, ewah_free(istate->fsmonitor_dirty); istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); istate->fsmonitor_dirty = fsmonitor_dirty; + istate->fsmonitor_token_valid = 1; trace2_data_string("index", NULL, "extension/fsmn/read/token", istate->fsmonitor_last_update); @@ -187,6 +188,8 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, invalid: istate->fsmonitor_extension_seen = 1; + istate->fsmonitor_token_valid = 0; + istate->fsmonitor_untracked_valid = 0; FREE_AND_NULL(istate->fsmonitor_last_update); if (istate->fsmonitor_dirty) { ewah_free(istate->fsmonitor_dirty); @@ -229,6 +232,7 @@ int read_fsmonitor_untracked_extension(struct index_state *istate, invalid: istate->fsmonitor_untracked_extension_seen = 1; istate->fsmonitor_untracked_extension_invalid = 1; + istate->fsmonitor_untracked_valid = 0; FREE_AND_NULL(istate->fsmonitor_untracked_token); trace2_data_intmax("fsmonitor", istate->repo, "untracked/invalid-extension", 1); @@ -246,6 +250,18 @@ void write_fsmonitor_untracked_extension(struct strbuf *sb, strbuf_addch(sb, '\0'); } +void prepare_fsmonitor_untracked(struct index_state *istate) +{ + istate->fsmonitor_untracked_valid = + !istate->fsmonitor_untracked_extension_invalid && + (!istate->untracked || !istate->untracked->root || + (istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token))); +} + void fill_fsmonitor_bitmap(struct index_state *istate) { unsigned int i, skipped = 0; diff --git a/read-cache-ll.h b/read-cache-ll.h index f71889101e2775..db5b13d06b946c 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -183,7 +183,9 @@ struct index_state { updated_workdir : 1, updated_skipworktree : 1, fsmonitor_has_run_once : 1, + fsmonitor_token_valid : 1, fsmonitor_extension_seen : 1, + fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1; enum sparse_index_mode sparse_index; diff --git a/read-cache.c b/read-cache.c index 43d4b15d54ad7e..57cc73dec90a0e 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1982,6 +1982,7 @@ static void post_read_index_from(struct index_state *istate) check_ce_order(istate); tweak_untracked_cache(istate); tweak_split_index(istate); + prepare_fsmonitor_untracked(istate); tweak_fsmonitor(istate); } @@ -3077,6 +3078,22 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, goto out; } } + if (write_extensions & WRITE_FSMONITOR_EXTENSION && + istate->untracked && + istate->fsmonitor_last_update && + istate->fsmonitor_untracked_valid) { + strbuf_reset(&sb); + + write_fsmonitor_untracked_extension(&sb, istate); + err = write_index_ext_header(f, eoie_c, + CACHE_EXT_FSMONITOR_UNTRACKED, + sb.len) < 0; + hashwrite(f, sb.buf, sb.len); + if (err) { + ret = -1; + goto out; + } + } if (istate->sparse_index) { if (write_index_ext_header(f, eoie_c, CACHE_EXT_SPARSE_DIRECTORIES, 0) < 0) { ret = -1; diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 4698265f5c090f..372b55b419d6b4 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -3,6 +3,7 @@ #include "test-tool.h" #include "attr.h" #include "config.h" +#include "dir.h" #include "environment.h" #include "ewah/ewok.h" #include "ewah/ewok_rlw.h" @@ -57,6 +58,8 @@ static int test_fsuc_parser(void) { struct index_state duplicate = INDEX_STATE_INIT(the_repository); struct index_state truncated = INDEX_STATE_INIT(the_repository); + struct untracked_cache untracked = { 0 }; + struct untracked_cache_dir root = { 0 }; struct strbuf encoded = STRBUF_INIT; struct strbuf written = STRBUF_INIT; uint32_t version; @@ -77,6 +80,17 @@ static int test_fsuc_parser(void) if (written.len != encoded.len || memcmp(written.buf, encoded.buf, encoded.len)) return error("FSUC did not round-trip"); + duplicate.fsmonitor_token_valid = 1; + duplicate.untracked = &untracked; + untracked.root = &root; + prepare_fsmonitor_untracked(&duplicate); + if (!duplicate.fsmonitor_untracked_valid) + return error("matching FSMN and FSUC tokens were not paired"); + free(duplicate.fsmonitor_last_update); + duplicate.fsmonitor_last_update = xstrdup("other"); + prepare_fsmonitor_untracked(&duplicate); + if (duplicate.fsmonitor_untracked_valid) + return error("mismatched FSMN and FSUC tokens were paired"); read_fsmonitor_untracked_extension( &duplicate, encoded.buf, encoded.len); if (!fsuc_failed_closed(&duplicate)) @@ -145,7 +159,8 @@ static void make_raw_fsmn(struct strbuf *out, uint32_t bit_size, static int fsmn_failed_closed(const struct index_state *istate) { return istate->fsmonitor_extension_seen && - !istate->fsmonitor_last_update && !istate->fsmonitor_dirty; + !istate->fsmonitor_last_update && !istate->fsmonitor_dirty && + !istate->fsmonitor_token_valid; } static int check_invalid_fsmn(const struct strbuf *encoded, @@ -156,6 +171,7 @@ static int check_invalid_fsmn(const struct strbuf *encoded, invalid.cache_nr = 1; invalid.fsmonitor_last_update = xstrdup("old"); invalid.fsmonitor_dirty = ewah_new(); + invalid.fsmonitor_token_valid = 1; read_fsmonitor_extension(&invalid, encoded->buf, encoded->len); if (!fsmn_failed_closed(&invalid)) return error("%s FSMN was published", description); @@ -173,7 +189,8 @@ static int test_fsmn_parser(void) duplicate.cache_nr = truncated.cache_nr = 1; make_valid_fsmn(&encoded); read_fsmonitor_extension(&duplicate, encoded.buf, encoded.len); - if (!duplicate.fsmonitor_last_update || + if (!duplicate.fsmonitor_token_valid || + !duplicate.fsmonitor_last_update || strcmp(duplicate.fsmonitor_last_update, "token") || !duplicate.fsmonitor_dirty) return error("valid FSMN was not published"); @@ -183,6 +200,7 @@ static int test_fsmn_parser(void) truncated.fsmonitor_last_update = xstrdup("old"); truncated.fsmonitor_dirty = ewah_new(); + truncated.fsmonitor_token_valid = 1; read_fsmonitor_extension(&truncated, encoded.buf, encoded.len - 1); if (!fsmn_failed_closed(&truncated)) return error("truncated FSMN was partially published"); From fefc815d814158a5fe7b17625fee334552f557f0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 17:20:24 -0500 Subject: [PATCH 085/403] Documentation: describe status clean-proof sidecars A clean-status sidecar is a narrowly scoped proof, not an alternate index or a general cache. Documenting only its serialized bytes would hide the full-scan issuance requirement and the revalidation needed before an empty provider response can answer status. Document the adjacent sidecar path, local-APFS and main-worktree eligibility, fixed-width version-one CSTS fields, a checksum using the repository object-format hash, the separate repository-identity hash, a bounded builtin-provider token, and the 8192-byte read limit. Explain why the source index, configuration, repository, HEAD, attributes, and standard excludes must remain coherent. Describe completed-scan issuance, persistent provider history, held index locks, the post-query race fence, nonblocking source opens, and read-only hits. State that every missing, unsupported, stale, malformed, or raced proof falls back to ordinary status. Register the technical document in both the documentation Makefile and Meson. Also document resumable CSHS history checkpoints separately from CSTS exact-result sidecars. Specify their namespace binding, complete FSMN, UNTR/FSUC, and FSCF contents, canonical logical-index digest, bounded local store, scratch-state validation, publication requirements, and normal-status-only rollback behavior. Signed-off-by: Taylor Blau --- Documentation/Makefile | 1 + Documentation/technical/meson.build | 1 + .../technical/status-clean-proof.adoc | 164 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 Documentation/technical/status-clean-proof.adoc diff --git a/Documentation/Makefile b/Documentation/Makefile index 2699f0b24af192..ff7cee368e3f7d 100644 --- a/Documentation/Makefile +++ b/Documentation/Makefile @@ -142,6 +142,7 @@ TECH_DOCS += technical/send-pack-pipeline TECH_DOCS += technical/shallow TECH_DOCS += technical/sparse-checkout TECH_DOCS += technical/sparse-index +TECH_DOCS += technical/status-clean-proof TECH_DOCS += technical/trivial-merge TECH_DOCS += technical/unambiguous-types TECH_DOCS += technical/unit-tests diff --git a/Documentation/technical/meson.build b/Documentation/technical/meson.build index ec07088c57617f..665977299cdab3 100644 --- a/Documentation/technical/meson.build +++ b/Documentation/technical/meson.build @@ -31,6 +31,7 @@ articles = [ 'shallow.adoc', 'sparse-checkout.adoc', 'sparse-index.adoc', + 'status-clean-proof.adoc', 'trivial-merge.adoc', 'unambiguous-types.adoc', 'unit-tests.adoc', diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc new file mode 100644 index 00000000000000..91ba95c29c6a4a --- /dev/null +++ b/Documentation/technical/status-clean-proof.adoc @@ -0,0 +1,164 @@ +Status clean-proof sidecar +========================== + +The status clean-proof sidecar is an optional cache for one exact clean +`git status --porcelain=v2` result. It is never a source of repository +state. A missing, stale, malformed, unsupported, or raced sidecar makes +status read the index and scan the worktree normally. + +Location and scope +------------------ + +The sidecar for an index at `` is stored at `.csts`. This +keeps a proof for one index from being applied to an alternate index. +Sidecars are currently limited to the main worktree, the builtin file +system monitor, and an index and worktree root on local APFS file +systems. + +Readers pass `O_NONBLOCK` and do not follow symbolic links when opening +the sidecar, then accept only regular files no larger than 8192 bytes. +An open or validation failure falls back to ordinary status. Writers use +the normal lockfile protocol. + +Binary format +------------- + +All integers are stored in network byte order. Hashes use the +repository's object-format hash algorithm. Version 1 consists of: + +* Four-byte magic `CSTS`. + +* A 32-bit version number (currently 1). + +* 32-bit flags (currently zero). + +* Fourteen 64-bit fields describing the source index: device, inode, + mode, link count, uid, gid, size, mtime seconds and nanoseconds, ctime + seconds and nanoseconds, birth time seconds and nanoseconds, and + generation. + +* The 32-bit index format version and 32-bit cache-entry count. + +* The index checksum and `HEAD` tree object ID. + +* Hashes of status-relevant configuration and repository identity. The + repository identity covers the worktree and Git directory paths, the + worktree root identity, the local APFS identifiers of the held index + and worktree root, external-attribute contents, and locale inputs. + +* One object ID digesting the unique standard-exclude observations in + first-observation order. Each observation contains the source path, + symbolic-link lookup policy, presence, and contents. Transient file + system identities used to make an observation coherent are not part + of the digest. + +* A 32-bit provider-token length followed by the token without a + terminating NUL. Version 1 accepts only builtin-fsmonitor tokens. + +* A hash over all preceding bytes in the sidecar. + +Issuance +-------- + +Only a literal, top-level `status --porcelain=v2` invocation can issue a +sidecar. Status first completes the tracked and untracked scan, +semantic-conversion checks, and provider-token closure. The result must +be empty and use the bulk scanner's complete standard-exclude result. +The index must also contain persistent semantic history from an earlier +scan, so the first scan cannot certify itself. + +Status then uses the held attribute snapshot and the scanner-sourced +standard-exclude digest, pins the named index, and checks its ordinary +expanded entries, `HEAD`, and the cache tree. External attribute +sources, effective replacement refs, untracked-cache results, null +index checksums, and other unsupported repository or index shapes +prevent issuance. + +The sidecar is installed while the index lock remains held and after +the pinned index is rechecked. Status then rolls back the index lock, so +issuing a sidecar does not itself rewrite the index. With optional locks +disabled, status does not issue a sidecar. + +Validation and races +-------------------- + +For the same literal command, status attempts validation before loading +the index entries. It checks the bounded sidecar, pins the named index, +and recreates the configuration, attribute, standard-exclude, +repository, and `HEAD` inputs. It then queries the builtin file system +monitor from the recorded token and accepts only an empty delta +response. + +The attribute and exclude snapshots remain held across that query. +Before returning a clean result, status freshly checks configuration, +`HEAD`, uncached replacement refs, the attribute contents and namespace, +the exclude sources, and both the opened and named index identities. +The sidecar's exclude-source opens pass `O_NONBLOCK` and fail closed +when the source cannot be opened and validated; standard-exclude +symbolic links retain their normal lookup behavior. + +A hit produces no output and reads only the 12-byte index header and +the 20- or 32-byte checksum trailer; it does not deserialize cache +entries, write the index, replace the sidecar, or advance its provider +token. Any failed check falls through to ordinary status. + +Resumable history checkpoints +----------------------------- + +The exact-result sidecar above is deliberately tied to one physical +index file. A normal, top-level `git status` has a separate checkpoint +store for resuming clean-status history after another Git implementation +has rewritten or re-encoded the index. The store is consulted only +after the ordinary index entries have been read. It cannot answer a +status command by itself. + +For an index at ``, a checkpoint is stored as +`.csh1.`. The namespace covers the checkpoint +schema, status configuration, semantic inputs, attribute namespace, and +the canonical worktree, Git directory, and common directory paths. It +does not include a Git executable, build prefix, provider token, or +physical index generation. At most eight regular version-1 checkpoint +files are retained next to an index. Each file is limited to 16 MiB; +readers use `O_NONBLOCK`, do not follow symbolic links, and verify an +outer checksum before parsing any section. Publication currently +requires durable index identities on a local APFS file system. + +Each checkpoint is self-contained and co-temporal. It contains: + +* a canonical digest of the ordered logical index entries; + +* the `FSMN` token and dirty bitmap; + +* the serialized untracked cache and its paired `FSUC` token, when an + untracked cache exists; and + +* the `FSCF` semantic proof bound to the same file-system-monitor token. + +The logical index digest includes entry count, path, stage, object ID, +mode, `CE_VALID`, skip-worktree, and intent-to-add state. It excludes +index format, checksum, file identity, cached stat data, and +`CE_FSMONITOR_VALID`. Split and collapsed sparse indexes and +unrecognized transient flags reject matching. A changed logical digest +misses, while publication additionally rejects logical changes during +the command and racy entries. This lets an index-format-only rewrite +find the same logical state without preserving proof across commands +which can change entry membership or persistent flags. + +On a valid hit, status installs all checkpoint sections together in a +scratch index, rechecks the pinned index, and only then replaces the +in-memory acceleration state. It queries the builtin file-system +monitor from the stored token. A trivial response, provider restart, +malformed section, token mismatch, namespace mismatch, or index race +falls back to ordinary validation. + +Status publishes a replacement checkpoint only after closing the +provider token and validating the semantic proof and paired untracked +cache. Publication uses the normal index lock followed by a per-slot +lockfile, and atomically replaces one namespace slot. When publication +succeeds, status rolls back the pending acceleration-only index update. +After an external restore, status also leaves the main index untouched +if republication fails, so one proof namespace is not copied over +another implementation's index extensions. Only literal normal status +enables this lane; commands capable of logical index changes retain the +normal index-writing path. Optional-lock-free commands neither publish +checkpoints nor use this rollback path. From fd577ae41689a9041817b1bd5865586dd7e4aa51 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 08:30:18 -0500 Subject: [PATCH 086/403] precompose: prepare Unicode configuration before parallel conversion precompose_string_if_needed() lazily reads core.precomposeUnicode on its first non-ASCII input. Concurrent directory workers must not race while initializing repository configuration. Add repo_precompose_utf8_prepare() to resolve that policy before workers start, and add repo_precompose_string_if_needed() for conversion against an explicit repository. Preserve precompose_string_if_needed() as the existing one-argument wrapper. Existing callers retain their behavior; only a caller that opts into explicit preparation separates configuration from parallel conversion. Signed-off-by: Taylor Blau --- compat/precompose_utf8.c | 24 +++++++++++++++++++++--- compat/precompose_utf8.h | 5 +++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/compat/precompose_utf8.c b/compat/precompose_utf8.c index 8077f6235b0cae..2be9f7577f9519 100644 --- a/compat/precompose_utf8.c +++ b/compat/precompose_utf8.c @@ -72,19 +72,32 @@ void probe_utf8_pathname_composition(void) strbuf_release(&path); } -const char *precompose_string_if_needed(const char *in) +void repo_precompose_utf8_prepare(struct repository *repo) +{ + struct repo_config_values *cfg = repo_config_values(repo); + + if (cfg->precomposed_unicode < 0 && + repo_config_get_bool(repo, "core.precomposeunicode", + &cfg->precomposed_unicode)) + cfg->precomposed_unicode = 0; +} + +const char *repo_precompose_string_if_needed(struct repository *repo, + const char *in) { size_t inlen; size_t outlen; - struct repo_config_values *cfg = repo_config_values(the_repository); + struct repo_config_values *cfg = repo_config_values(repo); if (!in) return NULL; if (has_non_ascii(in, (size_t)-1, &inlen)) { iconv_t ic_prec; char *out; + if (cfg->precomposed_unicode < 0) - repo_config_get_bool(the_repository, "core.precomposeunicode", &cfg->precomposed_unicode); + repo_config_get_bool(repo, "core.precomposeunicode", + &cfg->precomposed_unicode); if (cfg->precomposed_unicode != 1) return in; ic_prec = iconv_open(repo_encoding, path_encoding); @@ -104,6 +117,11 @@ const char *precompose_string_if_needed(const char *in) return in; } +const char *precompose_string_if_needed(const char *in) +{ + return repo_precompose_string_if_needed(the_repository, in); +} + const char *precompose_argv_prefix(int argc, const char **argv, const char *prefix) { int i = 0; diff --git a/compat/precompose_utf8.h b/compat/precompose_utf8.h index c7c3cc211e5031..6ec1fa973b7db9 100644 --- a/compat/precompose_utf8.h +++ b/compat/precompose_utf8.h @@ -29,8 +29,13 @@ typedef struct { struct dirent_prec_psx *dirent_nfc; } PREC_DIR; +struct repository; + const char *precompose_argv_prefix(int argc, const char **argv, const char *prefix); const char *precompose_string_if_needed(const char *in); +const char *repo_precompose_string_if_needed(struct repository *repo, + const char *in); +void repo_precompose_utf8_prepare(struct repository *repo); void probe_utf8_pathname_composition(void); PREC_DIR *precompose_utf8_opendir(const char *dirname); From 98053c24bec163595cdea6ac7757decf4ee9288c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:08:32 -0700 Subject: [PATCH 087/403] fsmonitor: validate builtin daemon responses before applying them The builtin fsmonitor client interpreted an IPC reply as unbounded C strings. A truncated token or pathname could read past the reply; an empty pathname could enter invalidation code expecting at least one byte; and a slash response could be confused with a real path. Parse the complete reply into an explicit error, delta, or trivial outcome before exposing a builtin token or path. Require a bounded builtin-prefixed token and fully terminated, nonempty, worktree- relative path records. Reserve an exact single slash for a trivial reply and retain the separate double-slash global invalidation marker. Route malformed replies through the existing scan fallback. Add unit coverage for valid paths, trivial and global responses, missing delimiters, oversized tokens, empty records, absolute paths, parent traversal, and malformed separators. Register the new unit suite in both the Makefile and t/meson.build. Hook parsing and token adoption remain unchanged. Signed-off-by: Taylor Blau --- Makefile | 1 + fsmonitor.c | 112 ++++++++++++++++++++++++---- fsmonitor.h | 23 ++++++ t/meson.build | 1 + t/unit-tests/u-fsmonitor-response.c | 86 +++++++++++++++++++++ 5 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 t/unit-tests/u-fsmonitor-response.c diff --git a/Makefile b/Makefile index 97a4936b146df3..2d4754b4643417 100644 --- a/Makefile +++ b/Makefile @@ -1540,6 +1540,7 @@ CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate CLAR_TEST_SUITES += u-fsmonitor-attributes +CLAR_TEST_SUITES += u-fsmonitor-response CLAR_TEST_SUITES += u-hash CLAR_TEST_SUITES += u-hashmap CLAR_TEST_SUITES += u-list-objects-filter-options diff --git a/fsmonitor.c b/fsmonitor.c index 7b90f80405909c..b88a5c377894af 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -731,6 +731,90 @@ static int is_trivial_response_at(const struct strbuf *result, size_t offset) return 1; } +void fsmonitor_query_result_release(struct fsmonitor_query_result *result) +{ + strbuf_release(&result->token); + strbuf_release(&result->paths); +} + +static int fsmonitor_valid_worktree_path(const char *path, size_t len) +{ + struct strbuf copy = STRBUF_INIT; + int valid = 0; + + if (!len || is_dir_sep(path[0]) || has_dos_drive_prefix(path)) + return 0; + strbuf_add(©, path, len); + if (is_dir_sep(copy.buf[copy.len - 1])) + strbuf_setlen(©, copy.len - 1); + if (!copy.len || is_dir_sep(copy.buf[copy.len - 1])) + goto done; + valid = verify_path(copy.buf, 0); + +done: + strbuf_release(©); + return valid; +} + +enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( + const struct strbuf *raw, struct fsmonitor_query_result *result) +{ + const char *nul, *p, *end; + + if (!raw->len) + goto malformed; + nul = memchr(raw->buf, '\0', raw->len); + if (!nul || nul == raw->buf || nul - raw->buf > FSMONITOR_TOKEN_MAX) + goto malformed; + strbuf_add(&result->token, raw->buf, nul - raw->buf); + if (!starts_with(result->token.buf, "builtin:")) + goto malformed; + + p = nul + 1; + end = raw->buf + raw->len; + if (p == end) { + result->outcome = FSMONITOR_QUERY_DELTA; + return result->outcome; + } + if (end[-1] != '\0') + goto malformed; + if (end - p == 2 && p[0] == '/' && p[1] == '\0') { + result->outcome = FSMONITOR_QUERY_TRIVIAL; + return result->outcome; + } + + while (p < end) { + nul = memchr(p, '\0', end - p); + if (!nul || nul == p) + goto malformed; + if (strcmp(p, FSMONITOR_PATH_GLOBAL_INVALIDATE) && + !fsmonitor_valid_worktree_path(p, nul - p)) + goto malformed; + p = nul + 1; + } + strbuf_add(&result->paths, raw->buf + result->token.len + 1, + end - (raw->buf + result->token.len + 1)); + result->outcome = FSMONITOR_QUERY_DELTA; + return result->outcome; + +malformed: + strbuf_reset(&result->token); + strbuf_reset(&result->paths); + trace2_data_intmax("fsm_client", NULL, "query/invalid-response", 1); + return FSMONITOR_QUERY_ERROR; +} + +static enum fsmonitor_query_outcome query_builtin_fsmonitor( + const char *since_token, struct fsmonitor_query_result *result) +{ + struct strbuf raw = STRBUF_INIT; + + if (!fsmonitor_ipc__send_query(since_token, &raw)) + fsmonitor_parse_builtin_response(&raw, result); + strbuf_release(&raw); + return result->outcome; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -762,24 +846,19 @@ void refresh_fsmonitor(struct index_state *istate) trace_printf_key(&trace_fsmonitor, "refresh fsmonitor"); if (fsm_mode == FSMONITOR_MODE_IPC) { - query_success = !fsmonitor_ipc__send_query( + struct fsmonitor_query_result result = + FSMONITOR_QUERY_RESULT_INIT; + + query_builtin_fsmonitor( istate->fsmonitor_last_update ? istate->fsmonitor_last_update : "builtin:fake", - &query_result); - if (query_success) { - /* - * The response contains a series of nul terminated - * strings. The first is the new token. - * - * Use `char *buf` as an interlude to trick the CI - * static analysis to let us use `strbuf_addstr()` - * here (and only copy the token) rather than - * `strbuf_addbuf()`. - */ - buf = query_result.buf; - strbuf_addstr(&last_update_token, buf); - bol = last_update_token.len + 1; - is_trivial = is_trivial_response_at(&query_result, bol); + &result); + if (result.outcome != FSMONITOR_QUERY_ERROR) { + query_success = 1; + strbuf_addbuf(&last_update_token, &result.token); + is_trivial = result.outcome == FSMONITOR_QUERY_TRIVIAL; + if (!is_trivial) + strbuf_addbuf(&query_result, &result.paths); if (is_trivial) trace2_data_intmax("fsm_client", NULL, "query/trivial-response", 1); @@ -795,6 +874,7 @@ void refresh_fsmonitor(struct index_state *istate) */ strbuf_addstr(&last_update_token, "builtin:fake"); } + fsmonitor_query_result_release(&result); goto apply_results; } diff --git a/fsmonitor.h b/fsmonitor.h index 47ce78de61c508..e20d280e06a220 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -6,6 +6,7 @@ #include "fsmonitor-settings.h" #include "object.h" #include "read-cache-ll.h" +#include "strbuf.h" #include "trace.h" /* @@ -15,6 +16,28 @@ */ void fsmonitor_invalidate_cache_entry(struct cache_entry *ce); +enum fsmonitor_query_outcome { + FSMONITOR_QUERY_ERROR = 0, + FSMONITOR_QUERY_DELTA, + FSMONITOR_QUERY_TRIVIAL, +}; + +struct fsmonitor_query_result { + enum fsmonitor_query_outcome outcome; + struct strbuf token; + struct strbuf paths; +}; + +#define FSMONITOR_QUERY_RESULT_INIT { \ + .outcome = FSMONITOR_QUERY_ERROR, \ + .token = STRBUF_INIT, \ + .paths = STRBUF_INIT, \ +} + +void fsmonitor_query_result_release(struct fsmonitor_query_result *result); +enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( + const struct strbuf *raw, struct fsmonitor_query_result *result); + /* * A pathname monitor cannot prove that every name for a multiply-linked * inode is inside its watch cone. When the platform reports real link diff --git a/t/meson.build b/t/meson.build index 6d4c7c4bed8fdf..d7dacb4063aff1 100644 --- a/t/meson.build +++ b/t/meson.build @@ -3,6 +3,7 @@ clar_test_suites = [ 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', 'unit-tests/u-fsmonitor-attributes.c', + 'unit-tests/u-fsmonitor-response.c', 'unit-tests/u-hash.c', 'unit-tests/u-hashmap.c', 'unit-tests/u-list-objects-filter-options.c', diff --git a/t/unit-tests/u-fsmonitor-response.c b/t/unit-tests/u-fsmonitor-response.c new file mode 100644 index 00000000000000..dda747aa764097 --- /dev/null +++ b/t/unit-tests/u-fsmonitor-response.c @@ -0,0 +1,86 @@ +#include "unit-test.h" + +#include "fsmonitor.h" + +static void check_response(const void *data, size_t len, + enum fsmonitor_query_outcome expected, + const char *token, const void *paths, + size_t paths_len) +{ + struct fsmonitor_query_result result = FSMONITOR_QUERY_RESULT_INIT; + struct strbuf raw = STRBUF_INIT; + + strbuf_add(&raw, data, len); + cl_assert_equal_i(fsmonitor_parse_builtin_response(&raw, &result), + expected); + cl_assert_equal_i(result.outcome, expected); + cl_assert_equal_s(result.token.buf, token); + cl_assert_equal_i(result.paths.len, paths_len); + cl_assert(!paths_len || !memcmp(result.paths.buf, paths, paths_len)); + + fsmonitor_query_result_release(&result); + strbuf_release(&raw); +} + +static void check_malformed(const void *data, size_t len) +{ + check_response(data, len, FSMONITOR_QUERY_ERROR, "", NULL, 0); +} + +void test_fsmonitor_response__rejects_malformed_framing(void) +{ + static const char missing_nul[] = "builtin:1"; + static const char empty_token[] = "\0"; + static const char non_builtin[] = "other:1\0"; + static const char unterminated_path[] = "builtin:1\0path"; + static const char empty_path[] = "builtin:1\0\0"; + static const char absolute_path[] = "builtin:1\0/absolute\0"; + static const char parent_path[] = "builtin:1\0../outside\0"; + static const char embedded_parent[] = + "builtin:1\0dir/../tracked\0"; + static const char dot_path[] = "builtin:1\0./tracked\0"; + static const char repeated_separator[] = + "builtin:1\0dir//tracked\0"; + static const char drive_path[] = "builtin:1\0C:/absolute\0"; + static const char backslash_path[] = "builtin:1\0\\absolute\0"; + struct strbuf overlong = STRBUF_INIT; + + check_malformed("", 0); + check_malformed(missing_nul, sizeof(missing_nul) - 1); + check_malformed(empty_token, sizeof(empty_token) - 1); + check_malformed(non_builtin, sizeof(non_builtin) - 1); + check_malformed(unterminated_path, sizeof(unterminated_path) - 1); + check_malformed(empty_path, sizeof(empty_path) - 1); + check_malformed(absolute_path, sizeof(absolute_path) - 1); + check_malformed(parent_path, sizeof(parent_path) - 1); + check_malformed(embedded_parent, sizeof(embedded_parent) - 1); + check_malformed(dot_path, sizeof(dot_path) - 1); + check_malformed(repeated_separator, + sizeof(repeated_separator) - 1); + if (has_dos_drive_prefix(drive_path + sizeof("builtin:1"))) + check_malformed(drive_path, sizeof(drive_path) - 1); + if (is_dir_sep('\\')) + check_malformed(backslash_path, sizeof(backslash_path) - 1); + + strbuf_addstr(&overlong, "builtin:"); + strbuf_addchars(&overlong, 'x', 4096); + strbuf_addch(&overlong, '\0'); + check_malformed(overlong.buf, overlong.len); + strbuf_release(&overlong); +} + +void test_fsmonitor_response__accepts_valid_builtin_responses(void) +{ + static const char delta[] = "builtin:2\0a\0dir/file\0dir/\0"; + static const char global[] = "builtin:3\0//\0"; + static const char trivial[] = "builtin:4\0/\0"; + + check_response(delta, sizeof(delta) - 1, FSMONITOR_QUERY_DELTA, + "builtin:2", delta + sizeof("builtin:2"), + sizeof(delta) - 1 - sizeof("builtin:2")); + check_response(global, sizeof(global) - 1, FSMONITOR_QUERY_DELTA, + "builtin:3", global + sizeof("builtin:3"), + sizeof(global) - 1 - sizeof("builtin:3")); + check_response(trivial, sizeof(trivial) - 1, FSMONITOR_QUERY_TRIVIAL, + "builtin:4", NULL, 0); +} From 623402264f0439b12234f9ade1c95c7bd28d744b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 19:10:21 -0600 Subject: [PATCH 088/403] status: reuse clean proofs for plain status after restore A normal status after another Git implementation rewrites the index can restore an external clean-history checkpoint, but later invocations still read the index and compute both logical-index digests. On a 1,034,481-entry worktree, that left clean status around 0.5 to 1.0 seconds after the original tracked-file thrash was gone. After a successful external-history restore, let literal top-level plain status publish the same physical clean proof used by the exact porcelain-v2 path. A later hit keeps the normal long-status printer and refreshes branch, tracking, and in-progress-operation state, but skips index deserialization, both logical digests, and untracked traversal. The Apple-written index in this workload uses index.skipHash, so its trailer cannot bind the proof. Accept a zero trailer only when the existing local-APFS durable identity binds the parsed and named index. Rewriting the same logical entries still changes that identity and invalidates the proof. A controlled Apple Git rewrite in the same worktree took 2.58 seconds to re-establish the proof; the next warm plain status took 0.01 seconds and traced clean-proof/hit without do_read_index or history_logical_digest. A new physical rewrite still takes the external-history path once before later unchanged-index runs become fast. --- .../technical/status-clean-proof.adoc | 59 ++++++++++------ builtin/commit.c | 65 +++++++++++++++-- clean-status-index.c | 15 +++- clean-status-index.h | 7 ++ clean-status-sidecar-issue.c | 12 ++-- clean-status-sidecar.c | 6 +- clean-status.h | 3 +- t/t7530-status-clean-sidecar.sh | 70 ++++++++++++++++++- t/unit-tests/u-clean-status-index.c | 24 +++++++ t/unit-tests/u-clean-status-sidecar.c | 29 +++++--- 10 files changed, 242 insertions(+), 48 deletions(-) diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc index 91ba95c29c6a4a..0ee74695697bca 100644 --- a/Documentation/technical/status-clean-proof.adoc +++ b/Documentation/technical/status-clean-proof.adoc @@ -1,10 +1,13 @@ Status clean-proof sidecar ========================== -The status clean-proof sidecar is an optional cache for one exact clean -`git status --porcelain=v2` result. It is never a source of repository -state. A missing, stale, malformed, unsupported, or raced sidecar makes -status read the index and scan the worktree normally. +The status clean-proof sidecar is an optional proof for one empty clean +scan. It can answer the exact empty `git status --porcelain=v2` query, +or let a literal plain `git status` print its live long-format metadata +without repeating the scan. It is never a source of repository state or +cached human-readable output. A missing, stale, malformed, unsupported, +or raced sidecar makes status read the index and scan the worktree +normally. Location and scope ------------------ @@ -39,7 +42,8 @@ repository's object-format hash algorithm. Version 1 consists of: * The 32-bit index format version and 32-bit cache-entry count. -* The index checksum and `HEAD` tree object ID. +* The index checksum, which is all zero for an `index.skipHash` index, + and the `HEAD` tree object ID. * Hashes of status-relevant configuration and repository identity. The repository identity covers the worktree and Git directory paths, the @@ -60,19 +64,23 @@ repository's object-format hash algorithm. Version 1 consists of: Issuance -------- -Only a literal, top-level `status --porcelain=v2` invocation can issue a +Only a literal, top-level `status --porcelain=v2` invocation, or a +literal plain `status` after it restored external history, can issue a sidecar. Status first completes the tracked and untracked scan, semantic-conversion checks, and provider-token closure. The result must be empty and use the bulk scanner's complete standard-exclude result. The index must also contain persistent semantic history from an earlier -scan, so the first scan cannot certify itself. +scan, so the first scan cannot certify itself. For plain status, +external checkpoint publication is attempted before the physical-index +proof is installed. Status then uses the held attribute snapshot and the scanner-sourced standard-exclude digest, pins the named index, and checks its ordinary expanded entries, `HEAD`, and the cache tree. External attribute -sources, effective replacement refs, untracked-cache results, null -index checksums, and other unsupported repository or index shapes -prevent issuance. +sources, effective replacement refs, untracked-cache results, and other +unsupported repository or index shapes prevent issuance. A null index +checksum is accepted only when the pinned index is bound by the durable +local-APFS identity used for raced-input checks. The sidecar is installed while the index lock remains held and after the pinned index is rechecked. Status then rolls back the index lock, so @@ -82,9 +90,9 @@ disabled, status does not issue a sidecar. Validation and races -------------------- -For the same literal command, status attempts validation before loading -the index entries. It checks the bounded sidecar, pins the named index, -and recreates the configuration, attribute, standard-exclude, +For either eligible literal command, status attempts validation before +loading the index entries. It checks the bounded sidecar, pins the named +index, and recreates the configuration, attribute, standard-exclude, repository, and `HEAD` inputs. It then queries the builtin file system monitor from the recorded token and accepts only an empty delta response. @@ -97,20 +105,25 @@ The sidecar's exclude-source opens pass `O_NONBLOCK` and fail closed when the source cannot be opened and validated; standard-exclude symbolic links retain their normal lookup behavior. -A hit produces no output and reads only the 12-byte index header and -the 20- or 32-byte checksum trailer; it does not deserialize cache -entries, write the index, replace the sidecar, or advance its provider -token. Any failed check falls through to ordinary status. +An exact porcelain hit produces no output. A plain-status hit refreshes +branch, tracking, and in-progress-operation metadata and passes the +empty tracked and untracked lists to the normal long-status printer. +Either hit reads only the 12-byte index header and the 20- or 32-byte +checksum trailer; for a null trailer, the durable local-APFS identity +is the physical binding. It does not deserialize cache entries, write +the index, replace the sidecar, or advance its provider token. Any +failed check falls through to ordinary status. Resumable history checkpoints ----------------------------- -The exact-result sidecar above is deliberately tied to one physical -index file. A normal, top-level `git status` has a separate checkpoint -store for resuming clean-status history after another Git implementation -has rewritten or re-encoded the index. The store is consulted only -after the ordinary index entries have been read. It cannot answer a -status command by itself. +The physical clean-proof sidecar above is deliberately tied to one +physical index file. A normal, top-level `git status` has a separate +checkpoint store for resuming clean-status history after another Git +implementation has rewritten or re-encoded the index. The store is +consulted only after the ordinary index entries have been read. It +cannot answer a status command by itself, but a successful restore can +publish a new physical proof for the next unchanged-index plain status. For an index at ``, a checkpoint is stored as `.csh1.`. The namespace covers the checkpoint diff --git a/builtin/commit.c b/builtin/commit.c index d9bf5270a0e030..eb266fd1304810 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -33,6 +33,7 @@ #include "path.h" #include "preload-index.h" #include "read-cache.h" +#include "refs.h" #include "repository.h" #include "string-list.h" #include "rerere.h" @@ -1603,6 +1604,37 @@ static int git_status_config(const char *k, const char *v, return git_diff_ui_config(k, v, ctx, NULL); } +/* + * A clean-proof hit certifies the tracked and untracked lists, but it + * deliberately does not cache human-readable status output. Refresh the + * cheap state which the long printer derives from refs and administrative + * files before printing those empty lists. + */ +static int print_normal_clean_sidecar(struct wt_status *s, + const char *prefix) +{ + struct object_id oid; + + if (repo_get_oid(s->repo, s->reference, &oid)) + return 0; + s->is_initial = 0; + oidcpy(&s->oid_commit, &oid); + s->ignore_submodule_arg = ignore_submodule_arg; + s->status_format = status_format; + s->verbose = verbose; + FREE_AND_NULL(s->branch); + s->branch = refs_resolve_refdup(get_main_ref_store(s->repo), + "HEAD", 0, NULL, NULL); + wt_status_get_state(s->repo, &s->state, + s->branch && !strcmp(s->branch, "HEAD")); + if (s->state.merge_in_progress) + s->committable = 1; + if (s->relative_paths) + s->prefix = prefix; + wt_status_print(s); + return 1; +} + int cmd_status(int argc, const char **argv, const char *prefix, @@ -1618,6 +1650,8 @@ struct repository *repo UNUSED) int exact_clean_command = argc == 2 && !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); int exact_clean_query; + int normal_clean_query; + int normal_has_head; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1704,22 +1738,38 @@ struct repository *repo UNUSED) default_status_command && !s.pathspec.nr; if (s.allow_clean_status_shortcuts) clean_status_enable_external_history(the_repository); + normal_has_head = default_status_command && + !repo_get_oid(the_repository, s.reference, &oid); exact_clean_query = exact_clean_command && status_format == STATUS_FORMAT_PORCELAIN_V2 && !s.pathspec.nr && !s.show_branch && !s.show_stash && !s.show_ignored_mode && !s.null_termination && !s.verbose && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES; + normal_clean_query = default_status_command && + status_format == STATUS_FORMAT_NONE && normal_has_head && + !s.pathspec.nr && !s.show_branch && !s.show_stash && + !s.show_ignored_mode && !s.null_termination && !s.verbose && + !s.submodule_summary && + s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !repo_config_values(the_repository)->apply_sparse_checkout; s.certify_clean_status = exact_clean_query; - if (exact_clean_query && + if ((exact_clean_query || normal_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { - wt_status_collect_free_buffers(&s); - return 0; + if (!normal_clean_query || + print_normal_clean_sidecar(&s, prefix)) { + wt_status_collect_free_buffers(&s); + return 0; + } } if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); + if (normal_clean_query && use_optional_locks() && + clean_status_external_history_was_restored( + the_repository->index)) + s.certify_clean_status = 1; wt_status_start_untracked_cache_preload(&s); wt_status_refresh_index( &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag | @@ -1751,7 +1801,8 @@ struct repository *repo UNUSED) wt_status_collect(&s); if (exact_clean_command && 0 <= fd && - clean_status_issue_sidecar(&s, &clean_digest, &index_lock)) + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 0)) fd = -1; if (0 <= fd) { int external_restored = @@ -1761,7 +1812,11 @@ struct repository *repo UNUSED) clean_status_save_external_history( the_repository->index); - if (external_restored || external_saved) { + if (normal_clean_query && external_restored && + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 1)) + fd = -1; + else if (external_restored || external_saved) { rollback_lock_file(&index_lock); fd = -1; } diff --git a/clean-status-index.c b/clean-status-index.c index d2303784c9dcd0..0ea48833ed5d0c 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -83,6 +83,13 @@ int clean_status_index_snapshot_open( return snapshot_open(snapshot, path, algo, 0); } +int clean_status_index_snapshot_open_allow_null_checksum( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo) +{ + return snapshot_open(snapshot, path, algo, 1); +} + int clean_status_index_snapshot_still_matches_path( const struct clean_status_index_snapshot *snapshot, const char *path, const struct git_hash_algo *algo) @@ -231,7 +238,13 @@ int clean_status_index_entries_are_certifiable( int clean_status_index_is_certifiable(const struct index_state *istate) { - return !is_null_oid(&istate->oid) && + const struct clean_status_state *state = istate->clean_status; + int checksum_is_bound = + !is_null_oid(&istate->oid) || + (clean_status_identity_is_durable() && state && + state->source_identity_valid); + + return checksum_is_bound && clean_status_index_entries_are_certifiable(istate); } diff --git a/clean-status-index.h b/clean-status-index.h index 2579b20ee437e1..6da2b37b73031a 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -17,6 +17,13 @@ struct clean_status_index_snapshot { int clean_status_index_snapshot_open( struct clean_status_index_snapshot *snapshot, const char *path, const struct git_hash_algo *algo); +/* + * Callers which accept a null trailer must separately require a durable + * source identity before trusting the snapshot. + */ +int clean_status_index_snapshot_open_allow_null_checksum( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo); int clean_status_index_snapshot_still_matches_path( const struct clean_status_index_snapshot *snapshot, const char *path, const struct git_hash_algo *algo); diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index bdf3c058595a27..06cab58d59699e 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -40,9 +40,12 @@ static int issue_test_barrier(void) return ret; } -static int output_is_certifiable(const struct wt_status *status) +static int output_is_certifiable(const struct wt_status *status, + int normal_clean_query) { - return status->status_format == STATUS_FORMAT_PORCELAIN_V2 && + return (status->status_format == STATUS_FORMAT_PORCELAIN_V2 || + (normal_clean_query && + status->status_format == STATUS_FORMAT_NONE)) && !status->pathspec.nr && !status->show_branch && !status->show_stash && !status->show_ignored_mode && !status->null_termination && !status->verbose && @@ -94,7 +97,8 @@ static int untracked_scan_is_certifiable( int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, - struct lock_file *index_lock) + struct lock_file *index_lock, + int normal_clean_query) { struct repository *repo = status->repo; struct index_state *istate = repo->index; @@ -107,7 +111,7 @@ int clean_status_issue_sidecar( if (!is_lock_file_locked(index_lock) || !config->finalized || config->filter_configured || - !output_is_certifiable(status)) { + !output_is_certifiable(status, normal_clean_query)) { trace_miss(repo, "issue-command-or-output"); goto done; } diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index 95db486a47b289..f387881a79368a 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -53,7 +53,8 @@ static int proof_valid(const struct clean_status_proof *proof, const struct git_hash_algo *algo) { return proof->index_version >= 2 && proof->index_version <= 4 && - !is_null_oid(&proof->index_checksum) && + (!is_null_oid(&proof->index_checksum) || + clean_status_identity_is_durable()) && !is_null_oid(&proof->head_tree) && !is_null_oid(&proof->exclude_source_digest) && proof->index_checksum.algo == hash_algo_by_ptr(algo) && @@ -252,7 +253,8 @@ int clean_status_sidecar_pin_source( const struct git_hash_algo *algo, struct clean_status_index_snapshot *snapshot) { - if (clean_status_index_snapshot_open(snapshot, index_path, algo)) + if (clean_status_index_snapshot_open_allow_null_checksum( + snapshot, index_path, algo)) return -1; if (sidecar_matches_snapshot( index_path, sidecar, snapshot, algo)) diff --git a/clean-status.h b/clean-status.h index 8aea521a3bb4bf..4432903437dd27 100644 --- a/clean-status.h +++ b/clean-status.h @@ -86,7 +86,8 @@ int clean_status_verify_null_index(const struct index_state *istate, int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, - struct lock_file *index_lock); + struct lock_file *index_lock, + int normal_clean_query); int clean_status_try_sidecar( struct repository *repo, const struct clean_status_config_digest *config); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 93b3705a089173..0459708491cd78 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -547,13 +547,14 @@ test_expect_success DURABLE_FSMONITOR \ ' test_expect_success DURABLE_FSMONITOR \ - 'a v4 skipHash index is not certified' ' + 'a v4 skipHash index uses durable identity for a clean proof' ' test_when_finished "stop_daemon sidecar-v4" && setup_repo sidecar-v4 && prime_semantic_history sidecar-v4 && git -C sidecar-v4 config index.version 4 && git -C sidecar-v4 config index.skipHash true && git -C sidecar-v4 update-index --force-write-index && + prime_semantic_history sidecar-v4 && git -C sidecar-v4 config core.autocrlf false && dd if=/dev/zero of=zeros bs=20 count=1 2>/dev/null && @@ -562,8 +563,22 @@ test_expect_success DURABLE_FSMONITOR \ test_env GIT_TRACE2_EVENT="$PWD/v4.trace" \ bulk_status -C sidecar-v4 status --porcelain=v2 >actual && test_must_be_empty actual && - test_path_is_missing sidecar-v4/.git/index.csts && - test_grep ! "\"key\":\"clean-proof/hit\"" v4.trace + test_path_is_file sidecar-v4/.git/index.csts && + test_grep "\"key\":\"clean-proof/sidecar\"" v4.trace && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/v4-hit.trace" \ + git -C sidecar-v4 status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" v4-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" v4-hit.trace && + + # Rewriting the same zero-checksum entries changes the durable identity. + git -C sidecar-v4 update-index --force-write-index && + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/v4-rewrite.trace" \ + git -C sidecar-v4 status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep ! "\"key\":\"clean-proof/hit\"" v4-rewrite.trace && + test_grep "\"value\":\"fast-index-mismatch\"" v4-rewrite.trace ' test_expect_success PIPE,DURABLE_FSMONITOR \ @@ -631,6 +646,7 @@ test_expect_success DURABLE_FSMONITOR \ test_when_finished "stop_daemon external-history" && setup_repo external-history && git -C external-history config core.untrackedCache true && + git -C external-history config index.skipHash true && git -C external-history config status.renameLimit 100 && git -C external-history update-index \ --index-version=4 --force-write-index && @@ -641,6 +657,9 @@ test_expect_success DURABLE_FSMONITOR \ test_grep UNTR external-history/.git/index && test_grep FSCF external-history/.git/index && test_grep FSUC external-history/.git/index && + dd if=/dev/zero of=external-zeros bs=20 count=1 2>/dev/null && + tail -c 20 external-history/.git/index >external-trailer && + test_cmp_bin external-zeros external-trailer && git -C external-history ls-files --stage >baseline.stage && cp external-history/.git/index namespace-a-v4.index && @@ -653,6 +672,7 @@ test_expect_success DURABLE_FSMONITOR \ test_cmp seed.before external-history/.git/index && test_trace2_data fsmonitor history/external-stored 1 \ external-sidecars && @@ -673,6 +693,8 @@ test_expect_success DURABLE_FSMONITOR \ test_grep UNTR external-history/.git/index && test_grep FSCF external-history/.git/index && test_grep FSUC external-history/.git/index && + tail -c 20 external-history/.git/index >external-trailer && + test_cmp_bin external-zeros external-trailer && test_cmp sidecar.before-rewrite "$sidecar" && git -C external-history config status.renameLimit 200 && @@ -688,6 +710,9 @@ test_expect_success DURABLE_FSMONITOR \ actual.fast && + test_cmp actual.restore actual.fast && + test_trace2_data status clean-proof/hit 1 \ + actual.branch && + test_grep "^On branch sidecar-live$" actual.branch && + test_trace2_data status clean-proof/hit 1 \ + external-history/.git/MERGE_HEAD && + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/external-merge.trace" \ + git -C external-history status >actual.merge && + test_grep "All conflicts fixed but you are still merging" \ + actual.merge && + test_trace2_data status clean-proof/hit 1 \ + rawsz); - assert_parse_fails(&fixture, algo); - memset(fixture.encoded.buf + index_checksum_offset(), 2, algo->rawsz); - memset(fixture.encoded.buf + head_tree_offset(algo), 0, algo->rawsz); assert_parse_fails(&fixture, algo); memset(fixture.encoded.buf + head_tree_offset(algo), 3, algo->rawsz); @@ -197,6 +193,26 @@ void test_clean_status_sidecar__rejects_invalid_proofs(void) fixture_release(&fixture); } +void test_clean_status_sidecar__accepts_null_checksum_with_durable_identity(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + + fixture_init(&fixture, algo); + oidclr(&fixture.sidecar.proof.index_checksum, algo); + cl_assert_equal_i(clean_status_sidecar_write( + &fixture.encoded, &fixture.sidecar, algo), + clean_status_identity_is_durable() ? 0 : -1); + if (clean_status_identity_is_durable()) { + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, + fixture.encoded.len, algo), 0); + cl_assert(is_null_oid(&parsed.proof.index_checksum)); + } + fixture_release(&fixture); +} + void test_clean_status_sidecar__rejects_invalid_tokens(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -270,11 +286,6 @@ void test_clean_status_sidecar__rejects_invalid_writes(void) unsigned char *token; fixture_init(&fixture, algo); - oidclr(&fixture.sidecar.proof.index_checksum, algo); - cl_assert_equal_i(clean_status_sidecar_write( - &fixture.encoded, &fixture.sidecar, algo), -1); - fill_oid(&fixture.sidecar.proof.index_checksum, 2, algo); - token = xmalloc(FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); memset(token, 'x', FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); memcpy(token, "builtin:", strlen("builtin:")); From e00b53a8674525ef129218c3a59f5d24ec1c3e7c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:15 -0500 Subject: [PATCH 089/403] preload-index: validate packed APFS directory records getattrlistbulk() returns variable-length records with attribute sets and name offsets supplied by the filesystem. A truncated record, entry error, unexpected attributes, or invalid name reference cannot safely describe an index entry. Add a bounded decoder that checks record alignment and length, required attribute sets, entry errors, record-local name offsets, valid path components, and file metadata before accepting a record. Add six Darwin unit checks for valid file and directory records, entry errors, short records, unexpected attributes, and invalid names. Register the Darwin decoder with Make, CMake, and Meson. Register its six unit checks with Make and Meson. Existing preload behavior remains unchanged. Signed-off-by: Taylor Blau --- Makefile | 2 + compat/preload-index/bulk-darwin.c | 134 ++++++++++++++ compat/preload-index/bulk-darwin.h | 4 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-preload-index-bulk-darwin.c | 193 +++++++++++++++++++++ 7 files changed, 336 insertions(+) create mode 100644 compat/preload-index/bulk-darwin.c create mode 100644 t/unit-tests/u-preload-index-bulk-darwin.c diff --git a/Makefile b/Makefile index 51059dadddf171..a5a0dabc80e45f 100644 --- a/Makefile +++ b/Makefile @@ -1384,6 +1384,7 @@ LIB_OBJS += ws.o ifdef PRELOAD_INDEX_BULK_BACKEND PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-index.o PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o +PRELOAD_INDEX_BULK_OBJS += compat/preload-index/bulk-$(PRELOAD_INDEX_BULK_BACKEND).o PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) endif LIB_OBJS += $(PRELOAD_INDEX_BULK_OBJS) @@ -1558,6 +1559,7 @@ CLAR_TEST_SUITES += u-oidmap CLAR_TEST_SUITES += u-oidtree CLAR_TEST_SUITES += u-path-namespace CLAR_TEST_SUITES += u-prio-queue +CLAR_TEST_SUITES += u-preload-index-bulk-darwin CLAR_TEST_SUITES += u-reftable-basics CLAR_TEST_SUITES += u-reftable-block CLAR_TEST_SUITES += u-reftable-merged diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c new file mode 100644 index 00000000000000..d766d0600151b2 --- /dev/null +++ b/compat/preload-index/bulk-darwin.c @@ -0,0 +1,134 @@ +#include "git-compat-util.h" + +#include +#include + +#include "compat/preload-index/bulk-darwin.h" + +static const attrgroup_t required_common = + ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | + ATTR_CMN_DEVID | ATTR_CMN_OBJTYPE | + ATTR_CMN_CRTIME | ATTR_CMN_MODTIME | ATTR_CMN_CHGTIME | + ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK | + ATTR_CMN_FLAGS | ATTR_CMN_FILEID; +static const attrgroup_t required_dir = ATTR_DIR_MOUNTSTATUS; +static const attrgroup_t required_file = + ATTR_FILE_LINKCOUNT | ATTR_FILE_DATALENGTH; + +static int valid_component(const char *component, size_t len) +{ + return len && + !(len == 1 && component[0] == '.') && + !(len == 2 && component[0] == '.' && component[1] == '.'); +} + +struct preload_bulk_darwin_entry { + const char *name; + uint32_t record_len; + dev_t dev; + fsobj_type_t type; + struct timespec birthtime; + struct timespec mtime; + struct timespec ctime; + uid_t uid; + gid_t gid; + uint32_t access; + uint32_t flags; + uint32_t linkcount; + uint32_t mountstatus; + uint64_t fileid; + off_t size; +}; + +static int decode_entry(const char *record, size_t remaining, + struct preload_bulk_darwin_entry *entry) +{ + uint32_t entry_error = 0; + attribute_set_t returned; + attrreference_t name_ref; + const char *p, *end, *name_ref_at; + size_t name_ref_offset, name_offset, name_remaining; + + if (remaining < sizeof(entry->record_len) + sizeof(returned)) + return -1; + memcpy(&entry->record_len, record, sizeof(entry->record_len)); + if ((entry->record_len % sizeof(uint64_t)) || + entry->record_len < sizeof(entry->record_len) + sizeof(returned) || + entry->record_len > remaining) + return -1; + + p = record + sizeof(entry->record_len); + end = record + entry->record_len; + memcpy(&returned, p, sizeof(returned)); + p += sizeof(returned); + if (returned.commonattr != required_common || + returned.volattr || returned.forkattr) + return -1; + +#define TAKE_ATTR(value) do { \ + if ((size_t)(end - p) < sizeof(value)) \ + return -1; \ + memcpy(&(value), p, sizeof(value)); \ + p += sizeof(value); \ +} while (0) + TAKE_ATTR(entry_error); + if (entry_error) + return -1; + name_ref_at = p; + TAKE_ATTR(name_ref); + TAKE_ATTR(entry->dev); + TAKE_ATTR(entry->type); + TAKE_ATTR(entry->birthtime); + TAKE_ATTR(entry->mtime); + TAKE_ATTR(entry->ctime); + TAKE_ATTR(entry->uid); + TAKE_ATTR(entry->gid); + TAKE_ATTR(entry->access); + TAKE_ATTR(entry->flags); + TAKE_ATTR(entry->fileid); + + if (entry->type == VDIR) { + if (returned.dirattr != required_dir || + returned.fileattr) + return -1; + TAKE_ATTR(entry->mountstatus); + } else { + if (returned.dirattr || + (returned.fileattr & ~required_file)) + return -1; + TAKE_ATTR(entry->linkcount); + TAKE_ATTR(entry->size); + if ((entry->type == VREG || entry->type == VLNK) && + returned.fileattr != required_file) + return -1; + } +#undef TAKE_ATTR + + if (name_ref.attr_dataoffset < 0 || + (name_ref.attr_dataoffset % (int32_t)sizeof(uint32_t))) + return -1; + name_ref_offset = name_ref_at - record; + if ((uint32_t)name_ref.attr_dataoffset > + entry->record_len - name_ref_offset) + return -1; + name_offset = name_ref_offset + name_ref.attr_dataoffset; + name_remaining = entry->record_len - name_offset; + entry->name = record + name_offset; + if (!name_ref.attr_length || + name_ref.attr_length > name_remaining || + entry->name < p) + return -1; + if (entry->name[name_ref.attr_length - 1] || + memchr(entry->name, '\0', name_ref.attr_length - 1) || + !valid_component(entry->name, name_ref.attr_length - 1) || + memchr(entry->name, '/', name_ref.attr_length - 1)) + return -1; + return 0; +} + +int preload_bulk_darwin_decode_record(const char *record, size_t len) +{ + struct preload_bulk_darwin_entry entry; + + return decode_entry(record, len, &entry); +} diff --git a/compat/preload-index/bulk-darwin.h b/compat/preload-index/bulk-darwin.h index d96ed99240c3d7..c69886ac972268 100644 --- a/compat/preload-index/bulk-darwin.h +++ b/compat/preload-index/bulk-darwin.h @@ -12,6 +12,10 @@ struct preload_bulk_darwin_data { fsid_t root_fsid; }; +/* + * Exposed so that tests can validate kernel-supplied records directly. + */ +int preload_bulk_darwin_decode_record(const char *record, size_t len); int preload_bulk_darwin_fd_on_root_mount(struct preload_bulk_scan *scan, int fd, struct stat *st_out); const char *preload_bulk_darwin_open_root(struct preload_bulk_scan *scan); diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 67fb3f2d9ba7c8..e53ff08cd07b23 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -278,6 +278,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(USE_ST_TIMESPEC) list(APPEND compat_SOURCES compat/darwin/procinfo.c + compat/preload-index/bulk-darwin.c compat/preload-index/bulk-darwin-root.c) endif() diff --git a/meson.build b/meson.build index 9134428deb5d31..2bdff5bb374325 100644 --- a/meson.build +++ b/meson.build @@ -1346,6 +1346,7 @@ elif host_machine.system() == 'windows' elif host_machine.system() == 'darwin' compat_sources += [ 'compat/darwin/procinfo.c', + 'compat/preload-index/bulk-darwin.c', 'compat/preload-index/bulk-darwin-root.c', ] libgit_sources += [ diff --git a/t/meson.build b/t/meson.build index d8734eae5ff77f..d22603637bc5a6 100644 --- a/t/meson.build +++ b/t/meson.build @@ -11,6 +11,7 @@ clar_test_suites = [ 'unit-tests/u-oidmap.c', 'unit-tests/u-oidtree.c', 'unit-tests/u-path-namespace.c', + 'unit-tests/u-preload-index-bulk-darwin.c', 'unit-tests/u-prio-queue.c', 'unit-tests/u-reftable-basics.c', 'unit-tests/u-reftable-block.c', diff --git a/t/unit-tests/u-preload-index-bulk-darwin.c b/t/unit-tests/u-preload-index-bulk-darwin.c new file mode 100644 index 00000000000000..f20a7bbd704ac5 --- /dev/null +++ b/t/unit-tests/u-preload-index-bulk-darwin.c @@ -0,0 +1,193 @@ +#include "unit-test.h" + +#ifdef __APPLE__ + +#include +#include + +#include "compat/preload-index/bulk-darwin.h" + +#define REQUIRED_COMMON \ + (ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | \ + ATTR_CMN_DEVID | ATTR_CMN_OBJTYPE | \ + ATTR_CMN_CRTIME | ATTR_CMN_MODTIME | ATTR_CMN_CHGTIME | \ + ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK | \ + ATTR_CMN_FLAGS | ATTR_CMN_FILEID) +#define REQUIRED_FILE (ATTR_FILE_LINKCOUNT | ATTR_FILE_DATALENGTH) + +struct test_record { + uint32_t record_len; + attribute_set_t returned; + uint32_t error; + attrreference_t name_ref; + dev_t dev; + fsobj_type_t type; + struct timespec birthtime; + struct timespec mtime; + struct timespec ctime; + uid_t uid; + gid_t gid; + uint32_t access; + uint32_t flags; + uint64_t fileid; + uint32_t linkcount; + off_t size; + char name[8]; +} __attribute__((packed)); + +static struct test_record make_record(void) +{ + struct test_record record = { + .record_len = sizeof(record), + .returned = { + .commonattr = REQUIRED_COMMON, + .fileattr = REQUIRED_FILE, + }, + .name_ref = { + .attr_dataoffset = offsetof(struct test_record, name) - + offsetof(struct test_record, name_ref), + .attr_length = 5, + }, + .dev = 1, + .type = VREG, + .uid = 1, + .gid = 1, + .access = 0644, + .fileid = 1, + .linkcount = 1, + .size = 1, + .name = "file", + }; + + return record; +} + +static void check_malformed(struct test_record *record, size_t len) +{ + cl_assert(preload_bulk_darwin_decode_record((char *)record, len) < 0); +} + +#endif /* __APPLE__ */ + +void test_preload_index_bulk_darwin__accepts_valid_record(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + cl_assert_equal_i(0, preload_bulk_darwin_decode_record((char *)&record, + sizeof(record))); +#endif +} + +void test_preload_index_bulk_darwin__accepts_valid_directory_record(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + record.returned.fileattr = 0; + record.returned.dirattr = ATTR_DIR_MOUNTSTATUS; + record.type = VDIR; + cl_assert_equal_i(0, preload_bulk_darwin_decode_record((char *)&record, + sizeof(record))); +#endif +} + +void test_preload_index_bulk_darwin__rejects_entry_error(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + record.error = EIO; + check_malformed(&record, sizeof(record)); +#endif +} + +void test_preload_index_bulk_darwin__rejects_short_record(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + check_malformed(&record, + sizeof(uint32_t) + sizeof(attribute_set_t) - 1); + + record.record_len = sizeof(record) + sizeof(uint64_t); + check_malformed(&record, sizeof(record)); + + record.record_len = sizeof(uint64_t); + check_malformed(&record, sizeof(record)); + + record.record_len = sizeof(record) - 1; + check_malformed(&record, sizeof(record)); + + record.record_len = (offsetof(struct test_record, type) + + sizeof(uint64_t) - 1) & + ~(sizeof(uint64_t) - 1); + check_malformed(&record, sizeof(record)); +#endif +} + +void test_preload_index_bulk_darwin__rejects_wrong_returned_attributes(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record; + + record = make_record(); + record.returned.commonattr &= ~ATTR_CMN_NAME; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.returned.volattr = 1; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.returned.fileattr &= ~ATTR_FILE_DATALENGTH; + check_malformed(&record, sizeof(record)); +#endif +} + +void test_preload_index_bulk_darwin__rejects_invalid_name_reference(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record; + + record = make_record(); + record.name_ref.attr_dataoffset = -4; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_dataoffset = 2; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_dataoffset = INT32_MAX; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_length = UINT32_MAX; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_dataoffset = 0; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name[1] = '\0'; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name[1] = '/'; + check_malformed(&record, sizeof(record)); +#endif +} From b020db09bdb2fa9ced1e5533208f1ca44d56ea9e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:49 -0500 Subject: [PATCH 090/403] fsmonitor: apply validated builtin path records directly A validated builtin daemon response already contains complete, nonempty, NUL-terminated path records. Sending those records through the hook-oriented byte-by-byte offset scanner repeats framing work and obscures the distinction between builtin and hook protocols. Introduce apply_fsmonitor_paths() and call it immediately from the builtin branch of refresh_fsmonitor(). Walk the already validated path buffer, invalidate each reported path exactly once, and retain the resulting path count. Preserve hook token offsets, malformed-response handling, and global invalidation. The parser and unit tests from S05/P04 provide the bounded input; this refactor adds no separate benchmark or test execution claim. Signed-off-by: Taylor Blau --- fsmonitor.c | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index b88a5c377894af..d2d369e6a1d2e5 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -815,6 +815,23 @@ static enum fsmonitor_query_outcome query_builtin_fsmonitor( return result->outcome; } +static int apply_fsmonitor_paths(struct index_state *istate, + const struct strbuf *paths) +{ + const char *p = paths->buf; + const char *end = paths->buf + paths->len; + int count = 0; + + while (p < end) { + size_t len = strlen(p); + + fsmonitor_refresh_callback(istate, (char *)p); + count++; + p += len + 1; + } + return count; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -974,17 +991,21 @@ void refresh_fsmonitor(struct index_state *istate) */ int count = 0; - buf = query_result.buf; - for (i = bol; i < query_result.len; i++) { - if (buf[i] != '\0') - continue; - fsmonitor_refresh_callback(istate, buf + bol); - bol = i + 1; - count++; - } - if (bol < query_result.len) { - fsmonitor_refresh_callback(istate, buf + bol); - count++; + if (fsm_mode == FSMONITOR_MODE_IPC) { + count = apply_fsmonitor_paths(istate, &query_result); + } else { + buf = query_result.buf; + for (i = bol; i < query_result.len; i++) { + if (buf[i] != '\0') + continue; + fsmonitor_refresh_callback(istate, buf + bol); + bol = i + 1; + count++; + } + if (bol < query_result.len) { + fsmonitor_refresh_callback(istate, buf + bol); + count++; + } } /* Now mark the untracked cache for fsmonitor usage */ From 03fdf83416a7dfe44d383ddb6dfac0922431ef5e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 23:58:32 -0500 Subject: [PATCH 091/403] fsmonitor: restore recursive validity for paired untracked state FSUC records each directory validity bit, but valid_recursive is an in-memory summary rebuilt while scanning. External clean-history restore parses UNTR and FSUC into scratch state and pairs their token with FSMN, then hands that cache to status without rebuilding the summary. A root-only fsmonitor event therefore leaves the restored root looking non-recursive, and read_directory walks every cached subtree. Recompute valid_recursive when matching FSMN and FSUC tokens make the cache valid. This only folds already-validated child bits; later invalidation still clears ancestors through the existing path. Extend the FSUC parser test with a root and child so the paired-token transition checks both directory bits and their recursive summary. --- dir.c | 8 ++++++++ dir.h | 2 ++ fsmonitor.c | 3 +++ t/helper/test-read-cache.c | 7 +++++++ 4 files changed, 20 insertions(+) diff --git a/dir.c b/dir.c index c1057362ea573f..4645f4a42a911c 100644 --- a/dir.c +++ b/dir.c @@ -608,6 +608,14 @@ static int compute_untracked_cache_fsmonitor_valid_recursive( return valid; } +void untracked_cache_recompute_fsmonitor_valid_recursive( + struct untracked_cache *uc) +{ + if (!uc || !uc->root) + return; + compute_untracked_cache_fsmonitor_valid_recursive(uc->root); +} + static void untracked_cache_preload_join( struct untracked_cache_preload *preload) { diff --git a/dir.h b/dir.h index 088e06c1ada4d8..de1782a3f254f6 100644 --- a/dir.h +++ b/dir.h @@ -624,6 +624,8 @@ void untracked_cache_invalidate_all(struct index_state *); void untracked_cache_invalidate_trimmed_path(struct index_state *, const char *path, int safe_path); +void untracked_cache_recompute_fsmonitor_valid_recursive( + struct untracked_cache *); void untracked_cache_remove_from_index(struct index_state *, const char *); void untracked_cache_add_to_index(struct index_state *, const char *); diff --git a/fsmonitor.c b/fsmonitor.c index e8d91d6681cc09..76bb7e051176a0 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -261,6 +261,9 @@ void prepare_fsmonitor_untracked(struct index_state *istate) istate->fsmonitor_untracked_token && !strcmp(istate->fsmonitor_last_update, istate->fsmonitor_untracked_token))); + if (istate->fsmonitor_untracked_valid) + untracked_cache_recompute_fsmonitor_valid_recursive( + istate->untracked); } static struct ewah_bitmap *fsmonitor_bitmap_from_index( diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 5228c2065e4404..3009e38d3b0bb6 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -66,6 +66,8 @@ static int test_fsuc_parser(void) struct index_state truncated = INDEX_STATE_INIT(the_repository); struct untracked_cache untracked = { 0 }; struct untracked_cache_dir root = { 0 }; + struct untracked_cache_dir child = { 0 }; + struct untracked_cache_dir *dirs[] = { &child }; struct strbuf encoded = STRBUF_INIT; struct strbuf written = STRBUF_INIT; uint32_t version; @@ -89,9 +91,14 @@ static int test_fsuc_parser(void) duplicate.fsmonitor_token_valid = 1; duplicate.untracked = &untracked; untracked.root = &root; + root.valid = child.valid = 1; + root.dirs = dirs; + root.dirs_nr = ARRAY_SIZE(dirs); prepare_fsmonitor_untracked(&duplicate); if (!duplicate.fsmonitor_untracked_valid) return error("matching FSMN and FSUC tokens were not paired"); + if (!root.valid_recursive || !child.valid_recursive) + return error("matching FSUC did not restore recursive validity"); free(duplicate.fsmonitor_last_update); duplicate.fsmonitor_last_update = xstrdup("other"); prepare_fsmonitor_untracked(&duplicate); From aeea9605226286a6fcbe4ba63af4be8471677aa8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:16 -0500 Subject: [PATCH 092/403] preload-index: walk APFS directories beneath a held root Descriptor limits can force a queued directory to be reopened after its parent was scanned. Reopening through an ordinary worktree pathname could follow a replacement directory or symlink into another namespace. Open immediate children with O_NOFOLLOW and reopen relative paths below the held root with O_NOFOLLOW_ANY. Visit only directories with tracked descendants on the original APFS mount, and compare parent and child identities before and after enumeration. Discard the complete scan after malformed records or changed directory identities. Leave multiply-linked tracked files to ordinary lstat. Allocate one 1 MiB record buffer per active worker; the backend is still not invoked from preload_index(), so existing behavior is unchanged. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 326 ++++++++++++++++++++++++++++ contrib/buildsystems/CMakeLists.txt | 3 +- preload-index-bulk-index.c | 13 ++ preload-index-bulk-thread.c | 31 ++- preload-index-bulk.h | 17 ++ 5 files changed, 383 insertions(+), 7 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index d766d0600151b2..22a0e16def307f 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -3,7 +3,16 @@ #include #include +#include "compat/precompose_utf8.h" #include "compat/preload-index/bulk-darwin.h" +#include "path-namespace.h" +#include "preload-index-bulk.h" + +#ifndef SF_FIRMLINK +#define SF_FIRMLINK 0x00800000 +#endif + +#define PRELOAD_INDEX_BULK_BUFFER_SIZE (1024 * 1024) static const attrgroup_t required_common = ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | @@ -22,6 +31,92 @@ static int valid_component(const char *component, size_t len) !(len == 2 && component[0] == '.' && component[1] == '.'); } +static int valid_relative_path(const char *path) +{ + const char *component = path; + + if (!strcmp(path, ".")) + return 1; + if (!*path || *path == '/') + return 0; + for (;;) { + const char *slash = strchr(component, '/'); + size_t len = slash ? (size_t)(slash - component) : + strlen(component); + + if (!valid_component(component, len)) + return 0; + if (!slash) + return 1; + component = slash + 1; + } +} + +static int preload_bulk_darwin_open_dir_at( + struct preload_bulk_worker *worker UNUSED, + int parent_fd, const char *name) +{ + if (!valid_component(name, strlen(name)) || strchr(name, '/')) { + errno = EINVAL; + return -1; + } + return openat(parent_fd, name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); +} + +static int preload_bulk_darwin_open_relative(struct preload_bulk_scan *scan, + const char *path) +{ + if (!valid_relative_path(path)) { + errno = EINVAL; + return -1; + } + +#ifdef O_NOFOLLOW_ANY + return openat(scan->root_fd, path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW_ANY | O_CLOEXEC); +#else + errno = ENOTSUP; + return -1; +#endif +} + +static mode_t vnode_mode(fsobj_type_t type) +{ + switch (type) { + case VREG: + return S_IFREG; + case VLNK: + return S_IFLNK; + default: + return 0; + } +} + +static int fill_file_stat(struct stat *st, dev_t dev, uint64_t fileid, + fsobj_type_t type, struct timespec mtime, + struct timespec ctime, uid_t uid, gid_t gid, + uint32_t access, uint32_t linkcount, off_t size) +{ + mode_t mode = vnode_mode(type); + + if (!mode || size < 0 || + ((access & S_IFMT) && (access & S_IFMT) != mode) || + (access & ~(S_IFMT | 07777))) + return -1; + memset(st, 0, sizeof(*st)); + st->st_dev = dev; + st->st_ino = fileid; + st->st_mode = mode | (access & 07777); + st->st_uid = uid; + st->st_gid = gid; + st->st_nlink = linkcount; + st->st_size = size; + st->st_mtimespec = mtime; + st->st_ctimespec = ctime; + return 0; +} + struct preload_bulk_darwin_entry { const char *name; uint32_t record_len; @@ -132,3 +227,234 @@ int preload_bulk_darwin_decode_record(const char *record, size_t len) return decode_entry(record, len, &entry); } + +static struct preload_bulk_dir_identity directory_identity( + const struct stat *st) +{ + struct preload_bulk_dir_identity result = { + .stat = *st, + .complete = 1, + }; + + return result; +} + +static int directory_identity_matches( + const struct preload_bulk_dir_identity *before, + const struct stat *after) +{ + if (before->complete) + return path_namespace_stat_equal(&before->stat, after); + return S_ISDIR(after->st_mode) && + before->stat.st_dev == after->st_dev && + before->stat.st_ino == after->st_ino && + before->stat.st_birthtimespec.tv_sec == + after->st_birthtimespec.tv_sec && + before->stat.st_birthtimespec.tv_nsec == + after->st_birthtimespec.tv_nsec && + before->stat.st_mtimespec.tv_sec == after->st_mtimespec.tv_sec && + before->stat.st_mtimespec.tv_nsec == + after->st_mtimespec.tv_nsec && + before->stat.st_ctimespec.tv_sec == after->st_ctimespec.tv_sec && + before->stat.st_ctimespec.tv_nsec == + after->st_ctimespec.tv_nsec; +} + +static int enumerate_directory(struct preload_bulk_worker *worker, + const struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_darwin_data *data = scan->platform_data; + struct attrlist attrs = { 0 }; + char *buf = worker->buffer; + size_t path_prefix_len; + + if (!buf) { + buf = xmalloc(PRELOAD_INDEX_BULK_BUFFER_SIZE); + worker->buffer = buf; + } + + attrs.bitmapcount = ATTR_BIT_MAP_COUNT; + attrs.commonattr = required_common; + attrs.dirattr = required_dir; + attrs.fileattr = required_file; + worker->dirs++; + strbuf_reset(&worker->path); + if (strcmp(task->path, ".")) { + strbuf_addstr(&worker->path, task->path); + strbuf_addch(&worker->path, '/'); + } + path_prefix_len = worker->path.len; + + for (;;) { + int nr = getattrlistbulk(fd, &attrs, buf, + PRELOAD_INDEX_BULK_BUFFER_SIZE, + FSOPT_NOFOLLOW | + FSOPT_PACK_INVAL_ATTRS); + char *record = buf; + + worker->bulk_calls++; + if (nr < 0) + return -1; + if (!nr) + return 0; + + for (int i = 0; i < nr; i++) { + struct preload_bulk_darwin_entry entry; + struct stat st; + const char *path_name; + size_t remaining; + int pos; + + remaining = buf + PRELOAD_INDEX_BULK_BUFFER_SIZE - record; + if (decode_entry(record, remaining, &entry)) + goto malformed; + worker->entries++; + + /* + * The caller prepares the repository's Unicode policy + * before starting workers, so this is read-only here. + */ + path_name = repo_precompose_string_if_needed(scan->repo, + entry.name); + strbuf_setlen(&worker->path, path_prefix_len); + strbuf_addstr(&worker->path, path_name); + if (path_name != entry.name) + free((char *)path_name); + + if (entry.type == VDIR) { + struct preload_bulk_dir_identity child_identity = { + .stat = { + .st_dev = entry.dev, + .st_ino = entry.fileid, + .st_birthtimespec = + entry.birthtime, + .st_mtimespec = entry.mtime, + .st_ctimespec = entry.ctime, + }, + }; + + if (!preload_bulk_index_has_tracked_descendants( + scan, worker->path.buf, + worker->path.len)) + goto next_record; + if (((entry.access & S_IFMT) && + (entry.access & S_IFMT) != S_IFDIR) || + (entry.access & ~(S_IFMT | 07777))) + goto malformed_record; + if (entry.dev != data->root_stat.st_dev || + entry.mountstatus || + (entry.flags & SF_FIRMLINK)) { + goto next_record; + } + preload_bulk_schedule_directory( + worker, fd, parent_identity, + &child_identity, entry.name, + worker->path.buf, + worker->path.len); + goto next_record; + } + + pos = preload_bulk_index_position(scan, worker->path.buf, + worker->path.len); + if (pos < 0) + goto next_record; + if (entry.dev != data->root_stat.st_dev) { + goto next_record; + } + if (entry.type != VREG && entry.type != VLNK) + goto next_record; + if (entry.linkcount != 1) + goto next_record; + if (fill_file_stat(&st, entry.dev, entry.fileid, + entry.type, entry.mtime, entry.ctime, + entry.uid, entry.gid, entry.access, + entry.linkcount, entry.size)) + goto malformed_record; + preload_bulk_record_tracked(worker, pos, &st); + +next_record: + record += entry.record_len; + continue; + +malformed_record: + worker->malformed++; + goto next_record; + } + } + +malformed: + worker->malformed++; + return -1; +} + +static int scan_directory(struct preload_bulk_worker *worker, + struct preload_bulk_task *task) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_dir_identity before_identity; + struct stat before, after; + int fd = task->fd; + int ret = -1; + + if (fd < 0) + fd = preload_bulk_darwin_open_relative(scan, task->path); + if (fd < 0) + goto out; + if (preload_bulk_darwin_fd_on_root_mount(scan, fd, &before)) { + if (errno != EXDEV) + goto out; + ret = 0; + goto out; + } + /* + * A child may have been replaced after its parent returned the bulk + * record, or while this task waited in the queue. + */ + if (task->has_child_identity && + !directory_identity_matches(&task->child_identity, &before)) { + worker->changed_dirs++; + ret = 0; + goto out; + } + before_identity = directory_identity(&before); + if (enumerate_directory(worker, task, fd, &before_identity)) + goto out; + if (fstat(fd, &after)) + goto out; + if (!directory_identity_matches(&before_identity, &after)) + worker->changed_dirs++; + ret = 0; + +out: + if (task->has_parent_identity) { + struct stat parent_after; + int parent_changed = fd < 0; + + /* + * Resolve ".." through the child descriptor, not the worktree + * path, so a rename cannot redirect this parent check. + */ + if (!parent_changed) + parent_changed = fstatat(fd, "..", &parent_after, + AT_SYMLINK_NOFOLLOW); + if (parent_changed || + !directory_identity_matches(&task->parent_identity, + &parent_after)) + worker->changed_dirs++; + } + if (fd >= 0) + close(fd); + return ret; +} + +static const struct preload_bulk_backend darwin_backend = { + .open_dir_at = preload_bulk_darwin_open_dir_at, + .scan_directory = scan_directory, +}; + +const struct preload_bulk_backend *preload_bulk_platform_backend(void) +{ + return &darwin_backend; +} diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index e53ff08cd07b23..e24ec739720dc5 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -275,9 +275,10 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") - add_compile_definitions(USE_ST_TIMESPEC) + add_compile_definitions(PRECOMPOSE_UNICODE USE_ST_TIMESPEC) list(APPEND compat_SOURCES compat/darwin/procinfo.c + compat/precompose_utf8.c compat/preload-index/bulk-darwin.c compat/preload-index/bulk-darwin-root.c) endif() diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index 3c8bfad7c2a631..623164822b5fdb 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -14,6 +14,19 @@ int preload_bulk_index_position(struct preload_bulk_scan *scan, return index_name_pos_sparse(scan->istate, path, path_len); } +int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, + const char *path, + size_t path_len) +{ + int pos; + + if (path_len > INT_MAX) + return 0; + pos = index_name_pos_sparse(scan->istate, path, path_len); + return preload_bulk_index_pos_has_tracked_descendants( + scan, path, path_len, pos); +} + int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos) diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 0a73d5a1fdeafd..8f57f143d4761b 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -176,6 +176,15 @@ static void *preload_bulk_worker_main(void *data) return NULL; } +static void release_workers(struct preload_bulk_scan *scan) +{ + for (int i = 0; i < scan->threads; i++) { + free(scan->workers[i].buffer); + strbuf_release(&scan->workers[i].path); + } + FREE_AND_NULL(scan->workers); +} + int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result) { @@ -188,8 +197,10 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, if (queue_init(&scan->queue)) return -1; CALLOC_ARRAY(scan->workers, scan->threads); - for (int i = 0; i < scan->threads; i++) + for (int i = 0; i < scan->threads; i++) { scan->workers[i].scan = scan; + strbuf_init(&scan->workers[i].path, 0); + } FLEX_ALLOC_STR(root_task, path, "."); if (!reserve_open_fd(&scan->queue)) @@ -199,8 +210,7 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, if (root_task->fd < 0) { release_open_fd(&scan->queue); free(root_task); - free(scan->workers); - scan->workers = NULL; + release_workers(scan); queue_release(&scan->queue); return -1; } @@ -222,11 +232,20 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, pthread_join(scan->workers[i].thread, NULL)) BUG("unable to join bulk preload worker"); + for (int i = 0; i < scan->threads; i++) { + struct preload_bulk_worker *worker = &scan->workers[i]; + + result->dirs += worker->dirs; + result->entries += worker->entries; + result->bulk_calls += worker->bulk_calls; + result->changed_dirs += worker->changed_dirs; + result->malformed += worker->malformed; + } result->threads = started_threads; - failed = scan->queue.failed; + failed = scan->queue.failed || result->malformed || + result->changed_dirs; - free(scan->workers); - scan->workers = NULL; + release_workers(scan); queue_release(&scan->queue); return failed ? -1 : 0; } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 9e8b085160a873..3272ce41ee81ec 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -3,6 +3,7 @@ #include "git-compat-util.h" #include "preload-index.h" +#include "strbuf.h" #include "thread-utils.h" struct preload_bulk_dir_identity { @@ -40,6 +41,13 @@ struct preload_bulk_scan; struct preload_bulk_worker { struct preload_bulk_scan *scan; pthread_t thread; + void *buffer; + struct strbuf path; + uint64_t dirs; + uint64_t entries; + uint64_t bulk_calls; + uint64_t changed_dirs; + uint64_t malformed; unsigned started : 1; }; @@ -67,6 +75,11 @@ struct preload_bulk_scan { }; struct preload_bulk_run_result { + uint64_t dirs; + uint64_t entries; + uint64_t bulk_calls; + uint64_t changed_dirs; + uint64_t malformed; int threads; }; @@ -77,6 +90,9 @@ void preload_bulk_schedule_directory( const char *name, const char *path, size_t path_len); int preload_bulk_index_position(struct preload_bulk_scan *scan, const char *path, size_t path_len); +int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, + const char *path, + size_t path_len); int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos); @@ -84,5 +100,6 @@ void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); +const struct preload_bulk_backend *preload_bulk_platform_backend(void); #endif /* PRELOAD_INDEX_BULK_H */ From 6d7e279e281df26fb01a5d3a2570a81e428a2eff Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:57 -0500 Subject: [PATCH 093/403] fsmonitor: ignore empty hook path records A version-2 fsmonitor hook can return consecutive NUL delimiters after its token. The hook parser passed the resulting empty record to pathname invalidation, whose callback inspects the last byte of a nonempty path. Skip zero-length hook records and count only pathnames that actually reach fsmonitor_refresh_callback(). Preserve valid reported paths, the existing treatment of a final unterminated hook record, and the separately validated builtin response path. Add a t/t7519-status-fsmonitor.sh regression whose hook emits an empty record before a modified tracked path. Require status to report the real modification without processing the empty pathname. Signed-off-by: Taylor Blau --- fsmonitor.c | 6 ++++-- t/t7519-status-fsmonitor.sh | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index d2d369e6a1d2e5..4d4979770a27c6 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -998,9 +998,11 @@ void refresh_fsmonitor(struct index_state *istate) for (i = bol; i < query_result.len; i++) { if (buf[i] != '\0') continue; - fsmonitor_refresh_callback(istate, buf + bol); + if (i > bol) { + fsmonitor_refresh_callback(istate, buf + bol); + count++; + } bol = i + 1; - count++; } if (bol < query_result.len) { fsmonitor_refresh_callback(istate, buf + bol); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index f29bea912efd18..e8cc70c428b181 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -68,6 +68,26 @@ test_expect_success 'FSUC parser fails closed' ' test-tool read-cache --test-fsuc-parser ' +test_expect_success 'hook parser ignores empty path records' ' + test_when_finished "rm -rf empty-hook-record" && + test_create_repo empty-hook-record && + ( + cd empty-hook-record && + test_commit base tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + printf "\0" + printf "tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + echo changed >tracked && + git status --porcelain --untracked-files=no >actual && + echo " M tracked" >expect && + test_cmp expect actual + ) +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From 2119b480bd4b39bd057fa0ef0820c23a44c0136f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 00:03:34 -0500 Subject: [PATCH 094/403] status: bind external history to physical index sources 5f74c83117 (status: checkpoint clean history outside the index, 2026-08-07) binds each CSH1 payload to a logical digest of every ordered cache entry. A plain status after a foreign index rewrite must compute that digest before restore, then compute it again before reissuing the checkpoint. On the OpenAI checkout those two walks were the bulk of the roughly 900ms touch-x regression. Add a v2 CSH1 source alias for local APFS: durable stat identity, index version, entry count, and trailer checksum. Once repo_read_index() and a pinned snapshot prove the parsed index is that exact physical source, reuse the checkpoint logical hash instead of hashing every entry. Old v1 records remain readable and fall back to the digest. After status, reuse that source hash only when cache_changed contains only FSMN/UNTR acceleration changes, no cache entry changed, all flags remain in the digest accepted set, and sparse checkout is off. Any other state keeps the old digest-and-compare path. This lets an untracked-only status publish its advanced external FSMN/UNTR checkpoint without writing the main index or paying a second digest. Cover v1/v2 parsing and physical snapshot matching in unit tests. Extend the external-history test with a nested cache and a root-only dirty event; it must restore through the alias, avoid both digest regions, visit only the root, and publish updated external history. --- clean-status-history-store.c | 85 +++++++++++++++++++-- clean-status-history-store.h | 11 +++ clean-status-history.c | 38 +++++++-- clean-status-index.c | 61 +++++++++++++-- clean-status-index.h | 2 + t/t7530-status-clean-sidecar.sh | 26 +++++++ t/unit-tests/u-clean-status-history-store.c | 9 +++ 7 files changed, 209 insertions(+), 23 deletions(-) diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 572264ffafb4b2..1dbaf505f6b2eb 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -15,7 +15,8 @@ #include "wrapper.h" #define CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC "CSHS" -#define CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION 1 +#define CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION 2 +#define CLEAN_STATUS_HISTORY_CHECKPOINT_LEGACY_VERSION 1 #define CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE (16 * 1024 * 1024) #define CLEAN_STATUS_HISTORY_STORE_MAX_FILES 8 #define CLEAN_STATUS_HISTORY_HAS_FSMN (1U << 0) @@ -195,7 +196,7 @@ int clean_status_history_checkpoint_parse( unsigned char expected_namespace[GIT_MAX_RAWSZ]; size_t minimum = 4 + 2 * sizeof(uint32_t) + 2 * algo->rawsz + 4 * sizeof(uint32_t) + algo->rawsz; - uint32_t flags, lengths[4]; + uint32_t version, flags, lengths[4]; memset(checkpoint, 0, sizeof(*checkpoint)); if (!proof_namespace || !*proof_namespace || len < minimum || @@ -205,9 +206,17 @@ int clean_status_history_checkpoint_parse( return -1; end = p + len - algo->rawsz; p += 4; - if (get_be32(p) != CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) + version = get_be32(p); + if (version != CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION && + version != CLEAN_STATUS_HISTORY_CHECKPOINT_LEGACY_VERSION) return -1; p += sizeof(uint32_t); + if (version == CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) { + minimum += CLEAN_STATUS_IDENTITY_SIZE + + 2 * sizeof(uint32_t) + algo->rawsz; + if (len < minimum) + return -1; + } flags = get_be32(p); p += sizeof(uint32_t); if ((flags & (CLEAN_STATUS_HISTORY_HAS_FSMN | @@ -227,6 +236,21 @@ int clean_status_history_checkpoint_parse( p += algo->rawsz; memcpy(checkpoint->index_hash, p, algo->rawsz); p += algo->rawsz; + if (version == CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) { + if (clean_status_identity_read( + &p, end, &checkpoint->source_identity)) + return -1; + checkpoint->source_version = get_be32(p); + p += sizeof(uint32_t); + checkpoint->source_cache_nr = get_be32(p); + p += sizeof(uint32_t); + oidread(&checkpoint->source_checksum, p, algo); + p += algo->rawsz; + if (checkpoint->source_version < 2 || + checkpoint->source_version > 4) + return -1; + checkpoint->source_alias_valid = 1; + } for (size_t i = 0; i < ARRAY_SIZE(lengths); i++) { lengths[i] = get_be32(p); p += sizeof(uint32_t); @@ -274,6 +298,9 @@ int clean_status_history_checkpoint_write( { unsigned char namespace_hash[GIT_MAX_RAWSZ]; uint32_t value, flags = 0; + uint32_t version = checkpoint->source_alias_valid ? + CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION : + CLEAN_STATUS_HISTORY_CHECKPOINT_LEGACY_VERSION; strbuf_reset(out); if (!proof_namespace || !*proof_namespace || @@ -288,6 +315,10 @@ int clean_status_history_checkpoint_write( !!checkpoint->fsmonitor_config_len) || (!!checkpoint->fsmonitor_untracked != !!checkpoint->fsmonitor_untracked_len) || + (checkpoint->source_alias_valid && + (checkpoint->source_version < 2 || + checkpoint->source_version > 4 || + checkpoint->source_checksum.algo != hash_algo_by_ptr(algo))) || !checkpoint->fsmonitor_len || !checkpoint->fsmonitor_config_len || (!!checkpoint->untracked_cache_len != !!checkpoint->fsmonitor_untracked_len)) @@ -301,12 +332,21 @@ int clean_status_history_checkpoint_write( flags |= CLEAN_STATUS_HISTORY_HAS_FSUC; proof_namespace_hash(proof_namespace, algo, namespace_hash); strbuf_add(out, CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC, 4); - put_be32(&value, CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION); + put_be32(&value, version); strbuf_add(out, &value, sizeof(value)); put_be32(&value, flags); strbuf_add(out, &value, sizeof(value)); strbuf_add(out, namespace_hash, algo->rawsz); strbuf_add(out, checkpoint->index_hash, algo->rawsz); + if (checkpoint->source_alias_valid) { + clean_status_identity_write(out, &checkpoint->source_identity); + put_be32(&value, checkpoint->source_version); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->source_cache_nr); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, checkpoint->source_checksum.hash, + algo->rawsz); + } put_be32(&value, checkpoint->fsmonitor_len); strbuf_add(out, &value, sizeof(value)); put_be32(&value, checkpoint->untracked_cache_len); @@ -397,6 +437,27 @@ static int local_apfs_id(int fd MAYBE_UNUSED, #endif } +int clean_status_history_checkpoint_source_matches( + const char *index_path, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct clean_status_filesystem_id fsid; + + return checkpoint && checkpoint->source_alias_valid && + clean_status_identity_is_durable() && + snapshot && snapshot->fd >= 0 && + !local_apfs_id(snapshot->fd, &fsid) && + clean_status_identity_equal( + &checkpoint->source_identity, &snapshot->identity) && + checkpoint->source_version == snapshot->version && + checkpoint->source_cache_nr == snapshot->cache_nr && + oideq(&checkpoint->source_checksum, &snapshot->checksum) && + clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo); +} + int clean_status_history_store_install( const char *index_path, const char *proof_namespace, const struct clean_status_history_checkpoint *checkpoint, @@ -404,6 +465,7 @@ int clean_status_history_store_install( const struct git_hash_algo *algo) { struct clean_status_filesystem_id fsid; + struct clean_status_history_checkpoint aliased; struct clean_status_history_store_record current = CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; struct strbuf encoded = STRBUF_INIT; @@ -416,9 +478,16 @@ int clean_status_history_store_install( if (!clean_status_identity_is_durable() || !snapshot || snapshot->fd < 0 || local_apfs_id(snapshot->fd, &fsid) || !clean_status_index_snapshot_still_matches_path( - snapshot, index_path, algo) || - clean_status_history_checkpoint_write( - &encoded, proof_namespace, checkpoint, algo)) + snapshot, index_path, algo)) + goto done; + aliased = *checkpoint; + aliased.source_alias_valid = 1; + aliased.source_identity = snapshot->identity; + aliased.source_version = snapshot->version; + aliased.source_cache_nr = snapshot->cache_nr; + oidcpy(&aliased.source_checksum, &snapshot->checksum); + if (clean_status_history_checkpoint_write( + &encoded, proof_namespace, &aliased, algo)) goto done; current_is_regular = !lstat(path, &st) && S_ISREG(st.st_mode); if (!clean_status_history_store_load( @@ -430,7 +499,7 @@ int clean_status_history_store_install( /* * If this namespace is new, make room before the atomic install so a * successful publication never takes the bounded store above eight - * regular schema-v1 slots. No other checkpoint schema is considered. + * regular checkpoint slots. No other checkpoint schema is considered. */ if (prune_history_store( index_path, path, algo, diff --git a/clean-status-history-store.h b/clean-status-history-store.h index 22f3f6d5a085fd..82c7a267efb5bc 100644 --- a/clean-status-history-store.h +++ b/clean-status-history-store.h @@ -1,6 +1,7 @@ #ifndef CLEAN_STATUS_HISTORY_STORE_H #define CLEAN_STATUS_HISTORY_STORE_H +#include "clean-status-identity.h" #include "hash.h" #include "strbuf.h" @@ -8,6 +9,11 @@ struct clean_status_index_snapshot; struct clean_status_history_checkpoint { unsigned char index_hash[GIT_MAX_RAWSZ]; + unsigned int source_alias_valid : 1; + struct clean_status_identity source_identity; + uint32_t source_version; + uint32_t source_cache_nr; + struct object_id source_checksum; const unsigned char *fsmonitor; size_t fsmonitor_len; const unsigned char *untracked_cache; @@ -46,5 +52,10 @@ int clean_status_history_store_install( const struct clean_status_history_checkpoint *checkpoint, const struct clean_status_index_snapshot *snapshot, const struct git_hash_algo *algo); +int clean_status_history_checkpoint_source_matches( + const char *index_path, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo); #endif /* CLEAN_STATUS_HISTORY_STORE_H */ diff --git a/clean-status-history.c b/clean-status-history.c index 28f32092325d06..03f9c209b56104 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -369,8 +369,12 @@ clean_status_prepare_external_history(struct index_state *istate) "history/external-save-reject", "namespace"); goto fail; } - if (clean_status_index_logical_digest_after_status( - istate, checkpoint->checkpoint.index_hash)) { + if (clean_status_index_can_reuse_source_logical_hash(istate)) { + memcpy(checkpoint->checkpoint.index_hash, + state->source_logical_hash, + istate->repo->hash_algo->rawsz); + } else if (clean_status_index_logical_digest_after_status( + istate, checkpoint->checkpoint.index_hash)) { trace2_data_string("fsmonitor", istate->repo, "history/external-save-reject", "logical-flags"); goto fail; @@ -488,6 +492,7 @@ int clean_status_restore_external_history(struct index_state *istate) struct index_state parsed = INDEX_STATE_INIT(istate->repo); unsigned char index_hash[GIT_MAX_RAWSZ]; char proof_namespace[GIT_MAX_HEXSZ + 1]; + int record_loaded = 0; int restored = 0; if (!clean_status_external_history_enabled(istate) || !state || @@ -496,16 +501,33 @@ int clean_status_restore_external_history(struct index_state *istate) !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || on_index_history_is_coherent(istate) || - clean_status_index_snapshot_pin(&snapshot, istate) || - clean_status_index_logical_digest(istate, index_hash)) + clean_status_index_snapshot_pin(&snapshot, istate)) goto done; + if (external_history_namespace(istate, proof_namespace)) + goto done; + if (!clean_status_history_store_load( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo, &record)) { + record_loaded = 1; + if (clean_status_index_can_reuse_source_logical_hash(istate) && + clean_status_history_checkpoint_source_matches( + istate->repo->index_file, &record.checkpoint, + &snapshot, istate->repo->hash_algo)) { + memcpy(index_hash, record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz); + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-physical-alias", 1); + goto have_index_hash; + } + } + if (clean_status_index_logical_digest(istate, index_hash)) + goto done; + +have_index_hash: memcpy(state->source_logical_hash, index_hash, istate->repo->hash_algo->rawsz); state->source_logical_hash_valid = 1; - if (external_history_namespace(istate, proof_namespace) || - clean_status_history_store_load( - istate->repo->index_file, proof_namespace, - istate->repo->hash_algo, &record) || + if (!record_loaded || memcmp(index_hash, record.checkpoint.index_hash, istate->repo->hash_algo->rawsz)) goto done; diff --git a/clean-status-index.c b/clean-status-index.c index 0ea48833ed5d0c..4ea3c4bfc9d746 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -9,6 +9,11 @@ #include "trace2.h" #include "wrapper.h" +#define LOGICAL_INDEX_PERSISTENT_FLAGS \ + (CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS) +#define LOGICAL_INDEX_BENIGN_FLAGS \ + (CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID) + static int snapshot_read( int fd, const struct stat *st, const struct git_hash_algo *algo, uint32_t *version, uint32_t *cache_nr, struct object_id *checksum) @@ -248,15 +253,56 @@ int clean_status_index_is_certifiable(const struct index_state *istate) clean_status_index_entries_are_certifiable(istate); } +static int index_entry_logical_state_is_supported( + const struct cache_entry *ce, unsigned int extra_benign_flags) +{ + return !(ce->ce_flags & ~(LOGICAL_INDEX_PERSISTENT_FLAGS | + LOGICAL_INDEX_BENIGN_FLAGS | + extra_benign_flags)); +} + +static int index_logical_state_is_supported( + const struct index_state *istate, unsigned int extra_benign_flags) +{ + if (!istate->repo || !istate->repo->hash_algo || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + istate->cache_nr > UINT32_MAX) + return 0; + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + if (!index_entry_logical_state_is_supported( + ce, extra_benign_flags)) + return 0; + } + return 1; +} + +int clean_status_index_can_reuse_source_logical_hash( + const struct index_state *istate) +{ + const unsigned int acceleration_changes = + FSMONITOR_CHANGED | UNTRACKED_CHANGED; + + /* + * Reading or refreshing acceleration extensions may mark only FSMN/UNTR + * state dirty. Reject any cache-entry change, while the flag walk + * preserves every reject condition which the logical digest enforced + * before a physical alias could skip it. Sparse-checkout post-processing + * may clear CE_SKIP_WORKTREE without setting cache_changed, so leave that + * mode on the digest path. + */ + return istate->repo && istate->repo->initialized && + !repo_config_values(istate->repo)->apply_sparse_checkout && + !(istate->cache_changed & ~acceleration_changes) && + index_logical_state_is_supported(istate, 0); +} + static int index_logical_digest(const struct index_state *istate, unsigned int extra_benign_flags, unsigned char *out) { static const char domain[] = "git-clean-status-logical-index-v1"; - const unsigned int persistent_flags = - CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; - const unsigned int benign_flags = - CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID; struct git_hash_ctx ctx; uint32_t value; int initialized = 0, ret = -1; @@ -282,12 +328,13 @@ static int index_logical_digest(const struct index_state *istate, * CE_CONTENT_CHECK_REQUIRED must not disappear with the process * which raised it. */ - if (ce->ce_flags & ~(persistent_flags | benign_flags | - extra_benign_flags)) + if (!index_entry_logical_state_is_supported( + ce, extra_benign_flags)) goto done; put_be32(&value, ce->ce_mode); hash_length_delimited(&ctx, &value, sizeof(value)); - put_be32(&value, ce->ce_flags & persistent_flags); + put_be32(&value, + ce->ce_flags & LOGICAL_INDEX_PERSISTENT_FLAGS); hash_length_delimited(&ctx, &value, sizeof(value)); hash_length_delimited(&ctx, ce->oid.hash, istate->repo->hash_algo->rawsz); diff --git a/clean-status-index.h b/clean-status-index.h index 6da2b37b73031a..fc473d6c6f1330 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -48,5 +48,7 @@ int clean_status_index_logical_digest(const struct index_state *istate, unsigned char *out); int clean_status_index_logical_digest_after_status( const struct index_state *istate, unsigned char *out); +int clean_status_index_can_reuse_source_logical_hash( + const struct index_state *istate); #endif /* CLEAN_STATUS_INDEX_H */ diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 0459708491cd78..b0f61b2d9f6d19 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -645,6 +645,10 @@ test_expect_success DURABLE_FSMONITOR \ 'normal status restores namespace-specific history outside the index' ' test_when_finished "stop_daemon external-history" && setup_repo external-history && + mkdir -p external-history/cached/deep && + test_commit -C external-history nested cached/deep/tracked && + test-tool -C external-history chmtime =-60 cached/deep/tracked && + git -C external-history update-index --refresh && git -C external-history config core.untrackedCache true && git -C external-history config index.skipHash true && git -C external-history config status.renameLimit 100 && @@ -763,6 +767,28 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" external-merge.trace && rm external-history/.git/MERGE_HEAD && + # A root-only dirty event stays shallow after external FSUC restore and + # advances only the external acceleration history. + rm -f external-history/.git/index.csts && + cp "$sidecar" sidecar.before-dirty && + : >external-history/root-probe && + sleep 1 && + test_env GIT_TRACE2_EVENT_NESTING=10 \ + GIT_TRACE2_EVENT="$PWD/external-dirty.trace" \ + git -C external-history status >actual.dirty && + test_grep root-probe actual.dirty && + test_trace2_data fsmonitor history/external-physical-alias 1 \ + Date: Tue, 21 Jul 2026 12:06:20 -0500 Subject: [PATCH 095/403] preload-index: factor the ordinary preload eligibility predicate preload_thread() spells out which index entries require a filesystem lookup. A bulk preloader must begin with those same exclusions before applying its stricter publication rules. Extract the existing checks into preload_entry_needs_stat(), covering staged entries, gitlinks, up-to-date entries, skip-worktree entries, and fsmonitor-valid entries. Keep the ordinary preload loop and its ordering unchanged. Signed-off-by: Taylor Blau --- preload-index.c | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/preload-index.c b/preload-index.c index b222821b448526..10bd66affe169c 100644 --- a/preload-index.c +++ b/preload-index.c @@ -44,6 +44,15 @@ struct thread_data { int t2_nr_lstat; }; +static int preload_entry_needs_stat(const struct cache_entry *ce) +{ + return !ce_stage(ce) && + !S_ISGITLINK(ce->ce_mode) && + !ce_uptodate(ce) && + !ce_skip_worktree(ce) && + !(ce->ce_flags & CE_FSMONITOR_VALID); +} + static void *preload_thread(void *_data) { int nr, last_nr; @@ -61,15 +70,7 @@ static void *preload_thread(void *_data) struct cache_entry *ce = *cep++; struct stat st; - if (ce_stage(ce)) - continue; - if (S_ISGITLINK(ce->ce_mode)) - continue; - if (ce_uptodate(ce)) - continue; - if (ce_skip_worktree(ce)) - continue; - if (ce->ce_flags & CE_FSMONITOR_VALID) + if (!preload_entry_needs_stat(ce)) continue; if (p->progress && !(nr & 31)) { struct progress_data *pd = p->progress; From 5a2881685502f01a4139530f136a25598c40a436 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:48:46 -0700 Subject: [PATCH 096/403] fsmonitor: centralize complete fsmonitor invalidation The fsmonitor failure path cleared tracked validity bits and disabled untracked-cache monitoring, but left the separate fsmonitor_untracked_valid proof intact. An untrusted cache token could therefore outlive the tracked state it was meant to certify. Extract invalidate_all_fsmonitor() and call it from the existing failure branch. Clear every CE_FSMONITOR_VALID bit, revoke the untracked-token proof, disable fsmonitor use for the untracked cache, and set FSMONITOR_CHANGED only when a tracked validity bit actually changed. The new helper has an immediate production consumer. It neither issues nor closes a provider token and introduces no independent benchmark. Signed-off-by: Taylor Blau --- fsmonitor.c | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index 4d4979770a27c6..dea229e7a8597a 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -832,6 +832,23 @@ static int apply_fsmonitor_paths(struct index_state *istate, return count; } +static void invalidate_all_fsmonitor(struct index_state *istate) +{ + unsigned int i; + int changed = 0; + + for (i = 0; i < istate->cache_nr; i++) { + if (istate->cache[i]->ce_flags & CE_FSMONITOR_VALID) + changed = 1; + istate->cache[i]->ce_flags &= ~CE_FSMONITOR_VALID; + } + istate->fsmonitor_untracked_valid = 0; + if (istate->untracked) + istate->untracked->use_fsmonitor = 0; + if (changed) + istate->cache_changed |= FSMONITOR_CHANGED; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -1029,24 +1046,7 @@ void refresh_fsmonitor(struct index_state *istate) * we've actually changed entries, so keep track if we * actually changed entries or not. */ - int is_cache_changed = 0; - - for (i = 0; i < istate->cache_nr; i++) { - if (istate->cache[i]->ce_flags & CE_FSMONITOR_VALID) { - is_cache_changed = 1; - istate->cache[i]->ce_flags &= ~CE_FSMONITOR_VALID; - } - } - - /* - * If we're going to check every file, ensure we save - * the results. - */ - if (is_cache_changed) - istate->cache_changed |= FSMONITOR_CHANGED; - - if (istate->untracked) - istate->untracked->use_fsmonitor = 0; + invalidate_all_fsmonitor(istate); } trace2_region_leave("fsmonitor", "apply_results", istate->repo); From 5684c457ea684ffb54648227de32e86bd17dbde3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 11:43:40 -0500 Subject: [PATCH 097/403] status: refresh external history before exact proofs An exact clean porcelain-v2 status can publish a physical CSTS proof after advancing the builtin fsmonitor boundary, while leaving the external CSH1 checkpoint at an older token. If another Git later rewrites the index, plain status restores that stale checkpoint and can receive a trivial response from a token which no longer replays. Enable external-history publication for the exact clean producer. Capture the source logical hash before refresh, publish CSH1 for the closed token first, and issue CSTS only after that publication succeeds. If either acceleration-only publication succeeds but the physical proof cannot be installed, keep the existing rollback behavior instead of writing the main index. External checkpoints carry fsmonitor and untracked-cache acceleration state, but not cache-entry stat data. A fresh status can repair that data, set CE_ENTRY_CHANGED, and then take the publication path above. Do not let a fresh checkpoint roll back the write which makes those repairs durable. After restoring a foreign checkpoint, keep the existing no-spill behavior. Treat absent optional UNTR and FSUC payloads as NULL while encoding CSH1. Repositories without an untracked cache otherwise reject the checkpoint which CSTS now depends on. Cover both plain bootstrap status and exact porcelain status: the first repair writes the index without publishing CSH1, and the following exact status can publish CSTS without another index write. --- .../technical/status-clean-proof.adoc | 10 +++- builtin/commit.c | 53 +++++++++++++---- clean-status-history.c | 51 ++++++++++++++++- clean-status.h | 2 + t/t7530-status-clean-sidecar.sh | 57 +++++++++++++++++-- 5 files changed, 151 insertions(+), 22 deletions(-) diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc index 0ee74695697bca..cbfd8fc892de04 100644 --- a/Documentation/technical/status-clean-proof.adoc +++ b/Documentation/technical/status-clean-proof.adoc @@ -124,6 +124,9 @@ implementation has rewritten or re-encoded the index. The store is consulted only after the ordinary index entries have been read. It cannot answer a status command by itself, but a successful restore can publish a new physical proof for the next unchanged-index plain status. +The exact clean porcelain-v2 producer also refreshes this checkpoint +before publishing its physical proof, so both files name the same +provider boundary after a later foreign index rewrite. For an index at ``, a checkpoint is stored as `.csh1.`. The namespace covers the checkpoint @@ -172,6 +175,7 @@ succeeds, status rolls back the pending acceleration-only index update. After an external restore, status also leaves the main index untouched if republication fails, so one proof namespace is not copied over another implementation's index extensions. Only literal normal status -enables this lane; commands capable of logical index changes retain the -normal index-writing path. Optional-lock-free commands neither publish -checkpoints nor use this rollback path. +and the exact clean porcelain-v2 producer enable this lane; commands +capable of logical index changes retain the normal index-writing path. +Optional-lock-free commands neither publish checkpoints nor use this +rollback path. diff --git a/builtin/commit.c b/builtin/commit.c index eb266fd1304810..7804bcef724670 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1736,8 +1736,6 @@ struct repository *repo UNUSED) prefix, argv); s.allow_clean_status_shortcuts = default_status_command && !s.pathspec.nr; - if (s.allow_clean_status_shortcuts) - clean_status_enable_external_history(the_repository); normal_has_head = default_status_command && !repo_get_oid(the_repository, s.reference, &oid); exact_clean_query = exact_clean_command && @@ -1752,6 +1750,8 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; + if (s.allow_clean_status_shortcuts || exact_clean_query) + clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; if ((exact_clean_query || normal_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { @@ -1766,6 +1766,9 @@ struct repository *repo UNUSED) status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); + if (exact_clean_query && use_optional_locks()) + clean_status_capture_external_history_source( + the_repository->index); if (normal_clean_query && use_optional_locks() && clean_status_external_history_was_restored( the_repository->index)) @@ -1800,23 +1803,49 @@ struct repository *repo UNUSED) wt_status_collect(&s); - if (exact_clean_command && 0 <= fd && - clean_status_issue_sidecar( - &s, &clean_digest, &index_lock, 0)) - fd = -1; if (0 <= fd) { int external_restored = clean_status_external_history_was_restored( the_repository->index); - int external_saved = - clean_status_save_external_history( + int external_saved = 0; + int preserve_entry_changes = + !external_restored && + (the_repository->index->cache_changed & + CE_ENTRY_CHANGED); + + /* + * Publish resumable history before the physical clean proof. + * A later foreign index rewrite can only recover the proof if + * both files name the same provider boundary. + * + * CSH1 carries acceleration state, not refreshed stat data. + * A fresh checkpoint names the pre-repair physical index; do + * not let publishing it roll back the write which makes an + * entry repair durable. Restored checkpoints stay no-spill + * for foreign index writers. + */ + if (!preserve_entry_changes && + (!exact_clean_query || + (clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index) && + !s.change.nr && !s.untracked.nr && !s.ignored.nr))) + external_saved = clean_status_save_external_history( the_repository->index); - if (normal_clean_query && external_restored && - clean_status_issue_sidecar( - &s, &clean_digest, &index_lock, 1)) + if (exact_clean_query) { + if (external_saved && + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 0)) + fd = -1; + else if (external_restored || external_saved) { + rollback_lock_file(&index_lock); + fd = -1; + } + } else if (normal_clean_query && external_restored && + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 1)) { fd = -1; - else if (external_restored || external_saved) { + } else if (external_restored || external_saved) { rollback_lock_file(&index_lock); fd = -1; } diff --git a/clean-status-history.c b/clean-status-history.c index 03f9c209b56104..82f8f1af0faccc 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -343,6 +343,49 @@ static int external_history_namespace(struct index_state *istate, char *out) return ret; } +void clean_status_capture_external_history_source( + struct index_state *istate) +{ + struct clean_status_history_store_record record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + struct clean_status_state *state = istate->clean_status; + char proof_namespace[GIT_MAX_HEXSZ + 1]; + + if (!clean_status_external_history_enabled(istate) || + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || + !state) + goto done; + if (state->source_logical_hash_valid) + goto done; + if (!clean_status_has_persistent_fsmonitor_semantic_history(istate)) + goto done; + if (clean_status_index_snapshot_pin(&snapshot, istate)) + goto done; + if (!external_history_namespace(istate, proof_namespace) && + !clean_status_history_store_load( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo, &record) && + clean_status_index_can_reuse_source_logical_hash(istate) && + clean_status_history_checkpoint_source_matches( + istate->repo->index_file, &record.checkpoint, + &snapshot, istate->repo->hash_algo)) { + memcpy(state->source_logical_hash, + record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz); + } else if (clean_status_index_logical_digest( + istate, state->source_logical_hash)) { + goto done; + } + if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + goto done; + state->source_logical_hash_valid = 1; + +done: + clean_status_index_snapshot_release(&snapshot); + clean_status_history_store_record_release(&record); +} + static struct clean_status_external_checkpoint * clean_status_prepare_external_history(struct index_state *istate) { @@ -408,7 +451,9 @@ clean_status_prepare_external_history(struct index_state *istate) (const unsigned char *)checkpoint->fsmonitor.buf; checkpoint->checkpoint.fsmonitor_len = checkpoint->fsmonitor.len; checkpoint->checkpoint.untracked_cache = - (const unsigned char *)checkpoint->untracked_cache.buf; + checkpoint->untracked_cache.len ? + (const unsigned char *)checkpoint->untracked_cache.buf : + NULL; checkpoint->checkpoint.untracked_cache_len = checkpoint->untracked_cache.len; checkpoint->checkpoint.fsmonitor_config = @@ -416,7 +461,9 @@ clean_status_prepare_external_history(struct index_state *istate) checkpoint->checkpoint.fsmonitor_config_len = checkpoint->fsmonitor_config.len; checkpoint->checkpoint.fsmonitor_untracked = - (const unsigned char *)checkpoint->fsmonitor_untracked.buf; + checkpoint->fsmonitor_untracked.len ? + (const unsigned char *)checkpoint->fsmonitor_untracked.buf : + NULL; checkpoint->checkpoint.fsmonitor_untracked_len = checkpoint->fsmonitor_untracked.len; return checkpoint; diff --git a/clean-status.h b/clean-status.h index 4432903437dd27..a50ef3af709840 100644 --- a/clean-status.h +++ b/clean-status.h @@ -106,6 +106,8 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, int clean_status_restore_external_history(struct index_state *istate); int clean_status_external_history_was_restored( const struct index_state *istate); +void clean_status_capture_external_history_source( + struct index_state *istate); int clean_status_save_external_history(struct index_state *istate); void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index b0f61b2d9f6d19..c46acb880d0375 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -62,8 +62,8 @@ prime_semantic_history () { issue_sidecar () { repo=$1 && - prime_semantic_history "$repo" && git -C "$repo" config core.autocrlf false && + prime_semantic_history "$repo" && bulk_status -C "$repo" status --porcelain=v2 >actual.issue && test_must_be_empty actual.issue && test_path_is_file "$repo/.git/index.csts" @@ -201,10 +201,9 @@ test_expect_success DURABLE_FSMONITOR \ >actual.first && test_must_be_empty actual.first && test_path_is_missing sidecar-issue/.git/index.csts && - test_grep "\"value\":\"issue-coherent-history\"" first-scan.trace && - prime_semantic_history sidecar-issue && git -C sidecar-issue config core.autocrlf false && + prime_semantic_history sidecar-issue && cp sidecar-issue/.git/index index.before && test_env GIT_TRACE2_EVENT="$PWD/issue.trace" \ @@ -212,6 +211,8 @@ test_expect_success DURABLE_FSMONITOR \ test_must_be_empty actual && test_cmp index.before sidecar-issue/.git/index && test_path_is_file sidecar-issue/.git/index.csts && + test_trace2_data fsmonitor history/external-stored 1 \ + actual && + ! test_trace2_data fsmonitor history/external-stored 1 \ + flush.out && + test_env GIT_TRACE2_EVENT="$PWD/external-stat-exact.trace" \ + bulk_status -C external-stat-exact status --porcelain=v2 \ + >actual && + test_must_be_empty actual && + ! test_trace2_data fsmonitor history/external-stored 1 \ + actual && + test_must_be_empty actual && + test_trace2_data fsmonitor history/external-stored 1 \ + sidecar-root-race.replacement/replacement-only && @@ -554,8 +601,8 @@ test_expect_success DURABLE_FSMONITOR \ git -C sidecar-v4 config index.version 4 && git -C sidecar-v4 config index.skipHash true && git -C sidecar-v4 update-index --force-write-index && - prime_semantic_history sidecar-v4 && git -C sidecar-v4 config core.autocrlf false && + prime_semantic_history sidecar-v4 && dd if=/dev/zero of=zeros bs=20 count=1 2>/dev/null && tail -c 20 sidecar-v4/.git/index >trailer && From 32321fdb3df63996d5fb89e86d93a065070f1ab0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 10:30:31 -0500 Subject: [PATCH 098/403] preload-index: enable opt-in APFS bulk preloading The APFS backend can enumerate tracked paths, but preload_index() never invokes it. Publishing incomplete or inconclusive directory-scan results would bypass the existing per-entry correctness checks. Run the bulk collector before ordinary threaded preload only for full-index requests with core.preloadIndexBulk and core.preloadIndex enabled and no active fsmonitor provider. Require local APFS, Darwin 20 or newer, and O_NOFOLLOW_ANY support; leave the option off by default. Retain an O_NOFOLLOW worktree-root descriptor, reopen relative paths with O_NOFOLLOW_ANY, and validate the root namespace again when the scan finishes. Publish only clean entries from a completed scan, and leave missing, changed, or multiply-linked entries to ordinary lstat. Register the backend in Make, CMake, and Meson. Add APFS integration coverage for configuration and test overrides, provider exclusion, descriptor pressure, prefix siblings, index states, sparse entries, Unicode names, and hardlink fallback. Signed-off-by: Taylor Blau --- Documentation/config/core.adoc | 10 ++ Makefile | 2 + compat/preload-index/bulk-darwin.c | 47 ++++- compat/preload-index/bulk-darwin.h | 1 + contrib/buildsystems/CMakeLists.txt | 6 +- meson.build | 2 + preload-index-bulk.c | 85 +++++++++ preload-index-bulk.h | 15 ++ preload-index.c | 152 +++++++++++++++- t/README | 3 + t/meson.build | 1 + t/t7529-preload-index-apfs.sh | 262 ++++++++++++++++++++++++++++ 12 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 preload-index-bulk.c create mode 100755 t/t7529-preload-index-apfs.sh diff --git a/Documentation/config/core.adoc b/Documentation/config/core.adoc index 340329edc38143..5f01b603e5761a 100644 --- a/Documentation/config/core.adoc +++ b/Documentation/config/core.adoc @@ -729,6 +729,16 @@ relatively high IO latencies. When enabled, Git will do the index comparison to the filesystem data in parallel, allowing overlapping IO's. Defaults to true. +core.preloadIndexBulk:: + On supported filesystems, scan working tree directories in bulk before + the parallel index preload. ++ +This replaces per-entry filesystem lookups with a physical directory scan, +but may cost more than normal preload depending on filesystem and cache +state. Inconclusive scans are discarded before continuing with the normal +preload. Currently this is supported on APFS and only has an effect when +`core.preloadIndex` is enabled. Defaults to false. + core.unsetenvvars:: Windows-only: comma-separated list of environment variables' names that need to be unset before spawning any other process. diff --git a/Makefile b/Makefile index a5a0dabc80e45f..94ad0f342c92b0 100644 --- a/Makefile +++ b/Makefile @@ -1382,8 +1382,10 @@ LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o ifdef PRELOAD_INDEX_BULK_BACKEND +BASIC_CFLAGS += -DHAVE_PRELOAD_INDEX_BULK PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-index.o PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o +PRELOAD_INDEX_BULK_OBJS += preload-index-bulk.o PRELOAD_INDEX_BULK_OBJS += compat/preload-index/bulk-$(PRELOAD_INDEX_BULK_BACKEND).o PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) endif diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 22a0e16def307f..9b9adba79b2ccd 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -1,6 +1,7 @@ #include "git-compat-util.h" #include +#include #include #include "compat/precompose_utf8.h" @@ -64,6 +65,27 @@ static int preload_bulk_darwin_open_dir_at( O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); } +int preload_bulk_darwin_supports_nofollow_any(void) +{ +#ifdef O_NOFOLLOW_ANY + struct utsname uts; + char *end; + unsigned long major; + + /* + * O_NOFOLLOW_ANY arrived in Darwin 20. Older kernels accept the + * same bit as O_ALERT without enforcing no-follow semantics. + */ + if (uname(&uts) || !isdigit((unsigned char)uts.release[0])) + return 0; + errno = 0; + major = strtoul(uts.release, &end, 10); + return !errno && end != uts.release && *end == '.' && major >= 20; +#else + return 0; +#endif +} + static int preload_bulk_darwin_open_relative(struct preload_bulk_scan *scan, const char *path) { @@ -449,12 +471,35 @@ static int scan_directory(struct preload_bulk_worker *worker, return ret; } +static const char *start_scan(struct preload_bulk_scan *scan) +{ + const char *error; + + repo_precompose_utf8_prepare(scan->repo); + error = preload_bulk_darwin_open_root(scan); + if (error) + return error; + return preload_bulk_darwin_snapshot_root(scan); +} + +static const char *finish_scan(struct preload_bulk_scan *scan) +{ + return preload_bulk_darwin_validate_root(scan); +} + static const struct preload_bulk_backend darwin_backend = { + .start = start_scan, + .finish = finish_scan, + .release = preload_bulk_darwin_release, .open_dir_at = preload_bulk_darwin_open_dir_at, .scan_directory = scan_directory, }; const struct preload_bulk_backend *preload_bulk_platform_backend(void) { - return &darwin_backend; +#ifdef O_NOFOLLOW_ANY + if (preload_bulk_darwin_supports_nofollow_any()) + return &darwin_backend; +#endif + return NULL; } diff --git a/compat/preload-index/bulk-darwin.h b/compat/preload-index/bulk-darwin.h index c69886ac972268..7c5c45ee947651 100644 --- a/compat/preload-index/bulk-darwin.h +++ b/compat/preload-index/bulk-darwin.h @@ -12,6 +12,7 @@ struct preload_bulk_darwin_data { fsid_t root_fsid; }; +int preload_bulk_darwin_supports_nofollow_any(void); /* * Exposed so that tests can validate kernel-supplied records directly. */ diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index e24ec739720dc5..87152a27fe7cb1 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -275,7 +275,8 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") - add_compile_definitions(PRECOMPOSE_UNICODE USE_ST_TIMESPEC) + add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE + USE_ST_TIMESPEC) list(APPEND compat_SOURCES compat/darwin/procinfo.c compat/precompose_utf8.c @@ -673,7 +674,8 @@ parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") list(APPEND libgit_SOURCES preload-index-bulk-index.c - preload-index-bulk-thread.c) + preload-index-bulk-thread.c + preload-index-bulk.c) endif() list(TRANSFORM libgit_SOURCES PREPEND "${CMAKE_SOURCE_DIR}/") diff --git a/meson.build b/meson.build index 2bdff5bb374325..74a1548af19a6f 100644 --- a/meson.build +++ b/meson.build @@ -1297,6 +1297,7 @@ endif if host_machine.system() == 'darwin' compat_sources += 'compat/precompose_utf8.c' + libgit_c_args += '-DHAVE_PRELOAD_INDEX_BULK' libgit_c_args += '-DPRECOMPOSE_UNICODE' libgit_c_args += '-DPROTECT_HFS_DEFAULT' endif @@ -1352,6 +1353,7 @@ elif host_machine.system() == 'darwin' libgit_sources += [ 'preload-index-bulk-index.c', 'preload-index-bulk-thread.c', + 'preload-index-bulk.c', ] else compat_sources += 'compat/stub/procinfo.c' diff --git a/preload-index-bulk.c b/preload-index-bulk.c new file mode 100644 index 00000000000000..31c370e2813e58 --- /dev/null +++ b/preload-index-bulk.c @@ -0,0 +1,85 @@ +#include "git-compat-util.h" +#include "preload-index-bulk.h" +#include "read-cache-ll.h" + +static int backend_available(const struct preload_bulk_backend *backend) +{ + return backend && backend->start && backend->finish && + backend->release && backend->open_dir_at && + backend->scan_directory; +} + +int preload_bulk_available(void) +{ + return backend_available(preload_bulk_platform_backend()); +} + +int preload_bulk_collect(struct index_state *istate, int threads, + struct preload_bulk_result *result) +{ + const struct preload_bulk_backend *backend = + preload_bulk_platform_backend(); + struct preload_bulk_scan scan = { + .repo = istate->repo, + .istate = istate, + .backend = backend, + .root_fd = -1, + .threads = threads, + }; + struct preload_bulk_run_result run_result = { 0 }; + const char *start_error, *finish_error = NULL; + int scan_error = -1; + int clean; + + memset(result, 0, sizeof(*result)); + result->outcome = "start-fallback"; + result->reason = "backend-unavailable"; + if (!backend_available(backend)) + return -1; + + CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); + start_error = backend->start(&scan); + if (!start_error) { + scan_error = preload_bulk_run_scan(&scan, &run_result); + finish_error = backend->finish(&scan); + } + + clean = !start_error && !scan_error && !finish_error && + !run_result.changed_dirs && + !run_result.malformed; + result->run = run_result; + if (start_error) { + result->outcome = "start-fallback"; + result->reason = start_error; + } else if (run_result.changed_dirs) { + result->outcome = "scan-fallback"; + result->reason = "filesystem-race"; + } else if (run_result.malformed) { + result->outcome = "scan-fallback"; + result->reason = "malformed-record"; + } else if (scan_error) { + result->outcome = "scan-fallback"; + result->reason = "scan-error"; + } else if (finish_error) { + result->outcome = "finish-fallback"; + result->reason = finish_error; + } else { + result->outcome = "complete"; + result->reason = NULL; + } + if (clean) { + result->tracked_state = scan.tracked_state; + result->nr = istate->cache_nr; + scan.tracked_state = NULL; + } + + backend->release(&scan); + free(scan.tracked_state); + return clean ? 0 : -1; +} + +void preload_bulk_result_release(struct preload_bulk_result *result) +{ + FREE_AND_NULL(result->tracked_state); + memset(result, 0, sizeof(*result)); +} diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 3272ce41ee81ec..45899e56e58a46 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -52,6 +52,9 @@ struct preload_bulk_worker { }; struct preload_bulk_backend { + const char *(*start)(struct preload_bulk_scan *scan); + const char *(*finish)(struct preload_bulk_scan *scan); + void (*release)(struct preload_bulk_scan *scan); int (*open_dir_at)(struct preload_bulk_worker *worker, int parent_fd, const char *name); /* @@ -83,6 +86,14 @@ struct preload_bulk_run_result { int threads; }; +struct preload_bulk_result { + unsigned char *tracked_state; + size_t nr; + const char *outcome; + const char *reason; + struct preload_bulk_run_result run; +}; + void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, @@ -101,5 +112,9 @@ void preload_bulk_record_tracked( int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); const struct preload_bulk_backend *preload_bulk_platform_backend(void); +int preload_bulk_collect(struct index_state *istate, int threads, + struct preload_bulk_result *result); +int preload_bulk_available(void); +void preload_bulk_result_release(struct preload_bulk_result *result); #endif /* PRELOAD_INDEX_BULK_H */ diff --git a/preload-index.c b/preload-index.c index 10bd66affe169c..f77760f05def9b 100644 --- a/preload-index.c +++ b/preload-index.c @@ -12,6 +12,9 @@ #include "gettext.h" #include "parse.h" #include "preload-index.h" +#ifdef HAVE_PRELOAD_INDEX_BULK +#include "preload-index-bulk.h" +#endif #include "progress.h" #include "read-cache.h" #include "thread-utils.h" @@ -28,6 +31,8 @@ */ #define MAX_PARALLEL (20) #define THREAD_COST (500) +#define BULK_MAX_PARALLEL (32) +#define BULK_ENTRIES_PER_THREAD (5000) struct progress_data { unsigned long n; @@ -104,6 +109,144 @@ static void *preload_thread(void *_data) return NULL; } +#ifdef HAVE_PRELOAD_INDEX_BULK +static int stat_data_is_zero(const struct stat_data *sd) +{ + return !sd->sd_ctime.sec && + !sd->sd_ctime.nsec && + !sd->sd_mtime.sec && + !sd->sd_mtime.nsec && + !sd->sd_dev && + !sd->sd_ino && + !sd->sd_uid && + !sd->sd_gid && + !sd->sd_size; +} + +static int preload_bulk_entry_is_useful(const struct cache_entry *ce) +{ + return preload_entry_needs_stat(ce) && + !ce_intent_to_add(ce) && + !(ce->ce_flags & (CE_VALID | CE_REMOVE)) && + (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) && + !stat_data_is_zero(&ce->ce_stat_data); +} + +static size_t preload_bulk_useful_candidates(struct index_state *index) +{ + size_t useful = 0; + + for (size_t i = 0; i < index->cache_nr; i++) + if (preload_bulk_entry_is_useful(index->cache[i])) + useful++; + return useful; +} + +static size_t preload_bulk_publish_clean( + struct index_state *index, + const struct preload_bulk_result *result) +{ + size_t applied = 0; + + if (result->nr != index->cache_nr) + BUG("bulk preload result does not match the index"); + + for (size_t i = 0; i < result->nr; i++) { + struct cache_entry *ce; + unsigned char state = result->tracked_state[i]; + + if (state != PRELOAD_BULK_TRACKED_CLEAN) + continue; + ce = index->cache[i]; + if (!preload_bulk_entry_is_useful(ce)) + continue; + ce_mark_uptodate(ce); + mark_fsmonitor_valid(index, ce); + applied++; + } + return applied; +} + +static int preload_bulk_threads(size_t useful) +{ + int cpus = online_cpus(); + int threads = DIV_ROUND_UP(useful, BULK_ENTRIES_PER_THREAD); + + if (threads < 1) + threads = 1; + if (cpus > 0) { + int cpu_limit = cpus > BULK_MAX_PARALLEL / 2 ? + BULK_MAX_PARALLEL : cpus * 2; + + if (threads > cpu_limit) + threads = cpu_limit; + } + if (threads > BULK_MAX_PARALLEL) + threads = BULK_MAX_PARALLEL; + return threads; +} + +static void preload_bulk_trace_result( + struct index_state *index, + const struct preload_bulk_result *result, + size_t applied) +{ + trace2_data_string("index", index->repo, "preload/bulk_result", + result->outcome); + if (result->reason) + trace2_data_string("index", index->repo, + "preload/bulk_reason", result->reason); + trace2_data_intmax("index", index->repo, "preload/bulk_applied", + applied); + trace2_data_intmax("index", index->repo, "preload/bulk_dirs", + result->run.dirs); + trace2_data_intmax("index", index->repo, "preload/bulk_entries", + result->run.entries); + trace2_data_intmax("index", index->repo, "preload/bulk_calls", + result->run.bulk_calls); + trace2_data_intmax("index", index->repo, "preload/bulk_workers", + result->run.threads); +} + +static void preload_bulk_try(struct index_state *index) +{ + struct preload_bulk_result result = { 0 }; + size_t useful; + size_t applied = 0; + int enabled = 0; + int control, threads; + + /* + * Let the test variable override configuration without bypassing + * any of the proof checks. + */ + control = git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", -1); + if (control < 0) + repo_config_get_bool(index->repo, "core.preloadindexbulk", + &enabled); + else + enabled = control; + if (!enabled || + fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_DISABLED || + !preload_bulk_available()) + return; + useful = preload_bulk_useful_candidates(index); + trace2_data_intmax("index", index->repo, "preload/bulk_useful", + useful); + trace2_data_intmax("index", index->repo, "preload/bulk_cache_nr", + index->cache_nr); + if (!useful) + return; + threads = preload_bulk_threads(useful); + trace2_region_enter("index", "preload/bulk", index->repo); + if (!preload_bulk_collect(index, threads, &result)) + applied = preload_bulk_publish_clean(index, &result); + preload_bulk_trace_result(index, &result, applied); + trace2_region_leave("index", "preload/bulk", index->repo); + preload_bulk_result_release(&result); +} +#endif + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags) @@ -116,7 +259,14 @@ void preload_index(struct index_state *index, repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); - if (!HAVE_THREADS || !core_preload_index) + if (!core_preload_index) + return; + +#ifdef HAVE_PRELOAD_INDEX_BULK + if (!pathspec || !pathspec->nr) + preload_bulk_try(index); +#endif + if (!HAVE_THREADS) return; threads = index->cache_nr / THREAD_COST; diff --git a/t/README b/t/README index 4252774f86c8ce..6ba21aead64858 100644 --- a/t/README +++ b/t/README @@ -422,6 +422,9 @@ overridden by the --no-path-walk command-line argument. GIT_TEST_PRELOAD_INDEX= exercises the preload-index code path by overriding the minimum number of cache entries required per thread. +GIT_TEST_PRELOAD_INDEX_BULK= overrides the +`core.preloadIndexBulk` setting. + GIT_TEST_INDEX_THREADS= enables exercising the multi-threaded loading of the index for the whole test suite by bypassing the default number of cache entries and thread minimums. Setting this to 1 will make the diff --git a/t/meson.build b/t/meson.build index d22603637bc5a6..2507f29aeec5ff 100644 --- a/t/meson.build +++ b/t/meson.build @@ -945,6 +945,7 @@ integration_tests = [ 't7526-commit-pathspec-file.sh', 't7527-builtin-fsmonitor.sh', 't7528-signed-commit-ssh.sh', + 't7529-preload-index-apfs.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh new file mode 100755 index 00000000000000..87b49869515151 --- /dev/null +++ b/t/t7529-preload-index-apfs.sh @@ -0,0 +1,262 @@ +#!/bin/sh + +test_description='APFS bulk index preload' + +. ./test-lib.sh + +test_lazy_prereq APFS_BULK_PRELOAD ' + test_have_prereq MACOS && + darwin_major=$(uname -r) && + darwin_major=${darwin_major%%.*} && + test "$darwin_major" -ge 20 && + /bin/df -t apfs "$TRASH_DIRECTORY" >/dev/null +' + +if ! test_have_prereq APFS_BULK_PRELOAD +then + skip_all='bulk index preload requires macOS on APFS' + test_done +fi + +setup_repo () { + repo=$1 && + git init "$repo" && + mkdir -p "$repo/nested/deep" "$repo/other" && + test_write_lines root >"$repo/root" && + test_write_lines root-peer >"$repo/root-peer" && + test_write_lines nested >"$repo/nested/tracked" && + test_write_lines nested-peer >"$repo/nested/peer" && + test_write_lines deep >"$repo/nested/deep/tracked" && + test_write_lines deep-peer >"$repo/nested/deep/peer" && + test_write_lines other >"$repo/other/tracked" && + test_write_lines other-peer >"$repo/other/peer" && + git -C "$repo" add . && + git -C "$repo" commit -m base && + git -C "$repo" config core.fsmonitor false && + test-tool chmtime -120 \ + "$repo/root" "$repo/root-peer" \ + "$repo/nested/tracked" "$repo/nested/peer" \ + "$repo/nested/deep/tracked" "$repo/nested/deep/peer" \ + "$repo/other/tracked" "$repo/other/peer" && + git -C "$repo" update-index --refresh +} + +ordinary_status () { + GIT_OPTIONAL_LOCKS=0 \ + git -C "$1" -c core.preloadIndex=false \ + status --porcelain=v2 >"$2" +} + +bulk_status () { + repo=$1 && + output=$2 && + bulk_trace=$TRASH_DIRECTORY/$3 && + rm -f "$bulk_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$bulk_trace" \ + git -C "$repo" status --porcelain=v2 >"$output" +} + +check_data () { + test_trace2_data index "$2" "$3" <"$TRASH_DIRECTORY/$1" +} + +check_lstat_data () { + test_have_prereq !PTHREADS || + check_data "$1" preload/sum_lstat "$2" +} + +compare_status () { + ordinary_status "$1" expect && + bulk_status "$1" actual "$2" && + test_cmp expect actual +} + +configured_bulk_status () { + repo=$1 && + output=$2 && + bulk_trace=$TRASH_DIRECTORY/$3 && + bulk=${4-true} && + preload=${5-true} && + rm -f "$bulk_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$bulk_trace" \ + git -C "$repo" \ + -c core.preloadIndex="$preload" \ + -c core.preloadIndexBulk="$bulk" \ + status --porcelain=v2 >"$output" +} + +test_expect_success 'bulk preload follows its configuration' ' + setup_repo opt-in && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/default.trace" \ + git -C opt-in status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" default.trace && + configured_bulk_status opt-in actual enabled.trace && + test_must_be_empty actual && + test_grep "\"key\":\"preload/bulk_result\"" enabled.trace && + configured_bulk_status opt-in actual preload-disabled.trace true false && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" preload-disabled.trace +' + +test_expect_success 'test variable overrides bulk preload configuration' ' + test_env GIT_TEST_PRELOAD_INDEX_BULK=0 \ + configured_bulk_status opt-in actual disabled.trace && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" disabled.trace && + test_env GIT_TEST_PRELOAD_INDEX_BULK=1 \ + configured_bulk_status opt-in actual forced.trace false && + test_must_be_empty actual && + check_data forced.trace preload/bulk_applied 8 +' + +test_expect_success 'bulk preload waits for fsmonitor provider closure' ' + write_script opt-in/.git/hooks/fsmonitor-test <<-\EOF && + printf "token\\0" + EOF + git -C opt-in config core.fsmonitor .git/hooks/fsmonitor-test && + configured_bulk_status opt-in actual fsmonitor.trace && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" fsmonitor.trace +' + +test_expect_success 'clean entries are published without lstat' ' + setup_repo clean && + bulk_status clean actual clean.trace && + test_must_be_empty actual && + check_data clean.trace preload/bulk_applied 8 && + check_lstat_data clean.trace 0 +' + +test_expect_success ULIMIT_FILE_DESCRIPTORS \ + 'bulk preload reopens directories under a low descriptor limit' ' + git init low-fd && + for i in $(test_seq 1 64) + do + mkdir "low-fd/$i" && + test_write_lines "$i" >"low-fd/$i/tracked" || + return 1 + done && + git -C low-fd add . && + git -C low-fd commit -m base && + test-tool chmtime -120 low-fd/*/tracked && + git -C low-fd update-index --refresh && + run_with_limited_open_files \ + bulk_status low-fd actual low-fd.trace && + test_must_be_empty actual && + check_data low-fd.trace preload/bulk_applied 64 +' + +test_expect_success 'prefix siblings do not hide tracked descendants' ' + git init prefix-order && + mkdir prefix-order/feather prefix-order/feather-db && + test_write_lines tracked >prefix-order/feather/tracked && + test_write_lines sibling >prefix-order/feather-db/tracked && + git -C prefix-order add . && + git -C prefix-order commit -m base && + test-tool chmtime -120 prefix-order/feather/tracked \ + prefix-order/feather-db/tracked && + git -C prefix-order update-index --refresh && + bulk_status prefix-order actual prefix-order.trace && + test_must_be_empty actual && + check_data prefix-order.trace preload/bulk_applied 2 && + check_lstat_data prefix-order.trace 0 +' + +test_expect_success SYMLINKS \ + 'modified, deleted, typechanged, and symlink entries agree' ' + setup_repo worktree-states && + ln -s root worktree-states/link && + git -C worktree-states add link && + git -C worktree-states commit -m symlink && + mtime=$(test-tool chmtime --get worktree-states/root) && + sleep 1 && + test_write_lines moot >worktree-states/root && + test-tool chmtime "=$mtime" worktree-states/root && + rm worktree-states/nested/tracked && + rm worktree-states/nested/deep/tracked && + mkdir worktree-states/nested/deep/tracked && + rm worktree-states/other/tracked worktree-states/other/peer && + rmdir worktree-states/other && + test_write_lines other >worktree-states/other && + rm worktree-states/link && + ln -s nested/deep/peer worktree-states/link && + compare_status worktree-states worktree-states.trace && + test_file_not_empty actual +' + +test_expect_success 'staged and unmerged entries agree' ' + setup_repo index-states && + test_write_lines staged >index-states/root && + test_write_lines added >index-states/added && + git -C index-states add root added && + git -C index-states rm nested/tracked && + base=$(git -C index-states rev-parse HEAD:nested/peer) && + ours=$(printf "ours\n" | + git -C index-states hash-object -w --stdin) && + theirs=$(printf "theirs\n" | + git -C index-states hash-object -w --stdin) && + { + printf "0 %s\tnested/peer\n" "$(test_oid zero)" && + printf "100644 %s 1\tnested/peer\n" "$base" && + printf "100644 %s 2\tnested/peer\n" "$ours" && + printf "100644 %s 3\tnested/peer\n" "$theirs" + } | git -C index-states update-index --index-info && + compare_status index-states index-states.trace && + test_file_not_empty actual +' + +test_expect_success 'sparse-index entries are left unexpanded' ' + setup_repo sparse && + git -C sparse sparse-checkout init --cone --sparse-index && + git -C sparse sparse-checkout set nested && + git -C sparse ls-files --sparse >before && + test_grep "^other/$" before && + test_write_lines changed >sparse/nested/tracked && + compare_status sparse sparse.trace && + git -C sparse ls-files --sparse >after && + test_cmp before after +' + +test_expect_success UTF8_NFD_TO_NFC \ + 'decomposed Unicode names agree' ' + setup_repo unicode && + nfc=$(printf "\303\244") && + nfd=$(printf "\141\314\210") && + git -C unicode config core.precomposeunicode true && + test_write_lines unicode >"unicode/$nfd" && + git -C unicode add "$nfc" && + git -C unicode commit -m unicode && + test-tool chmtime -120 "unicode/$nfd" && + git -C unicode update-index --refresh && + compare_status unicode unicode.trace && + test_must_be_empty actual && + check_data unicode.trace preload/bulk_applied 9 && + check_lstat_data unicode.trace 0 +' + +test_expect_success 'multiply-linked entries are left to lstat' ' + setup_repo hardlink && + # Mutate through a name outside the watched worktree, then restore + # mtime. The bulk scan must reject the multiply-linked entry. + ln hardlink/root hardlink-alias && + test-tool chmtime -120 hardlink/root && + git -C hardlink update-index --refresh && + mtime=$(test-tool chmtime --get hardlink/root) && + sleep 1 && + test_write_lines moot >hardlink-alias && + test-tool chmtime "=$mtime" hardlink/root && + compare_status hardlink hardlink.trace && + test_file_not_empty actual && + check_data hardlink.trace preload/bulk_applied 7 && + check_lstat_data hardlink.trace 1 +' + +test_done From 7b4a231cf1415beea6a063378adcf3f8dd3d593c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:52:33 -0700 Subject: [PATCH 099/403] wt-status: separate untracked traversal from result collection Untracked status used one helper both to traverse the worktree and to copy untracked and ignored entries into status output. Checking whether a traversal actually validated the repository's UNTR cache requires that directory walk without copying results or recording user-facing timing. Factor the walk into wt_status_collect_untracked_1() with an explicit collection flag. Return whether the traversal used the index's own untracked cache, and populate the result lists and advice timing only when collection is requested. Retain wt_status_collect_untracked() as the collecting wrapper. Every existing production caller still requests collection, so status output and ordinary traversal behavior remain unchanged. Token adoption and validation-only production use are not added by this preparatory patch. Signed-off-by: Taylor Blau --- wt-status.c | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/wt-status.c b/wt-status.c index da642642d4a229..fab9f1af38bea6 100644 --- a/wt-status.c +++ b/wt-status.c @@ -828,15 +828,16 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) untracked_cache_preload_start_ordinary(istate, dir_flags); } -static void wt_status_collect_untracked(struct wt_status *s) +static int wt_status_collect_untracked_1(struct wt_status *s, int collect) { int i; + int used_untracked_cache; struct dir_struct dir = DIR_INIT; uint64_t t_begin = getnanotime(); struct index_state *istate = s->repo->index; if (!s->show_untracked_files) - return; + return 0; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -859,25 +860,35 @@ static void wt_status_collect_untracked(struct wt_status *s) s->untracked_cache_preloaded; fill_directory(&dir, istate, &s->pathspec); + used_untracked_cache = dir.untracked && + dir.untracked == istate->untracked; + + if (collect) { + for (i = 0; i < dir.nr; i++) { + struct dir_entry *ent = dir.entries[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(&s->untracked, ent->name); + } + string_list_sort_u(&s->untracked, 0); - for (i = 0; i < dir.nr; i++) { - struct dir_entry *ent = dir.entries[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->untracked, ent->name); - } - string_list_sort_u(&s->untracked, 0); - - for (i = 0; i < dir.ignored_nr; i++) { - struct dir_entry *ent = dir.ignored[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->ignored, ent->name); + for (i = 0; i < dir.ignored_nr; i++) { + struct dir_entry *ent = dir.ignored[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(&s->ignored, ent->name); + } + string_list_sort_u(&s->ignored, 0); } - string_list_sort_u(&s->ignored, 0); dir_clear(&dir); - if (advice_enabled(ADVICE_STATUS_U_OPTION)) + if (collect && advice_enabled(ADVICE_STATUS_U_OPTION)) s->untracked_in_ms = (getnanotime() - t_begin) / 1000000; + return used_untracked_cache; +} + +static int wt_status_collect_untracked(struct wt_status *s) +{ + return wt_status_collect_untracked_1(s, 1); } static int has_unmerged(struct wt_status *s) From 19f2a78a0de2b8660d4a7fa738e67d8b1d8b3c77 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 11:49:08 -0500 Subject: [PATCH 100/403] status: keep a forward fsmonitor boundary over stale history A foreign writer can leave a newer usable FSMN token in the named index while CSH1 still carries an older token from a prior daemon epoch. Restoring that checkpoint replaces the forward boundary with one which cannot replay. The subsequent trivial response drives semantic strong invalidation and a full content scan. When the named index and CSH1 carry different builtin IPC tokens, probe the checkpoint token before installing it. Restore only if the daemon can still return a delta from that boundary. Otherwise keep the named index token, so the ordinary forward-baseline path can query the live boundary without borrowing stale FSCF or FSUC state. The extra query runs only for differing builtin IPC tokens. Other provider schemes keep the existing restore path because their replay semantics are not established here. The focused daemon-epoch case asserts that the stale checkpoint is not restored and refresh queries the main index token. --- .../technical/status-clean-proof.adoc | 5 ++- clean-status-history.c | 44 +++++++++++++++++++ t/t7530-status-clean-sidecar.sh | 32 +++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc index cbfd8fc892de04..fb5f24da58a133 100644 --- a/Documentation/technical/status-clean-proof.adoc +++ b/Documentation/technical/status-clean-proof.adoc @@ -163,7 +163,10 @@ which can change entry membership or persistent flags. On a valid hit, status installs all checkpoint sections together in a scratch index, rechecks the pinned index, and only then replaces the in-memory acceleration state. It queries the builtin file-system -monitor from the stored token. A trivial response, provider restart, +monitor from the stored token. If the named index already carries a +different usable builtin token, status first requires the stored token +to return a delta; otherwise it keeps the named boundary for the +forward-baseline fallback. A trivial response, provider restart, malformed section, token mismatch, namespace mismatch, or index race falls back to ordinary validation. diff --git a/clean-status-history.c b/clean-status-history.c index 82f8f1af0faccc..793d930fa55633 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -6,8 +6,10 @@ #include "clean-status-internal.h" #include "dir.h" #include "environment.h" +#include "fsmonitor.h" #include "fsmonitor-clean-proof.h" #include "fsmonitor-ll.h" +#include "fsmonitor-settings.h" #include "hash-framing.h" #include "hex.h" #include "read-cache-ll.h" @@ -530,6 +532,28 @@ static int on_index_history_is_coherent(struct index_state *istate) (!istate->untracked || istate->fsmonitor_untracked_valid); } +static int has_usable_on_index_builtin_token( + const struct index_state *istate) +{ + return istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + *istate->fsmonitor_last_update && + starts_with(istate->fsmonitor_last_update, "builtin:") && + strcmp(istate->fsmonitor_last_update, "builtin:fake"); +} + +static int external_token_is_replayable(const char *token) +{ + struct fsmonitor_query_result result = + FSMONITOR_QUERY_RESULT_INIT; + int replayable = + query_builtin_fsmonitor(token, &result) == + FSMONITOR_QUERY_DELTA; + + fsmonitor_query_result_release(&result); + return replayable; +} + int clean_status_restore_external_history(struct index_state *istate) { struct clean_status_history_store_record record = @@ -608,6 +632,26 @@ int clean_status_restore_external_history(struct index_state *istate) if (!current_proof_is_writable(&parsed) || (!!parsed.untracked && !parsed.fsmonitor_untracked_valid)) goto done; + /* + * Provider tokens are opaque. A logical-index match says that the + * checkpoint names the same staged entries; it does not say that its + * token can still replay the interval which the named index already + * crossed. Probe a differing checkpoint token before replacing a + * usable on-index boundary when builtin IPC can answer that question. + * A successful delta is queried again by the normal refresh path; a + * trivial or failed probe leaves the named index intact so its token + * can take the forward-baseline fallback. + */ + if (fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + has_usable_on_index_builtin_token(istate) && + starts_with(parsed.fsmonitor_last_update, "builtin:") && + strcmp(istate->fsmonitor_last_update, + parsed.fsmonitor_last_update) && + !external_token_is_replayable(parsed.fsmonitor_last_update)) { + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-token-unreplayable", 1); + goto done; + } if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) goto done; clean_status_invalidate_current_proof(istate); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index c46acb880d0375..521f3e8cb81c6d 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -839,7 +839,6 @@ test_expect_success DURABLE_FSMONITOR \ # A failed checkpoint refresh must not spill namespace B into main. cp namespace-a-v2.index namespace-a-v2.rewrite && mv namespace-a-v2.rewrite external-history/.git/index && - test-tool -C external-history fsmonitor-client flush >flush.out && : >"$sidecar.lock" && test_when_finished "rm -f \"$sidecar.lock\"" && cp external-history/.git/index locked.before && @@ -851,7 +850,36 @@ test_expect_success DURABLE_FSMONITOR \ flush.out && + git -C external-history config status.renameLimit 100 && + test_env GIT_TRACE2_EVENT="$PWD/external-main-token.trace" \ + git -C external-history status --porcelain=v2 \ + --untracked-files=normal >actual.main-token && + test_grep "\"label\":\"do_write_index\"" \ + external-main-token.trace && + test-tool -C external-history dump-fsmonitor >main-token && + main_token=$(sed -n "s/^fsmonitor last update //p" main-token) && + + git -C external-history config status.renameLimit 200 && + test_env GIT_TRACE2_EVENT="$PWD/external-token.trace" \ + git -C external-history status >actual.token && + test_grep "nothing to commit, working tree clean" actual.token && + test_trace2_data fsmonitor history/external-token-unreplayable 1 \ + Date: Tue, 28 Jul 2026 10:31:40 -0500 Subject: [PATCH 101/403] t7529: cover APFS preload directory and root replacement A queued directory can be replaced after its parent is enumerated, and the configured worktree root can change after the bulk walk completes. Neither race may leave previously collected observations published. Add a test-only barrier after opening a selected directory or after the complete walk. Arm it only when the existing bulk-preload test override is enabled, and use it to replace a queued child or the worktree root while status is paused. Require both integration cases to discard all bulk observations and produce the same output as ordinary status. These tests check namespace identity; they do not assert a scan-wide snapshot of individual files. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 2 + preload-index-bulk.c | 31 ++++++++++++ preload-index-bulk.h | 5 ++ t/README | 9 ++++ t/t7529-preload-index-apfs.sh | 79 ++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 9b9adba79b2ccd..f5fb62af26f4d3 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -424,6 +424,8 @@ static int scan_directory(struct preload_bulk_worker *worker, fd = preload_bulk_darwin_open_relative(scan, task->path); if (fd < 0) goto out; + if (preload_bulk_test_barrier(scan, task->path)) + goto out; if (preload_bulk_darwin_fd_on_root_mount(scan, fd, &before)) { if (errno != EXDEV) goto out; diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 31c370e2813e58..97fbd4b6ef22ff 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -14,6 +15,25 @@ int preload_bulk_available(void) return backend_available(preload_bulk_platform_backend()); } +int preload_bulk_test_barrier(struct preload_bulk_scan *scan, + const char *path) +{ + struct strbuf buf = STRBUF_INIT; + int result; + + if (!scan->test_barrier_path || + strcmp(scan->test_barrier_path, path)) + return 0; + if (!scan->test_barrier_ready || !scan->test_barrier_resume) + return -1; + + write_file(scan->test_barrier_ready, "ready"); + result = strbuf_read_file(&buf, scan->test_barrier_resume, 1) > 0 ? + 0 : -1; + strbuf_release(&buf); + return result; +} + int preload_bulk_collect(struct index_state *istate, int threads, struct preload_bulk_result *result) { @@ -37,10 +57,21 @@ int preload_bulk_collect(struct index_state *istate, int threads, if (!backend_available(backend)) return -1; + if (git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) { + scan.test_barrier_path = getenv( + "GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH"); + scan.test_barrier_ready = getenv( + "GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY"); + scan.test_barrier_resume = getenv( + "GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME"); + } + CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); if (!start_error) { scan_error = preload_bulk_run_scan(&scan, &run_result); + if (!scan_error) + scan_error = preload_bulk_test_barrier(&scan, ""); finish_error = backend->finish(&scan); } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 45899e56e58a46..61bce71a454268 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -70,6 +70,9 @@ struct preload_bulk_scan { struct index_state *istate; const struct preload_bulk_backend *backend; void *platform_data; + const char *test_barrier_path; + const char *test_barrier_ready; + const char *test_barrier_resume; struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; @@ -115,6 +118,8 @@ const struct preload_bulk_backend *preload_bulk_platform_backend(void); int preload_bulk_collect(struct index_state *istate, int threads, struct preload_bulk_result *result); int preload_bulk_available(void); +int preload_bulk_test_barrier(struct preload_bulk_scan *scan, + const char *path); void preload_bulk_result_release(struct preload_bulk_result *result); #endif /* PRELOAD_INDEX_BULK_H */ diff --git a/t/README b/t/README index 6ba21aead64858..e063266240fda9 100644 --- a/t/README +++ b/t/README @@ -425,6 +425,15 @@ by overriding the minimum number of cache entries required per thread. GIT_TEST_PRELOAD_INDEX_BULK= overrides the `core.preloadIndexBulk` setting. +GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH=, +GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY=, and +GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME=, when +GIT_TEST_PRELOAD_INDEX_BULK is enabled, pause a bulk preload before +scanning the named directory. An empty directory path pauses after the +complete walk. Git writes `ready` to the ready path, then waits until it +can read from the resume path. Tests which set one barrier variable must +set all three. + GIT_TEST_INDEX_THREADS= enables exercising the multi-threaded loading of the index for the whole test suite by bypassing the default number of cache entries and thread minimums. Setting this to 1 will make the diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index 87b49869515151..a54b049e75c5b2 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -127,6 +127,61 @@ test_expect_success 'bulk preload waits for fsmonitor provider closure' ' test_grep ! "\"key\":\"preload/bulk_result\"" fsmonitor.trace ' +cleanup_race () { + exec 9>&- + if test -n "$status_pid" + then + kill "$status_pid" 2>/dev/null || : + wait "$status_pid" 2>/dev/null || : + fi + status_pid= && + rm -f "$ready" "$resume" +} + +wait_for_ready () { + for i in $(test_seq 1 1000) + do + test "$(cat "$ready" 2>/dev/null)" = ready && return 0 + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +start_raced_status () { + repo=$1 && + barrier=$2 && + ready=$TRASH_DIRECTORY/$repo.ready && + resume=$TRASH_DIRECTORY/$repo.resume && + race_trace=$TRASH_DIRECTORY/$repo.trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH="$barrier" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY="$ready" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + git -C "$repo" status --porcelain=v2 >actual 9>&- & + status_pid=$! + } && + wait_for_ready +} + +finish_raced_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= && + ordinary_status "$1" expect && + test_cmp expect actual && + test_trace2_data index preload/bulk_applied 0 <"$race_trace" +} + test_expect_success 'clean entries are published without lstat' ' setup_repo clean && bulk_status clean actual clean.trace && @@ -259,4 +314,28 @@ test_expect_success 'multiply-linked entries are left to lstat' ' check_lstat_data hardlink.trace 1 ' +test_expect_success PIPE 'queued child replacement discards observations' ' + setup_repo child-race && + test_when_finished cleanup_race && + start_raced_status child-race nested/deep && + mv child-race/nested/deep child-race/nested/deep-away && + mkdir child-race/nested/deep && + test_write_lines dirty >child-race/nested/deep/tracked && + test_write_lines deep-peer >child-race/nested/deep/peer && + finish_raced_status child-race && + test_file_not_empty actual +' + +test_expect_success PIPE,SYMLINKS \ + 'worktree root replacement discards observations' ' + setup_repo root-race && + test_when_finished cleanup_race && + start_raced_status root-race "" && + mv root-race root-race-away && + ln -s root-race-away root-race && + test_write_lines dirty >root-race-away/root && + finish_raced_status root-race && + test_file_not_empty actual +' + test_done From cae38fd1a9f97a41d5bb61f0866dc2a88df3e200 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:50:12 -0500 Subject: [PATCH 102/403] status: close fsmonitor tokens around complete status scans A provider token obtained before a tracked or untracked scan does not cover worktree changes racing with that scan. Publishing it as an FSMN or FSUC proof can make a later status trust an index or untracked-cache snapshot that was never valid at that boundary. Keep bootstrap tokens pending while tracked entries are refreshed and any rooted untracked cache is traversed. For builtin providers, query again after the scans, apply intervening paths, and repeat the affected scans until a clean boundary is found or three closing queries are exhausted. Treat a trivial closing reply as complete invalidation followed by another scan; accept its replacement token only after a later clean reply. Reject provider errors, incomplete cache proofs, and exhausted retries with strong invalidation and complete fallback scans. Hook providers cannot perform a closing IPC query, so accept their token only after a complete tracked and applicable untracked collection; reject failed or trivial hook replies. A matching on-disk FSUC token can now authorize replay of recursive UNTR validity established by S01. Reconstruct that validity only after the entire extension has decoded, and only for directories without a cached per-directory exclude digest. This lets a warm status prune known-empty subtrees while still rechecking a changed .gitignore, including changes made through an unwatched hardlink alias. Trust an indexed exclude's metadata alone only when its identity is reliable and it has exactly one link; otherwise retain the complete content-hash check. Route both status collection and commit index refresh through the shared closure. Preserve ordinary behavior for existing paired state, path-limited requests, and ignored-mode collection. Cover clean and changed closures, trivial replies, retry exhaustion, provider errors, on-disk FSMN/FSUC publication, warm empty-subtree pruning, descendant events, and cached exclude changes. Signed-off-by: Taylor Blau --- builtin/commit.c | 7 +- dir.c | 467 ++++++++++++++++++++++++++-- dir.h | 11 +- fsmonitor-ll.h | 18 ++ fsmonitor.c | 215 ++++++++++++- read-cache-ll.h | 4 +- read-cache.c | 1 + t/t7519-status-fsmonitor.sh | 603 ++++++++++++++++++++++++++++++++++++ wt-status.c | 247 +++++++++++++-- wt-status.h | 8 + 10 files changed, 1518 insertions(+), 63 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 54e41c8ba578c1..e2f4d08b347707 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1628,9 +1628,10 @@ struct repository *repo UNUSED) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); wt_status_start_untracked_cache_preload(&s); - refresh_index(the_repository->index, - REFRESH_QUIET|REFRESH_UNMERGED|progress_flag, - &s.pathspec, NULL, NULL); + wt_status_refresh_index( + &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag, + s.show_untracked_files != SHOW_NO_UNTRACKED_FILES && + !s.show_ignored_mode); if (use_optional_locks()) fd = repo_hold_locked_index(the_repository, &index_lock, 0); diff --git a/dir.c b/dir.c index 940f6c744ecc7e..b2a4e4b2e5adc4 100644 --- a/dir.c +++ b/dir.c @@ -28,6 +28,7 @@ #include "varint.h" #include "ewah/ewok.h" #include "fsmonitor-ll.h" +#include "fsmonitor.h" #include "read-cache-ll.h" #include "setup.h" #include "sparse-index.h" @@ -79,10 +80,16 @@ struct untracked_cache_preload_task { char *path; struct stat_data stat_data; struct object_id exclude_oid; + unsigned int exclude_mode; unsigned int was_valid : 1; unsigned int stat_checked : 1; unsigned int stat_matches : 1; unsigned int exclude_matches : 1; + unsigned int exclude_index_present : 1; + unsigned int exclude_index_candidate : 1; + unsigned int exclude_index_matches : 1; + unsigned int exclude_index_content_matches : 1; + unsigned int normalize_exclude_oid : 1; unsigned int update_stat_data : 1; }; @@ -97,9 +104,11 @@ struct untracked_cache_preload_data { struct untracked_cache_preload { struct repository *repo; + struct index_state *istate; struct untracked_cache *uc; struct untracked_cache_dir *root; struct untracked_cache_preload_task *tasks; + struct object_id *exclude_index_oids; struct untracked_cache_preload_data *data; struct cache_time index_timestamp; char *exclude_per_dir; @@ -107,10 +116,12 @@ struct untracked_cache_preload { int threads; unsigned int dir_flags; uint64_t started_at; + unsigned int fsmonitor_excludes_only : 1; }; #define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 #define UNTRACKED_CACHE_PRELOAD_COST 1000 +#define UNTRACKED_CACHE_EXCLUDE_PRELOAD_COST 256 #define UNTRACKED_CACHE_MAX_EXCLUDE_SIZE (1024 * 1024) static void invalidate_gitignore(struct untracked_cache *uc, @@ -128,18 +139,22 @@ static void collect_untracked_cache_preload_tasks( struct strbuf *path, struct untracked_cache_preload_task **tasks, size_t *nr, - size_t *alloc) + size_t *alloc, + int fsmonitor_excludes_only) { size_t i; - ALLOC_GROW(*tasks, *nr + 1, *alloc); - memset(&(*tasks)[*nr], 0, sizeof(**tasks)); - (*tasks)[*nr].ucd = ucd; - (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); - (*tasks)[*nr].stat_data = ucd->stat_data; - oidcpy(&(*tasks)[*nr].exclude_oid, &ucd->exclude_oid); - (*tasks)[*nr].was_valid = ucd->valid; - (*nr)++; + if (!fsmonitor_excludes_only || + !is_null_oid(&ucd->exclude_oid)) { + ALLOC_GROW(*tasks, *nr + 1, *alloc); + memset(&(*tasks)[*nr], 0, sizeof(**tasks)); + (*tasks)[*nr].ucd = ucd; + (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); + (*tasks)[*nr].stat_data = ucd->stat_data; + oidcpy(&(*tasks)[*nr].exclude_oid, &ucd->exclude_oid); + (*tasks)[*nr].was_valid = ucd->valid; + (*nr)++; + } for (i = 0; i < ucd->dirs_nr; i++) { struct untracked_cache_dir *child = ucd->dirs[i]; @@ -149,7 +164,7 @@ static void collect_untracked_cache_preload_tasks( strbuf_addch(path, '/'); strbuf_addstr(path, child->name); collect_untracked_cache_preload_tasks(child, path, tasks, nr, - alloc); + alloc, fsmonitor_excludes_only); strbuf_setlen(path, old_len); } } @@ -194,7 +209,8 @@ static int exclude_path_matches_fd(const char *path, static int cached_exclude_file_matches( const struct git_hash_algo *algo, - const char *path, const struct object_id *cached_oid) + const char *path, const struct object_id *cached_oid, + struct object_id *raw_oid_out, unsigned int *mode_out) { struct object_id raw_oid, normalized_oid; struct stat st, st_after; @@ -218,9 +234,13 @@ static int cached_exclude_file_matches( !path_namespace_stat_equal(&st, &st_after) || !exclude_path_matches_fd(path, &st_after)) goto out; + if (mode_out) + *mode_out = st_after.st_mode; /* add_patterns() may record either the blob or its LF-normalized form. */ hash_object_file(algo, buf, size, OBJ_BLOB, &raw_oid); + if (raw_oid_out) + oidcpy(raw_oid_out, &raw_oid); if (oideq(&raw_oid, cached_oid)) { ret = 1; goto out; @@ -236,8 +256,87 @@ static int cached_exclude_file_matches( return ret; } +static int cached_exclude_file_matches_index_stat( + const struct stat_data *sd, + const struct stat *st) +{ + struct stat_data current; + struct stat st_copy = *st; + + /* + * Compare every field saved in the index, independent of the user's + * ordinary stat-match settings. Unreliable object identities and + * multiply-linked files can conceal changes through paths outside + * the monitor's watch cone, so both retain the content-hash check. + */ + if (!fstat_is_reliable() || !S_ISREG(st->st_mode) || + st->st_nlink != 1) + return 0; + fill_stat_data(¤t, &st_copy); + return sd->sd_ctime.sec == current.sd_ctime.sec && + sd->sd_ctime.nsec == current.sd_ctime.nsec && + sd->sd_mtime.sec == current.sd_mtime.sec && + sd->sd_mtime.nsec == current.sd_mtime.nsec && + sd->sd_dev == current.sd_dev && + sd->sd_ino == current.sd_ino && + sd->sd_uid == current.sd_uid && + sd->sd_gid == current.sd_gid && + sd->sd_size == current.sd_size; +} + +static void preload_fsmonitor_excludes_from_index( + struct untracked_cache_preload *preload) +{ + struct repo_config_values *cfg = + repo_config_values(preload->istate->repo); + size_t i; + int stat_candidates = cfg->trust_ctime && cfg->check_stat; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct strbuf exclude_path = STRBUF_INIT; + struct cache_entry *ce; + int pos; + + if (!preload->exclude_per_dir) + continue; + if (strcmp(task->path, ".")) + strbuf_addstr(&exclude_path, task->path); + if (exclude_path.len) + strbuf_addch(&exclude_path, '/'); + strbuf_addstr(&exclude_path, preload->exclude_per_dir); + pos = index_name_pos_sparse( + preload->istate, exclude_path.buf, + exclude_path.len); + if (pos < 0) + goto next; + ce = preload->istate->cache[pos]; + if (!S_ISREG(ce->ce_mode) || + !(ce->ce_flags & CE_FSMONITOR_VALID) || + ce_skip_worktree(ce) || + (ce->ce_flags & CE_REMOVE) || + ce_intent_to_add(ce)) + goto next; + oidcpy(&preload->exclude_index_oids[i], &ce->oid); + task->exclude_index_present = 1; + if (!stat_candidates || + is_racy_timestamp(preload->istate, ce) || + (ce->ce_flags & CE_VALID)) + goto next; + /* + * Snapshot before launching workers. The main thread may + * refresh cache entries while exclude checks run. + */ + task->stat_data = ce->ce_stat_data; + task->exclude_index_candidate = 1; +next: + strbuf_release(&exclude_path); + } +} + static struct untracked_cache_preload *untracked_cache_preload_start_1( - struct index_state *istate, unsigned int dir_flags, int automatic) + struct index_state *istate, unsigned int dir_flags, int automatic, + int fsmonitor_excludes_only) { struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; @@ -246,22 +345,35 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( unsigned long test_threads; int threads, online, create_threads = 1; - if (!uc || !uc->root || uc->use_fsmonitor || - uc->dir_flags != dir_flags) + if (!uc || !uc->root || uc->dir_flags != dir_flags || + (fsmonitor_excludes_only ? + !uc->use_fsmonitor : + uc->use_fsmonitor)) return NULL; CALLOC_ARRAY(preload, 1); preload->repo = istate->repo; + preload->istate = istate; preload->uc = uc; preload->root = uc->root; preload->index_timestamp = istate->timestamp; preload->exclude_per_dir = xstrdup_or_null(uc->exclude_per_dir); preload->dir_flags = dir_flags; + preload->fsmonitor_excludes_only = fsmonitor_excludes_only; collect_untracked_cache_preload_tasks( - uc->root, &path, &preload->tasks, &preload->nr, &alloc); + uc->root, &path, &preload->tasks, &preload->nr, &alloc, + fsmonitor_excludes_only); strbuf_release(&path); + if (fsmonitor_excludes_only) { + CALLOC_ARRAY(preload->exclude_index_oids, preload->nr); + preload_fsmonitor_excludes_from_index(preload); + } - threads = HAVE_THREADS ? preload->nr / UNTRACKED_CACHE_PRELOAD_COST : 1; + threads = HAVE_THREADS ? + preload->nr / (fsmonitor_excludes_only ? + UNTRACKED_CACHE_EXCLUDE_PRELOAD_COST : + UNTRACKED_CACHE_PRELOAD_COST) : + 1; online = HAVE_THREADS ? online_cpus() : 1; if (threads > online * 3) threads = online * 3; @@ -273,7 +385,7 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( UNTRACKED_CACHE_MAX_OVERLAP_THREADS : test_threads; if (threads < 1) threads = 1; - if ((size_t)threads > preload->nr) + if (preload->nr && (size_t)threads > preload->nr) threads = preload->nr; preload->threads = threads; @@ -282,6 +394,9 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( "preload_untracked_cache/threads", threads); trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/automatic", automatic); + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/fsmonitor-excludes-only", + fsmonitor_excludes_only); CALLOC_ARRAY(preload->data, threads); work = DIV_ROUND_UP(preload->nr, threads); for (i = 0; i < threads; i++) { @@ -310,6 +425,14 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( return preload; } +struct untracked_cache_preload * +untracked_cache_preload_start_fsmonitor_excludes( + struct index_state *istate, unsigned int dir_flags) +{ + return untracked_cache_preload_start_1( + istate, dir_flags, 0, 1); +} + struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *istate, unsigned int dir_flags) { @@ -318,7 +441,7 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( if (!uc || uc->dir_flags != dir_flags || !untracked_cache_auto_preload_worthwhile(uc)) return NULL; - return untracked_cache_preload_start_1(istate, dir_flags, 1); + return untracked_cache_preload_start_1(istate, dir_flags, 1, 0); } static void *preload_untracked_cache_thread(void *_data) @@ -332,6 +455,47 @@ static void *preload_untracked_cache_thread(void *_data) struct strbuf exclude_path = STRBUF_INIT; struct stat st; + if (preload->fsmonitor_excludes_only) { + struct object_id raw_oid; + + if (!preload->exclude_per_dir) + continue; + if (strcmp(task->path, ".")) + strbuf_addstr(&exclude_path, task->path); + if (exclude_path.len) + strbuf_addch(&exclude_path, '/'); + strbuf_addstr(&exclude_path, + preload->exclude_per_dir); + if (task->exclude_index_candidate && + oideq(&preload->exclude_index_oids[i], + &task->exclude_oid) && + !lstat(exclude_path.buf, &st) && + cached_exclude_file_matches_index_stat( + &task->stat_data, &st)) { + task->exclude_mode = st.st_mode; + task->exclude_index_matches = 1; + task->exclude_index_content_matches = 1; + task->exclude_matches = 1; + strbuf_release(&exclude_path); + continue; + } + task->exclude_matches = cached_exclude_file_matches( + preload->repo->hash_algo, + exclude_path.buf, + &task->exclude_oid, &raw_oid, + &task->exclude_mode); + if (task->exclude_matches && + task->exclude_index_present && + oideq(&preload->exclude_index_oids[i], + &raw_oid)) { + task->exclude_index_content_matches = 1; + if (!oideq(&preload->exclude_index_oids[i], + &task->exclude_oid)) + task->normalize_exclude_oid = 1; + } + strbuf_release(&exclude_path); + continue; + } if (!task->was_valid) continue; task->stat_checked = 1; @@ -361,7 +525,7 @@ static void *preload_untracked_cache_thread(void *_data) strbuf_addstr(&exclude_path, preload->exclude_per_dir); task->exclude_matches = cached_exclude_file_matches( preload->repo->hash_algo, exclude_path.buf, - &task->exclude_oid); + &task->exclude_oid, NULL, NULL); strbuf_release(&exclude_path); } return NULL; @@ -415,6 +579,33 @@ static int compute_untracked_cache_valid_recursive( return valid; } +static int compute_untracked_cache_disk_valid_recursive( + struct untracked_cache_dir *ucd) +{ + size_t i; + int valid = ucd->valid && is_null_oid(&ucd->exclude_oid); + + for (i = 0; i < ucd->dirs_nr; i++) + if (!compute_untracked_cache_disk_valid_recursive(ucd->dirs[i])) + valid = 0; + ucd->valid_recursive = valid; + return valid; +} + +static int compute_untracked_cache_fsmonitor_valid_recursive( + struct untracked_cache_dir *ucd) +{ + size_t i; + int valid = ucd->valid; + + for (i = 0; i < ucd->dirs_nr; i++) + if (!compute_untracked_cache_fsmonitor_valid_recursive( + ucd->dirs[i])) + valid = 0; + ucd->valid_recursive = valid; + return valid; +} + static void untracked_cache_preload_join( struct untracked_cache_preload *preload) { @@ -448,25 +639,219 @@ static void untracked_cache_preload_free( for (i = 0; i < preload->nr; i++) free(preload->tasks[i].path); free(preload->tasks); + free(preload->exclude_index_oids); free(preload->exclude_per_dir); free(preload); } +static int converted_exclude_matches_cache_and_index( + struct untracked_cache_preload *preload, + struct untracked_cache_preload_task *task, + struct cache_entry *ce, + const char *path) +{ + struct object_id converted_oid, normalized_oid, raw_oid; + struct stat before, after; + char *buf = NULL; + size_t size; + int converted_fd, fd = -1; + int cached_matches, ret = 0; + + fd = open_nofollow(path, O_RDONLY); + if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || + before.st_size < 0 || + before.st_size > UNTRACKED_CACHE_MAX_EXCLUDE_SIZE) + goto done; + size = xsize_t(before.st_size); + buf = xmallocz(size + 1); + if (read_in_full(fd, buf, size) != size) + goto done; + + hash_object_file(preload->repo->hash_algo, buf, size, OBJ_BLOB, + &raw_oid); + cached_matches = oideq(&raw_oid, &task->exclude_oid); + if (!cached_matches) { + buf[size] = '\n'; + hash_object_file(preload->repo->hash_algo, buf, size + 1, + OBJ_BLOB, &normalized_oid); + cached_matches = oideq(&normalized_oid, + &task->exclude_oid); + } + if (!cached_matches || lseek(fd, 0, SEEK_SET) < 0) + goto done; + + converted_fd = xdup(fd); + if (index_fd(preload->istate, &converted_oid, converted_fd, &before, + OBJ_BLOB, path, 0) || + !oideq(&converted_oid, &ce->oid)) + goto done; + + /* + * Keep the descriptor open while computing both identities, then + * prove that neither the opened file nor its pathname changed. + */ + if (fstat(fd, &after) || + !path_namespace_stat_equal(&before, &after) || + !exclude_path_matches_fd(path, &after)) + goto done; + fill_stat_data(&task->stat_data, &after); + task->exclude_mode = after.st_mode; + ret = 1; +done: + free(buf); + if (fd >= 0) + close(fd); + return ret; +} + +static int update_preloaded_exclude_index_uptodate( + struct untracked_cache_preload *preload, + struct untracked_cache_preload_task *task, + size_t task_nr, + size_t *normalized, + size_t *index_invalidated, + int *exclude_revalidated) +{ + struct strbuf path = STRBUF_INIT; + struct cache_entry *ce; + int content_matches, converts, pos, marked = 0; + + *exclude_revalidated = -1; + if (!task->exclude_index_present || !preload->exclude_per_dir) + return 0; + if (strcmp(task->path, ".")) + strbuf_addstr(&path, task->path); + if (path.len) + strbuf_addch(&path, '/'); + strbuf_addstr(&path, preload->exclude_per_dir); + pos = index_name_pos_sparse(preload->istate, path.buf, path.len); + if (pos < 0) + goto done; + ce = preload->istate->cache[pos]; + if (!ce_stage(ce) && S_ISREG(ce->ce_mode) && + oideq(&ce->oid, &preload->exclude_index_oids[task_nr])) { + if (task->exclude_index_matches) { + converts = 0; + content_matches = 1; + } else { + converts = would_convert_to_git( + preload->istate, path.buf); + content_matches = converts ? + converted_exclude_matches_cache_and_index( + preload, task, ce, path.buf) : + task->exclude_index_content_matches; + if (converts) + *exclude_revalidated = content_matches; + } + if (!converts && task->normalize_exclude_oid) { + oidcpy(&task->ucd->exclude_oid, + &preload->exclude_index_oids[task_nr]); + (*normalized)++; + } + if (content_matches && + (ce->ce_flags & CE_FSMONITOR_VALID) && + !ce_skip_worktree(ce) && + !(ce->ce_flags & CE_REMOVE) && + !ce_intent_to_add(ce) && + (!repo_trust_executable_bit(preload->istate->repo) || + !((ce->ce_mode ^ task->exclude_mode) & 0100))) { + if (converts && + memcmp(&ce->ce_stat_data, &task->stat_data, + sizeof(ce->ce_stat_data))) { + ce->ce_stat_data = task->stat_data; + ce->ce_flags |= CE_UPDATE_IN_BASE; + preload->istate->cache_changed |= + CE_ENTRY_CHANGED; + } + ce_mark_uptodate(ce); + marked = 1; + } else { + fsmonitor_invalidate_cache_entry(ce); + preload->istate->cache_changed |= FSMONITOR_CHANGED; + (*index_invalidated)++; + } + } +done: + strbuf_release(&path); + return marked; +} + int untracked_cache_preload_finish(struct untracked_cache_preload *preload, - struct index_state *istate, - unsigned int dir_flags) + struct index_state *istate, + unsigned int dir_flags, + size_t *index_invalidated) { struct untracked_cache *uc; size_t i; int applied = 0; + if (index_invalidated) + *index_invalidated = 0; if (!preload) return 0; untracked_cache_preload_join(preload); uc = istate->untracked; if (uc != preload->uc || !uc || uc->root != preload->root || - dir_flags != preload->dir_flags) + dir_flags != preload->dir_flags || + (preload->fsmonitor_excludes_only && !uc->use_fsmonitor)) + goto done; + + if (preload->fsmonitor_excludes_only) { + size_t index_matches = 0; + size_t invalidated = 0; + size_t index_uptodate = 0; + size_t normalized = 0; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = + &preload->tasks[i]; + int exclude_matches = + oideq(&task->exclude_oid, + &task->ucd->exclude_oid) && + task->exclude_matches; + int exclude_revalidated; + + if (!exclude_matches) + invalidate_gitignore(uc, task->ucd); + else { + if (task->exclude_index_matches) + index_matches++; + } + index_uptodate += + update_preloaded_exclude_index_uptodate( + preload, task, i, &normalized, + &invalidated, &exclude_revalidated); + if (exclude_matches && exclude_revalidated == 0) + invalidate_gitignore(uc, task->ucd); + } + if (normalized) + istate->cache_changed |= UNTRACKED_CHANGED; + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/index-excludes", + index_matches); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/index-uptodate", + index_uptodate); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/index-invalidated", + invalidated); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/normalized-excludes", + normalized); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/valid", + compute_untracked_cache_fsmonitor_valid_recursive( + preload->root)); + if (index_invalidated) + *index_invalidated = invalidated; + applied = 1; goto done; + } for (i = 0; i < preload->nr; i++) { struct untracked_cache_preload_task *task = &preload->tasks[i]; @@ -1684,6 +2069,10 @@ static int add_patterns(const char *fname, const char *base, int baselen, (pos = index_name_pos(istate, fname, strlen(fname))) >= 0 && !ce_stage(istate->cache[pos]) && ce_uptodate(istate->cache[pos]) && + !(istate->cache[pos]->ce_flags & + (CE_VALID | CE_REMOVE)) && + !ce_skip_worktree(istate->cache[pos]) && + !ce_intent_to_add(istate->cache[pos]) && !would_convert_to_git(istate, fname)) oidcpy(&oid_stat->oid, &istate->cache[pos]->oid); @@ -2253,9 +2642,24 @@ static void prep_exclude(struct dir_struct *dir, strbuf_addbuf(&sb, &dir->internal.basebuf); strbuf_addstr(&sb, dir->exclude_per_dir); pl->src = strbuf_detach(&sb, NULL); - add_patterns(pl->src, pl->src, stk->baselen, pl, istate, - PATTERN_NOFOLLOW, - untracked ? &oid_stat : NULL); + if (add_patterns(pl->src, pl->src, stk->baselen, pl, + istate, PATTERN_NOFOLLOW, + untracked ? &oid_stat : NULL) < 0 && + untracked && is_null_oid(&oid_stat.oid)) { + struct stat st; + + /* + * Keep a non-blob sentinel for a source that is + * present but unreadable. Otherwise a valid + * untracked-cache directory cannot distinguish + * that state from an absent per-directory + * exclude file. + */ + if (!lstat(pl->src, &st) || + !is_missing_file_error(errno)) + oidcpy(&oid_stat.oid, + the_hash_algo->empty_tree); + } } /* * NEEDSWORK: when untracked cache is enabled, prep_exclude() @@ -3234,7 +3638,9 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct strbuf path = STRBUF_INIT; strbuf_add(&path, base, baselen); - if (untracked && dir->internal.untracked_cache_preloaded && + if (untracked && + (dir->internal.untracked_cache_preloaded || + dir->untracked->use_fsmonitor) && untracked->valid && untracked->valid_recursive && untracked->check_only == !!check_only && !untracked->has_untracked && @@ -3428,6 +3834,8 @@ static int treat_leading_path(struct dir_struct *dir, return state == path_recurse; } +#define UNTRACKED_CACHE_IDENT_VERSION 2 + static const char *get_ident_string(void) { static struct strbuf sb = STRBUF_INIT; @@ -3437,8 +3845,9 @@ static const char *get_ident_string(void) return sb.buf; if (uname(&uts) < 0) die_errno(_("failed to get kernel name and information")); - strbuf_addf(&sb, "Location %s, system %s", repo_get_work_tree(the_repository), - uts.sysname); + strbuf_addf(&sb, "Location %s, system %s, cache version %d", + repo_get_work_tree(the_repository), uts.sysname, + UNTRACKED_CACHE_IDENT_VERSION); return sb.buf; } @@ -4502,6 +4911,8 @@ struct untracked_cache *read_untracked_extension(const void *data, unsigned long ewah_each_bit(rd.valid, read_stat, &rd); ewah_each_bit(rd.sha1_valid, read_oid, &rd); next = rd.data; + if (next == end) + compute_untracked_cache_disk_valid_recursive(uc->root); done: free(rd.ucd); diff --git a/dir.h b/dir.h index 198d7c846f8937..f6df0b54d271e9 100644 --- a/dir.h +++ b/dir.h @@ -189,7 +189,10 @@ struct untracked_cache_dir { unsigned int stat_matches : 1; unsigned int exclude_matches : 1; unsigned int valid_recursive : 1; - /* null object ID means this directory does not have .gitignore */ + /* + * A null object ID means this directory does not have .gitignore. + * The empty-tree ID records a present source that could not be read. + */ struct object_id exclude_oid; char name[FLEX_ARRAY]; }; @@ -617,10 +620,14 @@ void untracked_cache_remove_from_index(struct index_state *, const char *); void untracked_cache_add_to_index(struct index_state *, const char *); struct untracked_cache_preload; +struct untracked_cache_preload * +untracked_cache_preload_start_fsmonitor_excludes( + struct index_state *, unsigned int dir_flags); struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *, unsigned int dir_flags); int untracked_cache_preload_finish(struct untracked_cache_preload *, - struct index_state *, unsigned int dir_flags); + struct index_state *, unsigned int dir_flags, + size_t *index_invalidated); void untracked_cache_preload_release(struct untracked_cache_preload *); void free_untracked_cache(struct untracked_cache *); diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 8591a166665bd5..7e7564e5e2c493 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -7,6 +7,14 @@ struct strbuf; /* A provider-only marker; worktree-relative paths cannot begin with '/'. */ #define FSMONITOR_PATH_GLOBAL_INVALIDATE "//" +enum fsmonitor_token_result { + FSMONITOR_TOKEN_NOT_PENDING = 0, + FSMONITOR_TOKEN_CLEAN, + FSMONITOR_TOKEN_CHANGED, + FSMONITOR_TOKEN_TRIVIAL, + FSMONITOR_TOKEN_ERROR, +}; + extern struct trace_key trace_fsmonitor; /* @@ -55,6 +63,16 @@ void refresh_fsmonitor(struct index_state *istate); int fsmonitor_invalidate_attributes_path(struct index_state *istate, const char *name); + +/* Close a provider token which was obtained before a required scan. */ +int fsmonitor_has_pending_token(const struct index_state *istate); +int fsmonitor_pending_token_from_provider(const struct index_state *istate); +enum fsmonitor_token_result fsmonitor_query_pending_token( + struct index_state *istate, int untracked_ready); +void fsmonitor_accept_pending_token(struct index_state *istate); +void fsmonitor_reject_pending_token(struct index_state *istate); +void fsmonitor_mark_untracked_cache_valid(struct index_state *istate); + /* * Does the received result contain the "trivial" response? */ diff --git a/fsmonitor.c b/fsmonitor.c index dea229e7a8597a..94ccbceba7c307 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -807,8 +807,44 @@ enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( static enum fsmonitor_query_outcome query_builtin_fsmonitor( const char *since_token, struct fsmonitor_query_result *result) { + const char *test_sequence = + getenv("GIT_TEST_FSMONITOR_QUERY_SEQUENCE"); struct strbuf raw = STRBUF_INIT; + /* + * Tests may script clean, delta, trivial, and error responses with + * C, D, T, and E. A delta uses GIT_TEST_FSMONITOR_QUERY_PATH. + */ + if (test_sequence && *test_sequence) { + static size_t query_nr; + const char *path; + char outcome; + + if (query_nr >= strlen(test_sequence)) + return FSMONITOR_QUERY_ERROR; + outcome = test_sequence[query_nr++]; + if (outcome == 'E') + return FSMONITOR_QUERY_ERROR; + + strbuf_addf(&result->token, "builtin:test:%"PRIuMAX, + (uintmax_t)query_nr); + if (outcome == 'T') { + result->outcome = FSMONITOR_QUERY_TRIVIAL; + return result->outcome; + } + if (outcome == 'D') { + path = getenv("GIT_TEST_FSMONITOR_QUERY_PATH"); + if (!path || !*path) + return FSMONITOR_QUERY_ERROR; + strbuf_addstr(&result->paths, path); + strbuf_addch(&result->paths, '\0'); + } else if (outcome != 'C') { + return FSMONITOR_QUERY_ERROR; + } + result->outcome = FSMONITOR_QUERY_DELTA; + return result->outcome; + } + if (!fsmonitor_ipc__send_query(since_token, &raw)) fsmonitor_parse_builtin_response(&raw, result); strbuf_release(&raw); @@ -849,6 +885,15 @@ static void invalidate_all_fsmonitor(struct index_state *istate) istate->cache_changed |= FSMONITOR_CHANGED; } +static void invalidate_all_fsmonitor_strong(struct index_state *istate) +{ + unsigned int i; + + invalidate_all_fsmonitor(istate); + for (i = 0; i < istate->cache_nr; i++) + fsmonitor_invalidate_cache_entry(istate->cache[i]); +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -860,6 +905,8 @@ void refresh_fsmonitor(struct index_state *istate) char *buf; unsigned int i; int is_trivial = 0; + int tracked_requires_bootstrap; + int untracked_requires_bootstrap; struct repository *r = istate->repo; enum fsmonitor_mode fsm_mode = fsm_settings__get_mode(r); enum fsmonitor_reason reason = fsm_settings__get_reason(r); @@ -1000,6 +1047,10 @@ void refresh_fsmonitor(struct index_state *istate) */ trace2_region_enter("fsmonitor", "apply_results", istate->repo); + tracked_requires_bootstrap = !query_success || is_trivial || + !istate->fsmonitor_token_valid; + untracked_requires_bootstrap = !istate->fsmonitor_untracked_valid; + if (query_success && !is_trivial) { /* * Mark all pathnames returned by the monitor as dirty. @@ -1027,9 +1078,14 @@ void refresh_fsmonitor(struct index_state *istate) } } + if (tracked_requires_bootstrap) + invalidate_all_fsmonitor(istate); + /* Now mark the untracked cache for fsmonitor usage */ if (istate->untracked) - istate->untracked->use_fsmonitor = 1; + istate->untracked->use_fsmonitor = + !tracked_requires_bootstrap && + !untracked_requires_bootstrap; if (count > fsmonitor_force_update_threshold) istate->cache_changed |= FSMONITOR_CHANGED; @@ -1052,9 +1108,152 @@ void refresh_fsmonitor(struct index_state *istate) strbuf_release(&query_result); - /* Now that we've updated istate, save the last_update_token */ + /* + * A token obtained before a full scan cannot describe changes which + * race with that scan. Keep it in memory until the caller closes the + * race with a second query. The last valid token remains safe because + * a query relative to it will return a superset of changes. + */ + if (tracked_requires_bootstrap) { + if (!last_update_token.len) { + if (istate->fsmonitor_last_update) + strbuf_addstr(&last_update_token, + istate->fsmonitor_last_update); + else + strbuf_addstr(&last_update_token, "builtin:fake"); + } + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_last_update_pending = + strbuf_detach(&last_update_token, NULL); + /* + * A trivial response cannot validate prior state, but its + * returned token is still a provider-owned boundary. Use it + * to anchor the complete scan which the caller will close with + * another query. Hook providers cannot perform that closing + * query, so do not publish their trivial-response tokens. + */ + istate->fsmonitor_pending_token_from_provider = + query_success && + (fsm_mode == FSMONITOR_MODE_IPC || !is_trivial); + istate->fsmonitor_untracked_valid = 0; + } else { + FREE_AND_NULL(istate->fsmonitor_last_update); + istate->fsmonitor_last_update = + strbuf_detach(&last_update_token, NULL); + if (untracked_requires_bootstrap) { + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_last_update_pending = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_pending_token_from_provider = 1; + } else { + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + } + if (istate->fsmonitor_untracked_valid && istate->untracked) { + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + } + } +} + +int fsmonitor_has_pending_token(const struct index_state *istate) +{ + return !!istate->fsmonitor_last_update_pending; +} + +int fsmonitor_pending_token_from_provider(const struct index_state *istate) +{ + return istate->fsmonitor_last_update_pending && + istate->fsmonitor_pending_token_from_provider; +} + +enum fsmonitor_token_result fsmonitor_query_pending_token( + struct index_state *istate, int untracked_ready) +{ + struct fsmonitor_query_result result = FSMONITOR_QUERY_RESULT_INIT; + enum fsmonitor_token_result ret; + int count; + + if (!istate->fsmonitor_last_update_pending) + return FSMONITOR_TOKEN_NOT_PENDING; + if (fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) + return FSMONITOR_TOKEN_ERROR; + + query_builtin_fsmonitor(istate->fsmonitor_last_update_pending, &result); + if (result.outcome == FSMONITOR_QUERY_ERROR) { + istate->fsmonitor_pending_token_from_provider = 0; + ret = FSMONITOR_TOKEN_ERROR; + goto done; + } + + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_last_update_pending = + strbuf_detach(&result.token, NULL); + istate->fsmonitor_pending_token_from_provider = 1; + if (result.outcome == FSMONITOR_QUERY_TRIVIAL) { + invalidate_all_fsmonitor_strong(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/trivial", 1); + ret = FSMONITOR_TOKEN_TRIVIAL; + goto done; + } + + count = apply_fsmonitor_paths(istate, &result.paths); + if (istate->untracked) + istate->untracked->use_fsmonitor = !!untracked_ready; + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/apply_count", count); + ret = count ? FSMONITOR_TOKEN_CHANGED : FSMONITOR_TOKEN_CLEAN; + +done: + fsmonitor_query_result_release(&result); + return ret; +} + +void fsmonitor_accept_pending_token(struct index_state *istate) +{ + if (!fsmonitor_pending_token_from_provider(istate)) + return; FREE_AND_NULL(istate->fsmonitor_last_update); - istate->fsmonitor_last_update = strbuf_detach(&last_update_token, NULL); + istate->fsmonitor_last_update = istate->fsmonitor_last_update_pending; + istate->fsmonitor_last_update_pending = NULL; + istate->fsmonitor_pending_token_from_provider = 0; + istate->fsmonitor_token_valid = 1; + istate->fsmonitor_untracked_valid = 1; + if (istate->untracked) + istate->untracked->use_fsmonitor = 1; + istate->cache_changed |= FSMONITOR_CHANGED; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/accepted", 1); +} + +void fsmonitor_reject_pending_token(struct index_state *istate) +{ + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + if (!istate->fsmonitor_token_valid) + FREE_AND_NULL(istate->fsmonitor_last_update); + invalidate_all_fsmonitor_strong(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/rejected", 1); +} + +void fsmonitor_mark_untracked_cache_valid(struct index_state *istate) +{ + if (istate->fsmonitor_last_update_pending || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || !istate->untracked || + istate->fsmonitor_untracked_valid) + return; + istate->fsmonitor_untracked_valid = 1; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + istate->cache_changed |= FSMONITOR_CHANGED; } /* @@ -1086,6 +1285,7 @@ static void initialize_fsmonitor_last_update(struct index_state *istate) strbuf_addf(&last_update, "%"PRIu64"", getnanotime()); istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); + istate->fsmonitor_token_valid = 0; } void add_fsmonitor(struct index_state *istate) @@ -1104,7 +1304,7 @@ void add_fsmonitor(struct index_state *istate) /* reset the untracked cache */ if (istate->untracked) { add_untracked_cache(istate); - istate->untracked->use_fsmonitor = 1; + istate->untracked->use_fsmonitor = 0; } /* Update the fsmonitor state */ @@ -1114,6 +1314,13 @@ void add_fsmonitor(struct index_state *istate) void remove_fsmonitor(struct index_state *istate) { + istate->fsmonitor_token_valid = 0; + istate->fsmonitor_untracked_valid = 0; + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + if (istate->untracked) + istate->untracked->use_fsmonitor = 0; if (istate->fsmonitor_last_update) { trace_printf_key(&trace_fsmonitor, "remove fsmonitor"); istate->cache_changed |= FSMONITOR_CHANGED; diff --git a/read-cache-ll.h b/read-cache-ll.h index db5b13d06b946c..d44a946ed04d36 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -187,13 +187,15 @@ struct index_state { fsmonitor_extension_seen : 1, fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, - fsmonitor_untracked_extension_invalid : 1; + fsmonitor_untracked_extension_invalid : 1, + fsmonitor_pending_token_from_provider : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; struct object_id oid; struct untracked_cache *untracked; char *fsmonitor_last_update; + char *fsmonitor_last_update_pending; char *fsmonitor_untracked_token; struct ewah_bitmap *fsmonitor_dirty; struct mem_pool *ce_mem_pool; diff --git a/read-cache.c b/read-cache.c index 57cc73dec90a0e..cf3600f2a337d6 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2471,6 +2471,7 @@ void release_index(struct index_state *istate) free_name_hash(istate); cache_tree_free(&(istate->cache_tree)); free(istate->fsmonitor_last_update); + free(istate->fsmonitor_last_update_pending); free(istate->fsmonitor_untracked_token); free(istate->cache); discard_split_index(istate); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index e8cc70c428b181..6b1fdd3bcbbc6f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -88,6 +88,609 @@ test_expect_success 'hook parser ignores empty path records' ' ) ' +test_expect_success UNTRACKED_CACHE 'trivial hook clears a paired UNTR token' ' + test_when_finished "rm -rf hook-token-pair" && + test_create_repo hook-token-pair && + ( + cd hook-token-pair && + test_commit base tracked && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token1\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + test_grep ! FSUC .git/index && + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep FSUC .git/index && + test_hook --clobber fsmonitor-test <<-\EOF && + printf "token2\0/\0" + EOF + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep ! FSUC .git/index + ) +' + +test_expect_success UNTRACKED_CACHE 'failed hook clears a paired UNTR token' ' + test_when_finished "rm -rf hook-token-error" && + test_create_repo hook-token-error && + ( + cd hook-token-error && + test_commit base tracked && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 >/dev/null && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token1\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep FSUC .git/index && + test_hook --clobber fsmonitor-test <<-\EOF && + exit 1 + EOF + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep ! FSUC .git/index + ) +' + +test_expect_success UNTRACKED_CACHE \ + 'paired fsmonitor cache prunes recursively valid empty subtrees' ' + test_when_finished "rm -rf fsmonitor-untracked-prune" && + test_create_repo fsmonitor-untracked-prune && + ( + cd fsmonitor-untracked-prune && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/empty/deep && + test_write_lines tracked >cached/empty/deep/tracked && + git add cached/empty/deep/tracked && + git commit -m base && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + test_grep FSUC .git/index && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status --porcelain=v2 >.git/clean && + test_must_be_empty .git/clean && + test_trace2_data read_directory directories-visited 0 \ + <.git/clean.trace && + + test_write_lines untracked >cached/empty/deep/new && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/empty/deep/new \ + git status --porcelain=v2 >.git/changed && + test_grep "^? cached/empty/deep/new$" .git/changed + ) +' + +test_expect_success UNTRACKED_CACHE,HARDLINKS \ + 'fsmonitor pruning rechecks cached per-directory excludes' ' + test_when_finished "rm -rf fsmonitor-untracked-exclude" && + test_when_finished "rm -f fsmonitor-untracked-exclude-alias" && + test_create_repo fsmonitor-untracked-exclude && + ( + cd fsmonitor-untracked-exclude && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached cached2 cached3 && + test_write_lines ignored >cached/.gitignore && + test_write_lines hidden >cached/ignored && + test_write_lines ignored >cached2/.gitignore && + test_write_lines hidden >cached2/ignored && + test_write_lines ignored >cached3/.gitignore && + test_write_lines hidden >cached3/ignored && + git add cached/.gitignore cached2/.gitignore \ + cached3/.gitignore && + git commit -m base && + test-tool chmtime +60 cached2/.gitignore && + test-tool chmtime =-60 cached3/.gitignore && + git update-index --refresh && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + test_grep FSUC .git/index && + ln cached/.gitignore ../fsmonitor-untracked-exclude-alias && + + if test_have_prereq PTHREADS + then + threads=2 + else + threads=1 + fi && + if test_have_prereq MINGW || test_have_prereq CYGWIN + then + index_excludes=0 + else + index_excludes=1 + fi && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT_NESTING=10 \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status --porcelain >.git/clean && + test_must_be_empty .git/clean && + test_trace2_data dir \ + preload_untracked_cache/fsmonitor-excludes-only 1 \ + <.git/clean.trace && + test_trace2_data dir preload_untracked_cache/threads \ + $threads \ + <.git/clean.trace && + test_trace2_data dir preload_untracked_cache/dirs 3 \ + <.git/clean.trace && + test_trace2_data dir \ + preload_untracked_cache/index-excludes "$index_excludes" \ + <.git/clean.trace && + test_trace2_data read_directory directories-visited 0 \ + <.git/clean.trace && + + if test_have_prereq FILEMODE + then + chmod +x ../fsmonitor-untracked-exclude-alias && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/mode.trace" \ + git status --porcelain >.git/mode && + test_trace2_data dir \ + preload_untracked_cache/index-uptodate 2 \ + <.git/mode.trace && + chmod -x ../fsmonitor-untracked-exclude-alias + else + : + fi && + + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status >.git/changed && + test_grep "modified:.*cached/.gitignore" .git/changed && + test_grep "cached/ignored" .git/changed && + test_trace2_data status \ + fsmonitor/exclude-index-invalidated 1 \ + <.git/changed.trace && + + test_write_lines ignored \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/restored.trace" \ + git status >.git/restored && + test_grep "nothing to commit, working tree clean" \ + .git/restored && + + test_write_lines "?? cached/ignored" >.git/flagged.expect && + + git update-index --assume-unchanged cached/.gitignore && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/assume-prime && + test_must_be_empty .git/assume-prime && + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/assume-changed.trace" \ + git status --porcelain >.git/assume-changed && + test_cmp .git/flagged.expect .git/assume-changed && + test_write_lines ignored \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/assume-restored.trace" \ + git status --porcelain >.git/assume-restored && + test_must_be_empty .git/assume-restored && + git update-index --no-assume-unchanged cached/.gitignore && + + git update-index --skip-worktree cached/.gitignore && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/skip-prime && + test_must_be_empty .git/skip-prime && + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/skip-changed.trace" \ + git status --porcelain >.git/skip-changed && + test_cmp .git/flagged.expect .git/skip-changed && + test_write_lines ignored \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/skip-restored.trace" \ + git status --porcelain >.git/skip-restored && + test_must_be_empty .git/skip-restored && + git update-index --no-skip-worktree cached/.gitignore + ) +' + +test_expect_success UNTRACKED_CACHE \ + 'converted cached excludes retain a stable index proof' ' + test_when_finished "rm -rf fsmonitor-converted-exclude" && + test_create_repo fsmonitor-converted-exclude && + ( + cd fsmonitor-converted-exclude && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + printf "ignored\r\n" >cached/.gitignore && + test_write_lines hidden >cached/ignored && + test_write_lines "cached/.gitignore text eol=lf" \ + >.gitattributes && + git add .gitattributes cached/.gitignore && + git commit -m base && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status >.git/settle && + test_grep "nothing to commit, working tree clean" \ + .git/settle && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status >.git/clean && + test_grep "nothing to commit, working tree clean" \ + .git/clean && + test_trace2_data dir \ + preload_untracked_cache/index-uptodate 1 \ + <.git/clean.trace && + test_trace2_data dir \ + preload_untracked_cache/index-invalidated 0 \ + <.git/clean.trace && + test_trace2_data dir \ + preload_untracked_cache/normalized-excludes 0 \ + <.git/clean.trace && + test_trace2_data read_directory directories-visited 0 \ + <.git/clean.trace + ) +' + +test_expect_success UNTRACKED_CACHE,HARDLINKS,POSIXPERM,SANITY \ + 'fsmonitor rechecks cached unreadable per-directory excludes' ' + test_when_finished "rm -rf fsmonitor-unreadable-exclude" && + test_when_finished "rm -f fsmonitor-unreadable-exclude-alias" && + test_create_repo fsmonitor-unreadable-exclude && + ( + cd fsmonitor-unreadable-exclude && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines hidden >cached/.gitignore && + test_write_lines untracked >cached/hidden && + test_write_lines tracked >cached/tracked && + git add cached/tracked && + git commit -m base && + chmod 000 cached/.gitignore && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain \ + >.git/prime 2>.git/prime.err && + test_grep "^?? cached/hidden$" .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/prime-fsmonitor \ + 2>.git/prime-fsmonitor.err && + test_grep "^?? cached/hidden$" .git/prime-fsmonitor && + empty_tree=$(git mktree .git/untracked-cache && + test_grep "cached/ $empty_tree" .git/untracked-cache && + ln cached/.gitignore \ + ../fsmonitor-unreadable-exclude-alias && + + chmod 644 ../fsmonitor-unreadable-exclude-alias && + test_write_lines "?? cached/.gitignore" \ + >.git/readable.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/readable.trace" \ + git status --porcelain >.git/readable && + test_cmp .git/readable.expect .git/readable && + test_trace2_data dir \ + preload_untracked_cache/dirs "[1-9]" \ + <.git/readable.trace + ) +' + +check_weak_exclude_stat () { + repo=$1 && + key=$2 && + value=$3 && + test_create_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines ignored >cached/.gitignore && + test_write_lines hidden >cached/ignored && + git add cached/.gitignore && + git commit -m base && + git config "$key" "$value" && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible >cached/.gitignore && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain >.git/changed && + test_grep "^?? cached/ignored$" .git/changed && + test_trace2_data dir \ + preload_untracked_cache/index-excludes 0 \ + <.git/changed.trace + ) +} + +test_expect_success UNTRACKED_CACHE \ + 'weak stat settings retain exclude content checks' ' + test_when_finished "rm -rf weak-exclude-ctime weak-exclude-stat" && + check_weak_exclude_stat weak-exclude-ctime \ + core.trustctime false && + check_weak_exclude_stat weak-exclude-stat \ + core.checkStat minimal +' + +test_expect_success UNTRACKED_CACHE \ + 'root untracked events preserve cached descendant excludes' ' + test_when_finished "rm -rf root-untracked-event" && + test_create_repo root-untracked-event && + ( + cd root-untracked-event && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + test_write_lines tracked >cached/deep/tracked && + test_write_lines ignored >cached/deep/junk.ignored && + git add .gitignore cached/.gitignore cached/deep/tracked && + git commit -m base && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + + test_write_lines visible >root-probe && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=root-probe \ + GIT_TRACE2_EVENT="$PWD/.git/created.trace" \ + git status >.git/created && + test_grep "root-probe" .git/created && + test_trace2_data read_directory directories-visited 1 \ + <.git/created.trace && + test_trace2_data read_directory gitignore-invalidation 0 \ + <.git/created.trace && + + rm root-probe && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=root-probe \ + GIT_TRACE2_EVENT="$PWD/.git/removed.trace" \ + git status >.git/removed && + test_grep "nothing to commit, working tree clean" \ + .git/removed && + test_trace2_data read_directory directories-visited 1 \ + <.git/removed.trace && + test_trace2_data read_directory gitignore-invalidation 0 \ + <.git/removed.trace + ) +' + +prepare_builtin_closure_repo () { + test_create_repo "$1" && + ( + cd "$1" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + if test "${2-}" = untracked + then + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/actual && + test_must_be_empty .git/actual && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index + else + : + fi && + git config core.fsmonitor true && + test_grep ! FSMN .git/index + ) +} + +test_expect_success UNTRACKED_CACHE 'builtin clean closure publishes its proof' ' + test_when_finished "rm -rf builtin-closure-clean" && + prepare_builtin_closure_repo builtin-closure-clean untracked && + ( + cd builtin-closure-clean && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines visible >visible && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^? visible$" .git/actual && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 1 .git/read-directory && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index + ) +' + +test_expect_success 'builtin changed closure rescans before acceptance' ' + test_when_finished "rm -rf builtin-closure-changed" && + prepare_builtin_closure_repo builtin-closure-changed && + ( + cd builtin-closure-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + GIT_TRACE_FSMONITOR="$PWD/.git/fsmonitor.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_grep "fsmonitor_refresh_callback.*tracked" \ + .git/fsmonitor.trace && + test_trace2_data fsmonitor token_closure/apply_count 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace + ) +' + +test_expect_success 'builtin initial trivial response anchors a closure' ' + test_when_finished "rm -rf builtin-initial-trivial" && + prepare_builtin_closure_repo builtin-initial-trivial && + ( + cd builtin-initial-trivial && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "^fsmonitor last update builtin:test:2" \ + .git/fsmonitor + ) +' + +test_expect_success 'builtin trivial closure can rescan and accept' ' + test_when_finished "rm -rf builtin-closure-trivial" && + prepare_builtin_closure_repo builtin-closure-trivial && + ( + cd builtin-closure-trivial && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CTC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor token_closure/trivial 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace + ) +' + +test_expect_success 'builtin closure rejects three intervening changes' ' + test_when_finished "rm -rf builtin-closure-exhausted" && + prepare_builtin_closure_repo builtin-closure-exhausted && + ( + cd builtin-closure-exhausted && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDDD \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor token_closure/apply_count 1 \ + <.git/status.trace >.git/applied && + test_line_count = 3 .git/applied && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSUC .git/index + ) +' + +test_expect_success 'builtin closure query errors fall back completely' ' + test_when_finished "rm -rf builtin-closure-error" && + prepare_builtin_closure_repo builtin-closure-error untracked && + ( + cd builtin-closure-error && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines visible >visible && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^? visible$" .git/actual && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 2 .git/read-directory && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSUC .git/index + ) +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && diff --git a/wt-status.c b/wt-status.c index fab9f1af38bea6..57e2321275dc07 100644 --- a/wt-status.c +++ b/wt-status.c @@ -34,6 +34,7 @@ #include "worktree.h" #include "lockfile.h" #include "sequencer.h" +#include "fsmonitor.h" #include "fsmonitor-settings.h" #define AB_DELAY_WARNING_IN_MS (2 * 1000) @@ -814,21 +815,50 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) { struct index_state *istate = s->repo->index; unsigned int dir_flags; + int has_fsmonitor = + fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED; if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); - if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED || - s->pathspec.nr || + if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) return; - dir_flags = wt_status_untracked_dir_flags(s); + if (has_fsmonitor) { + s->untracked_cache_preload = + untracked_cache_preload_start_fsmonitor_excludes( + istate, dir_flags); + return; + } + s->untracked_cache_preload = untracked_cache_preload_start_ordinary(istate, dir_flags); } -static int wt_status_collect_untracked_1(struct wt_status *s, int collect) +static void wt_status_finish_untracked_cache_preload(struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + size_t index_invalidated = 0; + + if (!s->untracked_cache_preload) + return; + s->untracked_cache_preloaded = untracked_cache_preload_finish( + s->untracked_cache_preload, istate, + wt_status_untracked_dir_flags(s), &index_invalidated); + s->untracked_cache_preload = NULL; + if (!index_invalidated) + return; + + trace2_data_intmax("status", s->repo, + "fsmonitor/exclude-index-invalidated", + index_invalidated); +} + +static int wt_status_collect_untracked_1( + struct wt_status *s, + struct string_list *untracked, + struct string_list *ignored) { int i; int used_untracked_cache; @@ -851,11 +881,7 @@ static int wt_status_collect_untracked_1(struct wt_status *s, int collect) } setup_standard_excludes(&dir); - if (s->untracked_cache_preload) { - s->untracked_cache_preloaded = untracked_cache_preload_finish( - s->untracked_cache_preload, istate, dir.flags); - s->untracked_cache_preload = NULL; - } + wt_status_finish_untracked_cache_preload(s); dir.internal.untracked_cache_preloaded = s->untracked_cache_preloaded; @@ -863,32 +889,183 @@ static int wt_status_collect_untracked_1(struct wt_status *s, int collect) used_untracked_cache = dir.untracked && dir.untracked == istate->untracked; - if (collect) { - for (i = 0; i < dir.nr; i++) { - struct dir_entry *ent = dir.entries[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->untracked, ent->name); - } - string_list_sort_u(&s->untracked, 0); + for (i = 0; i < dir.nr; i++) { + struct dir_entry *ent = dir.entries[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(untracked, ent->name); + } + string_list_sort_u(untracked, 0); - for (i = 0; i < dir.ignored_nr; i++) { - struct dir_entry *ent = dir.ignored[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->ignored, ent->name); - } - string_list_sort_u(&s->ignored, 0); + for (i = 0; i < dir.ignored_nr; i++) { + struct dir_entry *ent = dir.ignored[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(ignored, ent->name); } + string_list_sort_u(ignored, 0); dir_clear(&dir); - if (collect && advice_enabled(ADVICE_STATUS_U_OPTION)) + if (advice_enabled(ADVICE_STATUS_U_OPTION)) s->untracked_in_ms = (getnanotime() - t_begin) / 1000000; + if (used_untracked_cache) + fsmonitor_mark_untracked_cache_valid(istate); return used_untracked_cache; } static int wt_status_collect_untracked(struct wt_status *s) { - return wt_status_collect_untracked_1(s, 1); + if (s->untracked_from_token_closure && !s->show_ignored_mode) + return 1; + return wt_status_collect_untracked_1( + s, &s->untracked, &s->ignored); +} + +#define FSMONITOR_TOKEN_MAX_QUERIES 3 + +struct wt_status_token_closure { + struct wt_status *status; + unsigned int refresh_flags; + int can_prime; + int untracked_ready; + struct string_list staged_untracked; + struct string_list staged_ignored; + int staged_untracked_ready; + int refresh_result; + int queries; +}; + +static void wt_status_discard_staged_untracked( + struct wt_status_token_closure *closure) +{ + string_list_clear(&closure->staged_untracked, 0); + string_list_clear(&closure->staged_ignored, 0); + closure->staged_untracked_ready = 0; +} + +static int wt_status_stage_untracked( + struct wt_status_token_closure *closure) +{ + wt_status_discard_staged_untracked(closure); + closure->staged_untracked_ready = + wt_status_collect_untracked_1( + closure->status, + &closure->staged_untracked, + &closure->staged_ignored); + if (!closure->staged_untracked_ready) + wt_status_discard_staged_untracked(closure); + return closure->staged_untracked_ready; +} + +static void wt_status_publish_staged_untracked( + struct wt_status_token_closure *closure) +{ + struct wt_status *s = closure->status; + + if (!closure->staged_untracked_ready) + return; + if (s->untracked.nr || s->ignored.nr) + BUG("publishing untracked results over collected status"); + SWAP(s->untracked, closure->staged_untracked); + SWAP(s->ignored, closure->staged_ignored); + s->untracked_from_token_closure = 1; + closure->staged_untracked_ready = 0; +} + +static int wt_status_close_ordinary_fsmonitor_token( + struct wt_status_token_closure *closure, + int refreshed_before_closure) +{ + struct wt_status *s = closure->status; + struct index_state *istate = s->repo->index; + + if (!refreshed_before_closure) + closure->refresh_result = refresh_index( + istate, closure->refresh_flags, &s->pathspec, + NULL, NULL); + if (!closure->untracked_ready && closure->can_prime) + closure->untracked_ready = wt_status_stage_untracked(closure); + + while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { + enum fsmonitor_token_result result = + fsmonitor_query_pending_token( + istate, closure->untracked_ready); + + closure->queries++; + if (result == FSMONITOR_TOKEN_CLEAN) { + if (closure->untracked_ready) { + fsmonitor_accept_pending_token(istate); + return 1; + } + break; + } + if (result == FSMONITOR_TOKEN_ERROR || + result == FSMONITOR_TOKEN_NOT_PENDING) + break; + + /* Rescan invalidations returned by the closure query. */ + closure->refresh_result |= refresh_index( + istate, closure->refresh_flags, &s->pathspec, + NULL, NULL); + if (closure->can_prime) + closure->untracked_ready = + wt_status_stage_untracked(closure); + } + return 0; +} + +static int wt_status_close_fsmonitor_token( + struct wt_status *s, unsigned int refresh_flags, + int require_untracked, int refreshed_before_closure) +{ + struct index_state *istate = s->repo->index; + struct wt_status_token_closure closure = { + .status = s, + .refresh_flags = refresh_flags, + .staged_untracked = STRING_LIST_INIT_DUP, + .staged_ignored = STRING_LIST_INIT_DUP, + }; + + refresh_fsmonitor(istate); + if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) { + if (!refreshed_before_closure) + closure.refresh_result = refresh_index( + istate, refresh_flags, &s->pathspec, + NULL, NULL); + return closure.refresh_result; + } + + closure.can_prime = require_untracked && + s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && + !s->show_ignored_mode; + closure.untracked_ready = !istate->untracked || + !istate->untracked->root; + if (require_untracked && !closure.can_prime && + !closure.untracked_ready) + BUG("cannot close required untracked scan"); + trace2_region_enter("status", "fsmonitor_token_closure", s->repo); + if (wt_status_close_ordinary_fsmonitor_token( + &closure, refreshed_before_closure)) + goto accepted; + + /* Keep the last valid token and fall back to complete scans. */ + wt_status_discard_staged_untracked(&closure); + fsmonitor_reject_pending_token(istate); + closure.refresh_result |= refresh_index( + istate, refresh_flags, &s->pathspec, NULL, NULL); +accepted: + wt_status_publish_staged_untracked(&closure); + wt_status_discard_staged_untracked(&closure); + trace2_region_leave("status", "fsmonitor_token_closure", s->repo); + return closure.refresh_result; +} + +int wt_status_refresh_index(struct wt_status *s, + unsigned int refresh_flags, + int require_untracked) +{ + return wt_status_close_fsmonitor_token( + s, refresh_flags, require_untracked, 0); } static int has_unmerged(struct wt_status *s) @@ -906,6 +1083,15 @@ static int has_unmerged(struct wt_status *s) void wt_status_collect(struct wt_status *s) { + int used_untracked_cache; + + if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED) + wt_status_finish_untracked_cache_preload(s); + wt_status_close_fsmonitor_token( + s, REFRESH_QUIET | REFRESH_UNMERGED, + s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && + !s->show_ignored_mode, 1); + trace2_region_enter("status", "worktrees", s->repo); wt_status_collect_changes_worktree(s); trace2_region_leave("status", "worktrees", s->repo); @@ -921,9 +1107,20 @@ void wt_status_collect(struct wt_status *s) } trace2_region_enter("status", "untracked", s->repo); - wt_status_collect_untracked(s); + used_untracked_cache = wt_status_collect_untracked(s); trace2_region_leave("status", "untracked", s->repo); + /* Hook providers have no second query with which to close the scan. */ + if (fsmonitor_has_pending_token(s->repo->index) && !s->pathspec.nr && + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_HOOK && + (used_untracked_cache || !s->repo->index->untracked || + !s->repo->index->untracked->root)) { + if (fsmonitor_pending_token_from_provider(s->repo->index)) + fsmonitor_accept_pending_token(s->repo->index); + else + fsmonitor_reject_pending_token(s->repo->index); + } + wt_status_get_state(s->repo, &s->state, s->branch && !strcmp(s->branch, "HEAD")); if (s->state.merge_in_progress && !has_unmerged(s)) s->committable = 1; diff --git a/wt-status.h b/wt-status.h index e64eda2d9cc666..34beac22576fc9 100644 --- a/wt-status.h +++ b/wt-status.h @@ -139,6 +139,7 @@ struct wt_status { /* These are computed during processing of the individual sections */ int committable; int workdir_dirty; + unsigned untracked_from_token_closure : 1; const char *index_file; FILE *fp; const char *prefix; @@ -157,6 +158,13 @@ void wt_status_prepare(struct repository *r, struct wt_status *s); void wt_status_print(struct wt_status *s); void wt_status_collect(struct wt_status *s); void wt_status_start_untracked_cache_preload(struct wt_status *s); +/* + * Refresh tracked entries and close any provider token. When requested, + * also close a complete untracked-cache scan before accepting that token. + */ +int wt_status_refresh_index(struct wt_status *s, + unsigned int refresh_flags, + int require_untracked); /* * Collect all changes between the two trees. Changes will be displayed as if From ed6c886af9bb639f11c614a7cbd0ac308c5b1a98 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:48:56 -0500 Subject: [PATCH 103/403] status: preserve external history for dirty root-wide queries --- builtin/commit.c | 21 +++--- clean-status-history-store.c | 17 +++-- clean-status-history.c | 39 ++++++++--- t/t7530-status-clean-sidecar.sh | 75 ++++++++++++++++++++- t/unit-tests/u-clean-status-history-store.c | 2 - 5 files changed, 121 insertions(+), 33 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 7804bcef724670..92d635134fee24 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1750,7 +1750,7 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; - if (s.allow_clean_status_shortcuts || exact_clean_query) + if (!s.pathspec.nr) clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; if ((exact_clean_query || normal_clean_query) && @@ -1766,7 +1766,7 @@ struct repository *repo UNUSED) status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); - if (exact_clean_query && use_optional_locks()) + if (!s.pathspec.nr && use_optional_locks()) clean_status_capture_external_history_source( the_repository->index); if (normal_clean_query && use_optional_locks() && @@ -1824,28 +1824,27 @@ struct repository *repo UNUSED) * entry repair durable. Restored checkpoints stay no-spill * for foreign index writers. */ - if (!preserve_entry_changes && - (!exact_clean_query || - (clean_status_has_persistent_fsmonitor_semantic_history( - the_repository->index) && - !s.change.nr && !s.untracked.nr && !s.ignored.nr))) + if (!s.pathspec.nr) external_saved = clean_status_save_external_history( the_repository->index); if (exact_clean_query) { - if (external_saved && + if (!preserve_entry_changes && external_saved && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 0)) fd = -1; - else if (external_restored || external_saved) { + else if (!preserve_entry_changes && + (external_restored || external_saved)) { rollback_lock_file(&index_lock); fd = -1; } - } else if (normal_clean_query && external_restored && + } else if (!preserve_entry_changes && + normal_clean_query && external_restored && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 1)) { fd = -1; - } else if (external_restored || external_saved) { + } else if (!preserve_entry_changes && + (external_restored || external_saved)) { rollback_lock_file(&index_lock); fd = -1; } diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 1dbaf505f6b2eb..49b37166ce56fd 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -475,17 +475,20 @@ int clean_status_history_store_install( int current_is_regular, encoded_matches = 0; int checkpoint_fd = -1, ret = -1; - if (!clean_status_identity_is_durable() || !snapshot || - snapshot->fd < 0 || local_apfs_id(snapshot->fd, &fsid) || + if (!snapshot || snapshot->fd < 0 || !clean_status_index_snapshot_still_matches_path( snapshot, index_path, algo)) goto done; aliased = *checkpoint; - aliased.source_alias_valid = 1; - aliased.source_identity = snapshot->identity; - aliased.source_version = snapshot->version; - aliased.source_cache_nr = snapshot->cache_nr; - oidcpy(&aliased.source_checksum, &snapshot->checksum); + aliased.source_alias_valid = + clean_status_identity_is_durable() && + !local_apfs_id(snapshot->fd, &fsid); + if (aliased.source_alias_valid) { + aliased.source_identity = snapshot->identity; + aliased.source_version = snapshot->version; + aliased.source_cache_nr = snapshot->cache_nr; + oidcpy(&aliased.source_checksum, &snapshot->checksum); + } if (clean_status_history_checkpoint_write( &encoded, proof_namespace, &aliased, algo)) goto done; diff --git a/clean-status-history.c b/clean-status-history.c index 793d930fa55633..ba4c822db01f2a 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -362,7 +362,7 @@ void clean_status_capture_external_history_source( goto done; if (!clean_status_has_persistent_fsmonitor_semantic_history(istate)) goto done; - if (clean_status_index_snapshot_pin(&snapshot, istate)) + if (clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate)) goto done; if (!external_history_namespace(istate, proof_namespace) && !clean_status_history_store_load( @@ -379,7 +379,8 @@ void clean_status_capture_external_history_source( istate, state->source_logical_hash)) { goto done; } - if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + if (!clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, istate)) goto done; state->source_logical_hash_valid = 1; @@ -397,12 +398,28 @@ clean_status_prepare_external_history(struct index_state *istate) CE_ENTRY_CHANGED | FSMONITOR_CHANGED | UNTRACKED_CHANGED; if (!clean_status_external_history_enabled(istate) || - getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || - !state || !state->source_logical_hash_valid || - !current_proof_is_writable(istate) || - (istate->cache_changed & ~acceleration_changes) || - has_racy_timestamp(istate)) + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index) + return NULL; + if (!state || !state->source_logical_hash_valid) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "missing-source"); + return NULL; + } + if (!current_proof_is_writable(istate)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "unwritable-proof"); + return NULL; + } + if (istate->cache_changed & ~acceleration_changes) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "logical-flags"); return NULL; + } + if (has_racy_timestamp(istate)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "racy-index"); + return NULL; + } CALLOC_ARRAY(checkpoint, 1); checkpoint->fsmonitor = (struct strbuf)STRBUF_INIT; checkpoint->untracked_cache = (struct strbuf)STRBUF_INIT; @@ -482,7 +499,8 @@ static int clean_status_install_external_history( struct clean_status_index_snapshot snapshot = { .fd = -1 }; int installed = 0; - if (!checkpoint || clean_status_index_snapshot_pin(&snapshot, istate) || + if (!checkpoint || + clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate) || clean_status_history_store_install( istate->repo->index_file, checkpoint->proof_namespace, &checkpoint->checkpoint, &snapshot, @@ -572,7 +590,7 @@ int clean_status_restore_external_history(struct index_state *istate) !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || on_index_history_is_coherent(istate) || - clean_status_index_snapshot_pin(&snapshot, istate)) + clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate)) goto done; if (external_history_namespace(istate, proof_namespace)) goto done; @@ -652,7 +670,8 @@ int clean_status_restore_external_history(struct index_state *istate) "history/external-token-unreplayable", 1); goto done; } - if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + if (!clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, istate)) goto done; clean_status_invalidate_current_proof(istate); clean_status_copy_fsmonitor_history(istate, &parsed); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 521f3e8cb81c6d..911ea85a9b714e 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -57,7 +57,8 @@ prime_semantic_history () { test_must_be_empty actual.1 && bulk_status -C "$repo" status --porcelain=2 >actual.2 && test_must_be_empty actual.2 && - test_grep FSCF "$repo/.git/index" + test_grep FSCF "$repo/.git/index" && + rm -f "$repo"/.git/index.csh1.* } issue_sidecar () { @@ -227,6 +228,73 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'dirty exact status checkpoints history without certifying cleanliness' ' + test_when_finished "stop_daemon external-dirty-exact" && + setup_repo external-dirty-exact && + git -C external-dirty-exact config core.untrackedCache true && + prime_semantic_history external-dirty-exact && + test_write_lines changed >external-dirty-exact/tracked && + test-tool chmtime -60 external-dirty-exact/tracked && + bulk_status -C external-dirty-exact status --porcelain=2 \ + >external-dirty-exact.primed && + test_env GIT_TRACE2_EVENT="$PWD/external-dirty-exact.trace" \ + bulk_status -C external-dirty-exact status --porcelain=v2 \ + >actual && + test_grep "^1 \.M .* tracked$" actual && + test_trace2_data fsmonitor history/external-stored 1 \ + external-dirty-exact.checkpoints && + test_line_count = 1 external-dirty-exact.checkpoints +' + +test_expect_success DURABLE_FSMONITOR \ + 'daemon-shaped dirty status checkpoints resumable history' ' + test_when_finished "stop_daemon external-daemon-shape" && + setup_repo external-daemon-shape && + git -C external-daemon-shape config core.untrackedCache true && + prime_semantic_history external-daemon-shape && + test_write_lines changed >external-daemon-shape/tracked && + test-tool chmtime -60 external-daemon-shape/tracked && + bulk_status -C external-daemon-shape status --porcelain=2 \ + >external-daemon-shape.primed && + test_env GIT_TRACE2_EVENT="$PWD/external-daemon-shape.trace" \ + bulk_status -C external-daemon-shape \ + status --porcelain=v2 -z --branch --show-stash \ + --no-ahead-behind --untracked-files=normal \ + --ignore-submodules=all >actual && + test_trace2_data fsmonitor history/external-stored 1 \ + external-daemon-shape.checkpoints && + test_line_count = 1 external-daemon-shape.checkpoints +' + +test_expect_success DURABLE_FSMONITOR \ + 'nested status uses root-wide resumable history' ' + test_when_finished "stop_daemon external-nested-status" && + setup_repo external-nested-status && + git -C external-nested-status config core.untrackedCache true && + prime_semantic_history external-nested-status && + mkdir -p external-nested-status/deep/inside && + test_write_lines changed >external-nested-status/tracked && + test-tool chmtime -60 external-nested-status/tracked && + bulk_status -C external-nested-status status --porcelain=2 \ + >external-nested-status.primed && + test_env GIT_TRACE2_EVENT="$PWD/external-nested-status.trace" \ + bulk_status -C external-nested-status/deep/inside \ + status --porcelain=v2 >actual && + test_grep "^1 \.M .* \.\./\.\./tracked$" actual && + test_trace2_data fsmonitor history/external-stored 1 \ + external-nested-status.checkpoints && + test_line_count = 1 external-nested-status.checkpoints +' + test_expect_success DURABLE_FSMONITOR \ 'normal status persists bootstrap stat repairs' ' test_when_finished "stop_daemon external-stat-bootstrap" && @@ -234,7 +302,7 @@ test_expect_success DURABLE_FSMONITOR \ git -C external-stat-bootstrap update-index --fsmonitor && test_env GIT_TRACE2_EVENT="$PWD/external-stat-bootstrap.trace" \ git -C external-stat-bootstrap status >actual && - ! test_trace2_data fsmonitor history/external-stored 1 \ + test_trace2_data fsmonitor history/external-stored 1 \ flush.out && git -C external-history config status.renameLimit 100 && - test_env GIT_TRACE2_EVENT="$PWD/external-main-token.trace" \ + test_env GIT_INDEX_FILE="$PWD/external-history/.git/index" \ + GIT_TRACE2_EVENT="$PWD/external-main-token.trace" \ git -C external-history status --porcelain=v2 \ --untracked-files=normal >actual.main-token && test_grep "\"label\":\"do_write_index\"" \ diff --git a/t/unit-tests/u-clean-status-history-store.c b/t/unit-tests/u-clean-status-history-store.c index f6a8d1062e34fb..73719517ecc3f8 100644 --- a/t/unit-tests/u-clean-status-history-store.c +++ b/t/unit-tests/u-clean-status-history-store.c @@ -189,7 +189,6 @@ void test_clean_status_history_store__keeps_namespaces_independent(void) static const unsigned char second_fsmn[] = "second-fsmn"; static const unsigned char second_fscf[] = "second-fscf"; - require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); fixture_init(&fixture, algo); memset(first.index_hash, 1, algo->rawsz); first.fsmonitor = first_fsmn; @@ -292,7 +291,6 @@ void test_clean_status_history_store__bounds_namespaces(void) struct utimbuf times; char namespace[32]; - require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); fixture_init(&fixture, algo); checkpoint.fsmonitor = fsmn; checkpoint.fsmonitor_len = sizeof(fsmn) - 1; From b76a6c5d88c559540c696d28bdd80290067b5816 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:49:03 -0500 Subject: [PATCH 104/403] status: show delayed progress during semantic refresh --- builtin/commit.c | 5 +++- clean-status.c | 48 ++++++++++++++++++++++++++++++++++++++ clean-status.h | 7 ++++++ semantic-verify-internal.h | 2 ++ semantic-verify-worker.c | 13 +++++++++-- semantic-verify.c | 6 +++++ worktree-attr-manifest.c | 19 +++++++++++++-- 7 files changed, 95 insertions(+), 5 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 92d635134fee24..7d564109df7454 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1763,8 +1763,11 @@ struct repository *repo UNUSED) } if (status_format != STATUS_FORMAT_PORCELAIN && - status_format != STATUS_FORMAT_PORCELAIN_V2) + status_format != STATUS_FORMAT_PORCELAIN_V2) { progress_flag = REFRESH_PROGRESS; + if (isatty(2)) + clean_status_enable_progress(the_repository); + } repo_read_index(the_repository); if (!s.pathspec.nr && use_optional_locks()) clean_status_capture_external_history_source( diff --git a/clean-status.c b/clean-status.c index 374738e81cbdb0..1597f76e64881e 100644 --- a/clean-status.c +++ b/clean-status.c @@ -3,18 +3,27 @@ #include "clean-status.h" #include "clean-status-internal.h" #include "fsmonitor-clean-proof.h" +#include "progress.h" #include "read-cache-ll.h" #include "repository.h" +#include "thread-utils.h" #include "trace2.h" static struct repository *configured_repo; static unsigned char configured_hash[GIT_MAX_RAWSZ]; static unsigned char configured_semantic_hash[GIT_MAX_RAWSZ]; static struct repository *external_history_repo; +static struct repository *progress_repo; static int configured_hash_valid; static int configured_filter_configured; static int configured_semantic_explicit; +struct clean_status_progress { + struct progress *display; + pthread_mutex_t mutex; + uint64_t completed; +}; + void clean_status_enable_external_history(struct repository *repo) { external_history_repo = repo; @@ -25,6 +34,45 @@ int clean_status_external_history_enabled(const struct index_state *istate) return istate && istate->repo == external_history_repo; } +void clean_status_enable_progress(struct repository *repo) +{ + progress_repo = repo; +} + +struct clean_status_progress *clean_status_start_progress( + struct repository *repo, const char *title, uint64_t total) +{ + struct clean_status_progress *progress; + + if (repo != progress_repo) + return NULL; + CALLOC_ARRAY(progress, 1); + if (pthread_mutex_init(&progress->mutex, NULL)) + BUG("could not initialize clean status progress mutex"); + progress->display = start_delayed_progress(repo, title, total); + return progress; +} + +void clean_status_update_progress(struct clean_status_progress *progress, + uint64_t completed) +{ + if (!progress || !completed) + return; + pthread_mutex_lock(&progress->mutex); + progress->completed += completed; + display_progress(progress->display, progress->completed); + pthread_mutex_unlock(&progress->mutex); +} + +void clean_status_stop_progress(struct clean_status_progress **progress) +{ + if (!progress || !*progress) + return; + stop_progress(&(*progress)->display); + pthread_mutex_destroy(&(*progress)->mutex); + FREE_AND_NULL(*progress); +} + struct clean_status_state *clean_status_get_state(struct index_state *istate) { if (!istate->clean_status) { diff --git a/clean-status.h b/clean-status.h index a50ef3af709840..0988e9ba318f32 100644 --- a/clean-status.h +++ b/clean-status.h @@ -5,6 +5,7 @@ struct index_state; struct attr_source_snapshot; +struct clean_status_progress; struct clean_status_proof_epoch; struct lock_file; struct repository; @@ -22,6 +23,12 @@ void clean_status_set_config_digest( const struct clean_status_config_digest *digest); void clean_status_enable_external_history(struct repository *repo); int clean_status_external_history_enabled(const struct index_state *istate); +void clean_status_enable_progress(struct repository *repo); +struct clean_status_progress *clean_status_start_progress( + struct repository *repo, const char *title, uint64_t total); +void clean_status_update_progress(struct clean_status_progress *progress, + uint64_t completed); +void clean_status_stop_progress(struct clean_status_progress **progress); void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index e784cf4d78f955..b4a896fc8195f5 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -31,6 +31,7 @@ struct attr_check; struct repository; struct clean_status_proof_epoch; +struct clean_status_progress; struct cache_entry; struct git_hash_algo; struct index_state; @@ -107,6 +108,7 @@ struct semantic_verify_worker { struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; + struct clean_status_progress *progress; struct attr_check *check; size_t start; size_t end; diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index 591da8aa70703d..d63461335472db 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -2,6 +2,7 @@ #include "git-compat-util.h" #include "attr.h" +#include "clean-status.h" #include "convert.h" #include "object.h" #include "read-cache-ll.h" @@ -9,6 +10,8 @@ #include "semantic-verify.h" #include "semantic-verify-internal.h" +#define SEMANTIC_VERIFY_PROGRESS_BATCH 128 + static void record_stat_update(struct semantic_verify_worker *worker, uint32_t cache_pos, const struct stat_data *stat_data) @@ -58,7 +61,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) semantic_verify_path_new(worker->root); struct attr_check *check = worker->check; void *buffer = xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); - size_t unstable_from = SIZE_MAX; + size_t unstable_from = SIZE_MAX, completed = 0; worker->check = NULL; if (!check) @@ -79,7 +82,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) worker->active_filters++; } count_result(worker, result->kind); - continue; + goto counted; } active_filter = file.active_filter; @@ -103,7 +106,13 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) record_stat_update(worker, i, &file.stat_data); } count_result(worker, result->kind); + counted: + if (++completed == SEMANTIC_VERIFY_PROGRESS_BATCH) { + clean_status_update_progress(worker->progress, completed); + completed = 0; + } } + clean_status_update_progress(worker->progress, completed); semantic_verify_path_free(path, &worker->namespace_unstable, &unstable_from); diff --git a/semantic-verify.c b/semantic-verify.c index cdd179fdef4215..dc6af79c2ce796 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -5,6 +5,7 @@ #include "clean-status.h" #include "convert.h" #include "fsmonitor.h" +#include "gettext.h" #include "object.h" #include "read-cache-ll.h" #include "repository.h" @@ -80,6 +81,7 @@ int semantic_verify_prepare(struct index_state *istate, { struct semantic_verify_proof *proof; struct semantic_verify_worker *workers; + struct clean_status_progress *progress; unsigned int nr_threads; size_t updates_nr = 0; int create_threads = 1; @@ -179,6 +181,8 @@ int semantic_verify_prepare(struct index_state *istate, "threads", nr_threads); trace2_data_intmax("semantic_verify", istate->repo, "result-bytes", sizeof(struct semantic_verify_result)); + progress = clean_status_start_progress( + istate->repo, _("Verifying tracked files"), proof->cache_nr); for (unsigned int i = 0; i < nr_threads; i++) { struct semantic_verify_worker *worker = &workers[i]; @@ -187,6 +191,7 @@ int semantic_verify_prepare(struct index_state *istate, worker->istate = istate; worker->root = proof->root; worker->results = proof->results; + worker->progress = progress; worker->start = st_mult(proof->cache_nr, i) / nr_threads; worker->end = st_mult(proof->cache_nr, i + 1) / nr_threads; worker->validate_filter_scope = proof->filter_scope_checked; @@ -214,6 +219,7 @@ int semantic_verify_prepare(struct index_state *istate, die("could not join semantic verifier thread: %s", strerror(err)); } + clean_status_stop_progress(&progress); for (unsigned int i = 0; i < nr_threads; i++) updates_nr += workers[i].updates_nr; diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c index 1a0234360c67cb..116ff10b8e3fb7 100644 --- a/worktree-attr-manifest.c +++ b/worktree-attr-manifest.c @@ -1,7 +1,9 @@ #include "git-compat-util.h" #include "attr-manifest.h" +#include "clean-status.h" #include "dir.h" #include "environment.h" +#include "gettext.h" #include "hash-framing.h" #include "object.h" #include "odb.h" @@ -17,6 +19,7 @@ #define ATTR_MANIFEST_FILES_PER_THREAD 256 #define ATTR_MANIFEST_MAX_THREADS 32 +#define ATTR_MANIFEST_PROGRESS_BATCH 128 struct attr_manifest_candidate { unsigned char worktree_hash[GIT_MAX_RAWSZ]; @@ -30,6 +33,7 @@ struct attr_manifest_probe_data { struct string_list *candidates; struct semantic_verify_root *root; const struct git_hash_algo *algo; + struct clean_status_progress *progress; size_t start; size_t end; unsigned int namespace_unstable; @@ -130,7 +134,7 @@ static void *probe_attr_manifest_candidates(void *cb_data) struct attr_manifest_probe_data *data = cb_data; struct semantic_verify_path *path = semantic_verify_path_new(data->root); - size_t i; + size_t i, completed = 0; for (i = data->start; i < data->end; i++) { struct string_list_item *item = &data->candidates->items[i]; @@ -142,7 +146,12 @@ static void *probe_attr_manifest_candidates(void *cb_data) candidate->error = 1; else candidate->worktree_present = found; + if (++completed == ATTR_MANIFEST_PROGRESS_BATCH) { + clean_status_update_progress(data->progress, completed); + completed = 0; + } } + clean_status_update_progress(data->progress, completed); semantic_verify_path_free(path, &data->namespace_unstable, NULL); return NULL; } @@ -178,15 +187,19 @@ static int create_probe_thread(struct attr_manifest_thread *worker, } static int probe_candidates(struct string_list *candidates, + struct repository *repo, struct semantic_verify_root *root, const struct git_hash_algo *algo, struct worktree_attr_manifest_stats *stats) { struct attr_manifest_thread *workers; + struct clean_status_progress *progress; size_t thread_id, threads = select_thread_count(candidates->nr); int create_threads = HAVE_THREADS; int ret = 0; + progress = clean_status_start_progress( + repo, _("Refreshing worktree metadata"), candidates->nr); CALLOC_ARRAY(workers, threads); for (thread_id = 0; thread_id < threads; thread_id++) { struct attr_manifest_thread *worker = &workers[thread_id]; @@ -196,6 +209,7 @@ static int probe_candidates(struct string_list *candidates, data->candidates = candidates; data->root = root; data->algo = algo; + data->progress = progress; data->start = st_mult(candidates->nr, thread_id) / threads; data->end = st_mult(candidates->nr, thread_id + 1) / threads; if (threads == 1 || !create_threads) { @@ -219,6 +233,7 @@ static int probe_candidates(struct string_list *candidates, ret |= worker->probe.namespace_unstable; } stats->threads = threads; + clean_status_stop_progress(&progress); free(workers); return ret ? -1 : 0; } @@ -243,7 +258,7 @@ int worktree_attr_manifest_build( collect_index_sources(istate, &candidates)) goto done; stats->candidates = candidates.nr; - if (probe_candidates(&candidates, root, algo, stats)) + if (probe_candidates(&candidates, istate->repo, root, algo, stats)) goto done; attr_manifest_writer_init(&writer, manifest, algo); for (i = 0; i < candidates.nr; i++) { From b9b56f3d4f91bc9fceb02629f8e0d0e36b6b4401 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:54:41 -0500 Subject: [PATCH 105/403] preload-index: retain proven deletions from complete bulk scans An APFS bulk scan already observes which expanded-index paths are present, but its preload consumer treats an unseen tracked entry as something ordinary preload must stat again. Missing subtrees therefore trigger redundant speculative lookups. Case-folded aliases, mount crossings, unsupported vnodes, and multiply-linked entries must not be mistaken for deletions. Retain a complete scan's per-entry state through threaded preload and classify an unseen useful entry as definitively deleted only when the expanded index makes that conclusion safe. Initialize case-folded name hashes before workers start, and record explicit per-entry fallback for aliases, mount boundaries, tracked directories, and unsupported vnode types. Leave multiply-linked tracked files to ordinary lstat. Leave collapsed sparse indexes and incomplete scans on the existing ordinary path. The APFS tests compare bulk and ordinary status for missing paths, content mismatches, aliases, unsupported tracked types, and hardlinks. Their Trace2 assertions verify that proven deletions avoid speculative lstat while authoritative refresh and fallback entries retain ordinary checks. Retaining per-entry state through preload extends its temporary lifetime; release it when preload finishes. Refresh the case-alias fixture after its case-only renames so ordinary and bulk status compare the same index baseline even when a rename crosses a filesystem timestamp boundary. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 42 ++++++++++-- name-hash.c | 8 +++ name-hash.h | 2 + preload-index-bulk-index.c | 101 ++++++++++++++++++++------- preload-index-bulk-thread.c | 2 + preload-index-bulk.c | 15 +++++ preload-index-bulk.h | 14 +++- preload-index.c | 105 +++++++++++++++++++++++++---- preload-index.h | 1 + t/t7529-preload-index-apfs.sh | 59 +++++++++++++++- 10 files changed, 299 insertions(+), 50 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index f5fb62af26f4d3..882cc64a41f64b 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -345,6 +345,8 @@ static int enumerate_directory(struct preload_bulk_worker *worker, if (path_name != entry.name) free((char *)path_name); + pos = preload_bulk_index_position(scan, worker->path.buf, + worker->path.len); if (entry.type == VDIR) { struct preload_bulk_dir_identity child_identity = { .stat = { @@ -357,10 +359,19 @@ static int enumerate_directory(struct preload_bulk_worker *worker, }, }; - if (!preload_bulk_index_has_tracked_descendants( + if (pos >= 0) { + preload_bulk_record_tracked_fallback( + worker, pos); + goto next_record; + } + if (!preload_bulk_index_pos_has_tracked_descendants( scan, worker->path.buf, - worker->path.len)) + worker->path.len, pos)) { + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); goto next_record; + } if (((entry.access & S_IFMT) && (entry.access & S_IFMT) != S_IFDIR) || (entry.access & ~(S_IFMT | 07777))) @@ -368,6 +379,9 @@ static int enumerate_directory(struct preload_bulk_worker *worker, if (entry.dev != data->root_stat.st_dev || entry.mountstatus || (entry.flags & SF_FIRMLINK)) { + preload_bulk_record_tracked_descendants_fallback( + worker, worker->path.buf, + worker->path.len); goto next_record; } preload_bulk_schedule_directory( @@ -378,17 +392,27 @@ static int enumerate_directory(struct preload_bulk_worker *worker, goto next_record; } - pos = preload_bulk_index_position(scan, worker->path.buf, - worker->path.len); - if (pos < 0) + if (pos < 0) { + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); goto next_record; + } if (entry.dev != data->root_stat.st_dev) { + preload_bulk_record_tracked_fallback( + worker, pos); goto next_record; } - if (entry.type != VREG && entry.type != VLNK) + if (entry.type != VREG && entry.type != VLNK) { + preload_bulk_record_tracked_fallback( + worker, pos); goto next_record; - if (entry.linkcount != 1) + } + if (entry.linkcount != 1) { + preload_bulk_record_tracked_fallback( + worker, pos); goto next_record; + } if (fill_file_stat(&st, entry.dev, entry.fileid, entry.type, entry.mtime, entry.ctime, entry.uid, entry.gid, entry.access, @@ -417,6 +441,7 @@ static int scan_directory(struct preload_bulk_worker *worker, struct preload_bulk_scan *scan = worker->scan; struct preload_bulk_dir_identity before_identity; struct stat before, after; + size_t path_len; int fd = task->fd; int ret = -1; @@ -429,6 +454,9 @@ static int scan_directory(struct preload_bulk_worker *worker, if (preload_bulk_darwin_fd_on_root_mount(scan, fd, &before)) { if (errno != EXDEV) goto out; + path_len = strlen(task->path); + preload_bulk_record_tracked_descendants_fallback( + worker, task->path, path_len); ret = 0; goto out; } diff --git a/name-hash.c b/name-hash.c index 83757db8746230..47c659d6c75374 100644 --- a/name-hash.c +++ b/name-hash.c @@ -619,6 +619,14 @@ static void lazy_init_name_hash(struct index_state *istate) trace_performance_leave("initialize name hash"); } +int prepare_index_casefolding(struct index_state *istate) +{ + if (!repo_ignore_case(istate->repo)) + return 0; + lazy_init_name_hash(istate); + return 1; +} + /* * A test routine for t/helper/ sources. * diff --git a/name-hash.h b/name-hash.h index 0cbfc4286316b2..cc7e752ab1e27d 100644 --- a/name-hash.h +++ b/name-hash.h @@ -10,6 +10,8 @@ int index_dir_find(struct index_state *istate, const char *name, int namelen, #define index_dir_exists(i, n, l) index_dir_find((i), (n), (l), NULL) +/* Prepare the name and directory hashes for concurrent case-folded lookups. */ +int prepare_index_casefolding(struct index_state *istate); void adjust_dirname_case(struct index_state *istate, char *name); struct cache_entry *index_file_exists(struct index_state *istate, const char *name, int namelen, int igncase); diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index 623164822b5fdb..0d129b619cf2fc 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "name-hash.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -14,17 +15,23 @@ int preload_bulk_index_position(struct preload_bulk_scan *scan, return index_name_pos_sparse(scan->istate, path, path_len); } -int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, - const char *path, - size_t path_len) +static int first_tracked_descendant(struct index_state *istate, + const char *path, size_t path_len, + int pos) { - int pos; + while ((unsigned int)pos < istate->cache_nr) { + const struct cache_entry *ce = istate->cache[pos]; - if (path_len > INT_MAX) - return 0; - pos = index_name_pos_sparse(scan->istate, path, path_len); - return preload_bulk_index_pos_has_tracked_descendants( - scan, path, path_len, pos); + if (ce_namelen(ce) <= path_len || + memcmp(ce->name, path, path_len)) + return -1; + if (ce->name[path_len] == '/') + return pos; + if ((unsigned char)ce->name[path_len] > '/') + return -1; + pos++; + } + return -1; } int preload_bulk_index_pos_has_tracked_descendants( @@ -32,27 +39,11 @@ int preload_bulk_index_pos_has_tracked_descendants( int pos) { struct index_state *istate = scan->istate; - const struct cache_entry *ce; if (pos >= 0) return 0; pos = -pos - 1; - while ((unsigned int)pos < istate->cache_nr) { - ce = istate->cache[pos]; - if (ce_namelen(ce) < path_len || - memcmp(ce->name, path, path_len)) - return 0; - if (ce_namelen(ce) == path_len) { - pos++; - continue; - } - if (ce->name[path_len] == '/') - return 1; - if ((unsigned char)ce->name[path_len] > '/') - return 0; - pos++; - } - return 0; + return first_tracked_descendant(istate, path, path_len, pos) >= 0; } static int record_tracked_state(struct preload_bulk_worker *worker, int pos, @@ -112,3 +103,61 @@ void preload_bulk_record_tracked( PRELOAD_BULK_TRACKED_CLEAN; record_tracked_state(worker, pos, state); } + +void preload_bulk_record_tracked_fallback( + struct preload_bulk_worker *worker, int pos) +{ + if (!tracked_entry_is_eligible(worker->scan->istate->cache[pos])) + return; + record_tracked_state(worker, pos, + PRELOAD_BULK_TRACKED_FALLBACK); +} + +void preload_bulk_record_tracked_descendants_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len) +{ + struct index_state *istate = worker->scan->istate; + int pos = preload_bulk_index_position(worker->scan, path, path_len); + + if (pos < 0) + pos = -pos - 1; + else + pos++; + while ((pos = first_tracked_descendant(istate, path, path_len, pos)) >= 0) { + preload_bulk_record_tracked_fallback(worker, pos); + pos++; + } +} + +int preload_bulk_record_tracked_alias_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len) +{ + struct preload_bulk_scan *scan = worker->scan; + struct index_state *istate = scan->istate; + struct cache_entry *ce; + struct strbuf canonical = STRBUF_INIT; + int found = 0; + int pos; + + if (!scan->case_insensitive || path_len > INT_MAX) + return 0; + if (index_dir_find(istate, path, path_len, &canonical)) { + found = 1; + preload_bulk_record_tracked_descendants_fallback( + worker, canonical.buf, canonical.len); + goto out; + } + ce = index_file_exists(istate, path, path_len, 1); + if (!ce) + goto out; + found = 1; + pos = index_name_pos_sparse(istate, ce->name, ce_namelen(ce)); + if (pos >= 0) + preload_bulk_record_tracked_fallback(worker, pos); + +out: + strbuf_release(&canonical); + return found; +} diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 8f57f143d4761b..b1a8d430d8e21e 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -77,6 +77,8 @@ void preload_bulk_schedule_directory( task->reserved_fd = 0; release_open_fd(&scan->queue); if (saved_errno == EXDEV) { + preload_bulk_record_tracked_descendants_fallback( + worker, path, path_len); free(task); return; } diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 97fbd4b6ef22ff..41b2398d3913f3 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "name-hash.h" #include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -56,6 +57,18 @@ int preload_bulk_collect(struct index_state *istate, int threads, result->reason = "backend-unavailable"; if (!backend_available(backend)) return -1; + if (istate->sparse_index == INDEX_EXPANDED) { + /* + * Workers may need case-folding lookups for names returned by + * the filesystem. Build the lazy hash before they start. + * + * A collapsed sparse index cannot expand itself concurrently + * from the worker threads. Leave its unseen entries to the + * existing preload path, which expands them on the main thread. + */ + scan.case_insensitive = prepare_index_casefolding(istate); + scan.can_skip_unseen_preload = 1; + } if (git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) { scan.test_barrier_path = getenv( @@ -101,6 +114,8 @@ int preload_bulk_collect(struct index_state *istate, int threads, if (clean) { result->tracked_state = scan.tracked_state; result->nr = istate->cache_nr; + result->can_skip_unseen_preload = + scan.can_skip_unseen_preload; scan.tracked_state = NULL; } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 61bce71a454268..fff436d23f8d4b 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -78,6 +78,8 @@ struct preload_bulk_scan { unsigned char *tracked_state; int root_fd; int threads; + unsigned case_insensitive : 1; + unsigned can_skip_unseen_preload : 1; }; struct preload_bulk_run_result { @@ -95,6 +97,7 @@ struct preload_bulk_result { const char *outcome; const char *reason; struct preload_bulk_run_result run; + unsigned can_skip_unseen_preload : 1; }; void preload_bulk_schedule_directory( @@ -104,14 +107,19 @@ void preload_bulk_schedule_directory( const char *name, const char *path, size_t path_len); int preload_bulk_index_position(struct preload_bulk_scan *scan, const char *path, size_t path_len); -int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, - const char *path, - size_t path_len); int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos); void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st); +void preload_bulk_record_tracked_fallback( + struct preload_bulk_worker *worker, int pos); +void preload_bulk_record_tracked_descendants_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len); +int preload_bulk_record_tracked_alias_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); const struct preload_bulk_backend *preload_bulk_platform_backend(void); diff --git a/preload-index.c b/preload-index.c index ee7045b6b7a98c..5dadcee0a50471 100644 --- a/preload-index.c +++ b/preload-index.c @@ -45,6 +45,9 @@ struct thread_data { struct index_state *index; struct pathspec pathspec; struct progress_data *progress; +#ifdef HAVE_PRELOAD_INDEX_BULK + const unsigned char *bulk_state; +#endif int offset, nr; int t2_nr_lstat; }; @@ -65,6 +68,9 @@ static void *preload_thread(void *_data) struct index_state *index = p->index; struct cache_entry **cep = index->cache + p->offset; struct cache_def cache = CACHE_DEF_INIT; +#ifdef HAVE_PRELOAD_INDEX_BULK + const unsigned char *bulk_state = p->bulk_state; +#endif nr = p->nr; if (nr + p->offset > index->cache_nr) @@ -74,9 +80,18 @@ static void *preload_thread(void *_data) do { struct cache_entry *ce = *cep++; struct stat st; +#ifdef HAVE_PRELOAD_INDEX_BULK + unsigned char state = bulk_state ? + *bulk_state++ : PRELOAD_BULK_TRACKED_UNSEEN; +#endif if (!preload_entry_needs_stat(ce)) continue; +#ifdef HAVE_PRELOAD_INDEX_BULK + if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) + continue; +#endif if (p->progress && !(nr & 31)) { struct progress_data *pd = p->progress; @@ -143,9 +158,10 @@ static size_t preload_bulk_useful_candidates(struct index_state *index) return useful; } -static size_t preload_bulk_publish_clean( +static size_t preload_bulk_apply_result( struct index_state *index, - const struct preload_bulk_result *result) + struct preload_bulk_result *result, + int *has_deferred) { size_t applied = 0; @@ -153,12 +169,27 @@ static size_t preload_bulk_publish_clean( BUG("bulk preload result does not match the index"); for (size_t i = 0; i < result->nr; i++) { - struct cache_entry *ce; + struct cache_entry *ce = index->cache[i]; unsigned char state = result->tracked_state[i]; + /* + * A complete scan which did not observe a useful entry proves + * that the entry is absent. Avoid repeating the same lookup in + * speculative preload. A status consumer may use this result + * directly; other callers retain the authoritative refresh. + */ + if (result->can_skip_unseen_preload && + state == PRELOAD_BULK_TRACKED_UNSEEN && + preload_bulk_entry_is_useful(ce)) { + state = PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED; + result->tracked_state[i] = state; + } + if ((state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) && + preload_entry_needs_stat(ce)) + *has_deferred = 1; if (state != PRELOAD_BULK_TRACKED_CLEAN) continue; - ce = index->cache[i]; if (!preload_bulk_entry_is_useful(ce)) continue; ce_mark_uptodate(ce); @@ -192,6 +223,23 @@ static void preload_bulk_trace_result( const struct preload_bulk_result *result, size_t applied) { + uint64_t content_check = 0, definitive_deleted = 0, fallback = 0; + + for (size_t i = 0; i < result->nr; i++) { + switch (result->tracked_state[i]) { + case PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED: + definitive_deleted++; + break; + case PRELOAD_BULK_TRACKED_CONTENT_CHECK: + content_check++; + break; + case PRELOAD_BULK_TRACKED_FALLBACK: + fallback++; + break; + default: + break; + } + } trace2_data_string("index", index->repo, "preload/bulk_result", result->outcome); if (result->reason) @@ -207,13 +255,22 @@ static void preload_bulk_trace_result( result->run.bulk_calls); trace2_data_intmax("index", index->repo, "preload/bulk_workers", result->run.threads); + trace2_data_intmax("index", index->repo, + "preload/bulk_definitive_deleted", + definitive_deleted); + trace2_data_intmax("index", index->repo, + "preload/bulk_content_check", content_check); + trace2_data_intmax("index", index->repo, + "preload/bulk_fallback", fallback); } -static void preload_bulk_try(struct index_state *index) +static unsigned char *preload_bulk_try(struct index_state *index) { struct preload_bulk_result result = { 0 }; + unsigned char *tracked_state = NULL; size_t useful; size_t applied = 0; + int has_deferred = 0; int enabled = 0; int control, threads; @@ -230,21 +287,28 @@ static void preload_bulk_try(struct index_state *index) if (!enabled || fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_DISABLED || !preload_bulk_available()) - return; + return NULL; useful = preload_bulk_useful_candidates(index); trace2_data_intmax("index", index->repo, "preload/bulk_useful", useful); trace2_data_intmax("index", index->repo, "preload/bulk_cache_nr", index->cache_nr); if (!useful) - return; + return NULL; threads = preload_bulk_threads(useful); trace2_region_enter("index", "preload/bulk", index->repo); - if (!preload_bulk_collect(index, threads, &result)) - applied = preload_bulk_publish_clean(index, &result); + if (!preload_bulk_collect(index, threads, &result)) { + applied = preload_bulk_apply_result(index, &result, + &has_deferred); + } preload_bulk_trace_result(index, &result, applied); + if (has_deferred) { + tracked_state = result.tracked_state; + result.tracked_state = NULL; + } trace2_region_leave("index", "preload/bulk", index->repo); preload_bulk_result_release(&result); + return tracked_state; } #endif @@ -255,6 +319,9 @@ void preload_index(struct index_state *index, int threads, i, work, offset; struct thread_data data[MAX_PARALLEL]; struct progress_data pd; +#ifdef HAVE_PRELOAD_INDEX_BULK + unsigned char *bulk_state = NULL; +#endif int t2_sum_lstat = 0; int core_preload_index = 1; @@ -265,16 +332,24 @@ void preload_index(struct index_state *index, #ifdef HAVE_PRELOAD_INDEX_BULK if (!pathspec || !pathspec->nr) - preload_bulk_try(index); + bulk_state = preload_bulk_try(index); +#endif + if (!HAVE_THREADS) { +#ifdef HAVE_PRELOAD_INDEX_BULK + free(bulk_state); #endif - if (!HAVE_THREADS) return; + } threads = index->cache_nr / THREAD_COST; if ((index->cache_nr > 1) && (threads < 2) && git_env_bool("GIT_TEST_PRELOAD_INDEX", 0)) threads = 2; - if (threads < 2) + if (threads < 2) { +#ifdef HAVE_PRELOAD_INDEX_BULK + free(bulk_state); +#endif return; + } trace2_region_enter("index", "preload", NULL); @@ -298,6 +373,9 @@ void preload_index(struct index_state *index, int err; p->index = index; +#ifdef HAVE_PRELOAD_INDEX_BULK + p->bulk_state = bulk_state ? bulk_state + offset : NULL; +#endif if (pathspec) copy_pathspec(&p->pathspec, pathspec); p->offset = offset; @@ -317,6 +395,9 @@ void preload_index(struct index_state *index, t2_sum_lstat += p->t2_nr_lstat; } stop_progress(&pd.progress); +#ifdef HAVE_PRELOAD_INDEX_BULK + free(bulk_state); +#endif if (pathspec) { /* earlier we made deep copies for each thread to work with */ diff --git a/preload-index.h b/preload-index.h index 4b21e22b6afb19..64906fe5ac74a2 100644 --- a/preload-index.h +++ b/preload-index.h @@ -8,6 +8,7 @@ struct repository; enum preload_bulk_tracked_state { PRELOAD_BULK_TRACKED_UNSEEN = 0, PRELOAD_BULK_TRACKED_CLEAN, + PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED, PRELOAD_BULK_TRACKED_CONTENT_CHECK, PRELOAD_BULK_TRACKED_FALLBACK, }; diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index a54b049e75c5b2..193f616447c8a4 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -190,6 +190,59 @@ test_expect_success 'clean entries are published without lstat' ' check_lstat_data clean.trace 0 ' +test_expect_success 'known mismatches are not restated during preload' ' + setup_repo dirty && + test_write_lines changed-content >dirty/root && + compare_status dirty dirty.trace && + test_file_not_empty actual && + check_data dirty.trace preload/bulk_applied 7 && + check_data dirty.trace preload/bulk_content_check 1 && + check_lstat_data dirty.trace 0 && + check_data dirty.trace refresh/sum_lstat 1 +' + +test_expect_success 'missing entries bypass speculative lstat' ' + setup_repo missing && + rm missing/root && + rm -rf missing/nested && + compare_status missing missing.trace && + test_line_count = 5 actual && + check_data missing.trace preload/bulk_applied 3 && + check_data missing.trace preload/bulk_definitive_deleted 5 && + check_lstat_data missing.trace 0 && + check_data missing.trace refresh/sum_lstat 5 +' + +test_expect_success PIPE \ + 'tracked directories and unsupported vnodes fall back' ' + setup_repo tracked-types && + rm tracked-types/root tracked-types/root-peer && + mkdir tracked-types/root && + mkfifo tracked-types/root-peer && + compare_status tracked-types tracked-types.trace && + test_line_count = 2 actual && + check_data tracked-types.trace preload/bulk_applied 6 && + check_data tracked-types.trace preload/bulk_fallback 2 && + check_lstat_data tracked-types.trace 2 && + check_data tracked-types.trace refresh/sum_lstat 2 +' + +test_expect_success CASE_INSENSITIVE_FS \ + 'case aliases retain parallel preload' ' + setup_repo case-alias && + mv case-alias/root case-alias/ROOT && + mv case-alias/nested case-alias/NESTED && + git -C case-alias update-index --refresh && + compare_status case-alias case-alias.trace && + test_must_be_empty actual && + check_data case-alias.trace preload/bulk_applied 3 && + check_lstat_data case-alias.trace 5 && + { + test_have_prereq !PTHREADS || + check_data case-alias.trace refresh/sum_lstat 0 + } +' + test_expect_success ULIMIT_FILE_DESCRIPTORS \ 'bulk preload reopens directories under a low descriptor limit' ' git init low-fd && @@ -300,7 +353,7 @@ test_expect_success UTF8_NFD_TO_NFC \ test_expect_success 'multiply-linked entries are left to lstat' ' setup_repo hardlink && # Mutate through a name outside the watched worktree, then restore - # mtime. The bulk scan must reject the multiply-linked entry. + # mtime. The bulk scan must leave the multiply-linked entry to lstat. ln hardlink/root hardlink-alias && test-tool chmtime -120 hardlink/root && git -C hardlink update-index --refresh && @@ -311,7 +364,9 @@ test_expect_success 'multiply-linked entries are left to lstat' ' compare_status hardlink hardlink.trace && test_file_not_empty actual && check_data hardlink.trace preload/bulk_applied 7 && - check_lstat_data hardlink.trace 1 + check_data hardlink.trace preload/bulk_fallback 1 && + check_lstat_data hardlink.trace 1 && + check_data hardlink.trace refresh/sum_lstat 1 ' test_expect_success PIPE 'queued child replacement discards observations' ' From 8ba8f9d93fb7685e73633bcee78d996fc643eb7c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:06:36 -0500 Subject: [PATCH 106/403] preload-index: avoid bulk scans for sparse provider deltas --- preload-index.c | 25 ++++++++++++++++-------- t/t7530-status-clean-sidecar.sh | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/preload-index.c b/preload-index.c index 892f1204676829..1bea4a5d3f3b66 100644 --- a/preload-index.c +++ b/preload-index.c @@ -33,6 +33,7 @@ #define THREAD_COST (500) #define BULK_MAX_PARALLEL (32) #define BULK_ENTRIES_PER_THREAD (5000) +#define BULK_MIN_CANDIDATE_DIVISOR (8) struct progress_data { unsigned long n; @@ -454,17 +455,25 @@ int preload_index_bulk_can_close_provider(struct index_state *index) { #ifdef HAVE_PRELOAD_INDEX_BULK int core_preload_index = 1; + size_t useful; repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); - return core_preload_index && - preload_bulk_config_enabled(index) && - preload_bulk_available() && - index->sparse_index == INDEX_EXPANDED && - fsm_settings__get_mode(index->repo) == FSMONITOR_MODE_IPC && - fsmonitor_pending_token_from_provider(index) && - (preload_bulk_useful_candidates(index, 1) || - index->preload_untracked); + if (!core_preload_index || !preload_bulk_config_enabled(index) || + !preload_bulk_available() || + index->sparse_index != INDEX_EXPANDED || + fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_IPC || + !fsmonitor_pending_token_from_provider(index)) + return 0; + useful = preload_bulk_useful_candidates(index, 1); + if (!index->preload_untracked && + useful < DIV_ROUND_UP(index->cache_nr, + BULK_MIN_CANDIDATE_DIVISOR)) { + trace2_data_intmax("index", index->repo, + "preload/bulk_sparse_skip", useful); + return 0; + } + return useful || index->preload_untracked; #else (void)index; return 0; diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 911ea85a9b714e..2f66978038829c 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -295,6 +295,40 @@ test_expect_success DURABLE_FSMONITOR \ test_line_count = 1 external-nested-status.checkpoints ' +test_expect_success DURABLE_FSMONITOR \ + 'exact dirty status avoids a sparse full-worktree bulk scan' ' + test_when_finished "stop_daemon external-sparse-exact" && + setup_repo external-sparse-exact && + for i in $(test_seq 1 31) + do + test_write_lines "$i" >external-sparse-exact/clean-$i || + return 1 + done && + git -C external-sparse-exact add . && + git -C external-sparse-exact commit -m clean-files && + test-tool chmtime -120 external-sparse-exact/tracked \ + external-sparse-exact/clean-* && + git -C external-sparse-exact update-index --refresh && + git -C external-sparse-exact config core.untrackedCache true && + prime_semantic_history external-sparse-exact && + test_write_lines changed >external-sparse-exact/tracked && + test-tool chmtime -60 external-sparse-exact/tracked && + bulk_status -C external-sparse-exact status --porcelain=2 \ + >external-sparse-exact.primed && + bulk_status -C external-sparse-exact status --porcelain=v2 \ + -z --branch --show-stash --no-ahead-behind \ + --untracked-files=normal --ignore-submodules=all \ + >external-sparse-exact.daemon && + test_env GIT_TRACE2_EVENT="$PWD/external-sparse-exact.trace" \ + bulk_status -C external-sparse-exact status --porcelain=v2 \ + >actual && + test_grep "^1 \.M .* tracked$" actual && + test_trace2_data index preload/bulk_sparse_skip 1 \ + Date: Thu, 23 Jul 2026 23:55:11 -0500 Subject: [PATCH 107/403] preload-index: classify definitive tracked-file size changes S11/P01 retains scan results for later processing, but treats every changed stat observation as a pending content check. A nonzero cached size that differs from the observed size already proves that a tracked file changed. Racy timestamps, zero cached sizes, type changes, and the Windows symlink-size sentinel cannot establish that conclusion. Classify an entry as definitively modified only when its mode and type remain comparable, its cached size is nonzero, and match_stat_data() reports an actual data-size difference. Pass that terminal state through the existing preload result, skip its speculative restat, and emit a separate definitive-modification Trace2 count. Preserve ordinary content verification for every ambiguous observation. Update the APFS dirty-file test to require the new terminal classification, no speculative lstat, and the still-required authoritative refresh. This verifies the new state at its first consumer without claiming that status already consumes it directly. Signed-off-by: Taylor Blau --- preload-index-bulk-index.c | 30 ++++++++++++++++++++++++++++-- preload-index.c | 11 ++++++++++- preload-index.h | 1 + t/t7529-preload-index-apfs.sh | 4 ++-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index 0d129b619cf2fc..bd26b72ccb2986 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -86,6 +86,28 @@ static int tracked_entry_is_eligible(const struct cache_entry *ce) (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)); } +/* + * Match ie_modified(): a nonzero cached size mismatch is a conclusive + * content change. Zero sizes and the historical Windows symlink sentinel + * still require an ordinary content check. Recompute the stat-data match + * because CE_MATCH_RACY_IS_DIRTY may make ie_match_stat() report a data + * change without a size mismatch. + */ +static int size_change_is_definitive(const struct cache_entry *ce, + const struct stat *st, + unsigned int changed) +{ + if (changed & (MODE_CHANGED | TYPE_CHANGED)) + return 0; +#ifdef GIT_WINDOWS_NATIVE + if (S_ISLNK(st->st_mode) && ce->ce_stat_data.sd_size == MAX_PATH) + return 0; +#endif + return ce->ce_stat_data.sd_size && + (match_stat_data(&ce->ce_stat_data, (struct stat *)st) & + DATA_CHANGED); +} + void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st) { @@ -99,8 +121,12 @@ void preload_bulk_record_tracked( changed = ie_match_stat( scan->istate, ce, (struct stat *)st, CE_MATCH_RACY_IS_DIRTY | CE_MATCH_IGNORE_FSMONITOR); - state = changed ? PRELOAD_BULK_TRACKED_CONTENT_CHECK : - PRELOAD_BULK_TRACKED_CLEAN; + if (!changed) + state = PRELOAD_BULK_TRACKED_CLEAN; + else if (size_change_is_definitive(ce, st, changed)) + state = PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED; + else + state = PRELOAD_BULK_TRACKED_CONTENT_CHECK; record_tracked_state(worker, pos, state); } diff --git a/preload-index.c b/preload-index.c index 5dadcee0a50471..9e082af764a82d 100644 --- a/preload-index.c +++ b/preload-index.c @@ -89,6 +89,7 @@ static void *preload_thread(void *_data) continue; #ifdef HAVE_PRELOAD_INDEX_BULK if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) continue; #endif @@ -185,6 +186,7 @@ static size_t preload_bulk_apply_result( result->tracked_state[i] = state; } if ((state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) && preload_entry_needs_stat(ce)) *has_deferred = 1; @@ -223,10 +225,14 @@ static void preload_bulk_trace_result( const struct preload_bulk_result *result, size_t applied) { - uint64_t content_check = 0, definitive_deleted = 0, fallback = 0; + uint64_t content_check = 0, definitive_modified = 0; + uint64_t definitive_deleted = 0, fallback = 0; for (size_t i = 0; i < result->nr; i++) { switch (result->tracked_state[i]) { + case PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED: + definitive_modified++; + break; case PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED: definitive_deleted++; break; @@ -255,6 +261,9 @@ static void preload_bulk_trace_result( result->run.bulk_calls); trace2_data_intmax("index", index->repo, "preload/bulk_workers", result->run.threads); + trace2_data_intmax("index", index->repo, + "preload/bulk_definitive_modified", + definitive_modified); trace2_data_intmax("index", index->repo, "preload/bulk_definitive_deleted", definitive_deleted); diff --git a/preload-index.h b/preload-index.h index 64906fe5ac74a2..01d90e06bb6b3f 100644 --- a/preload-index.h +++ b/preload-index.h @@ -8,6 +8,7 @@ struct repository; enum preload_bulk_tracked_state { PRELOAD_BULK_TRACKED_UNSEEN = 0, PRELOAD_BULK_TRACKED_CLEAN, + PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED, PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED, PRELOAD_BULK_TRACKED_CONTENT_CHECK, PRELOAD_BULK_TRACKED_FALLBACK, diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index 193f616447c8a4..5ad04921ebfda8 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -190,13 +190,13 @@ test_expect_success 'clean entries are published without lstat' ' check_lstat_data clean.trace 0 ' -test_expect_success 'known mismatches are not restated during preload' ' +test_expect_success 'definitive size changes are not restated' ' setup_repo dirty && test_write_lines changed-content >dirty/root && compare_status dirty dirty.trace && test_file_not_empty actual && check_data dirty.trace preload/bulk_applied 7 && - check_data dirty.trace preload/bulk_content_check 1 && + check_data dirty.trace preload/bulk_definitive_modified 1 && check_lstat_data dirty.trace 0 && check_data dirty.trace refresh/sum_lstat 1 ' From 29e9dff3eab994b1983848769c50a3918a0d652a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:24:17 -0500 Subject: [PATCH 108/403] fsmonitor: keep external history disabled on Windows --- clean-status-history-store.c | 4 ++++ t/unit-tests/u-clean-status-history-store.c | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 49b37166ce56fd..06e6a7219a92b9 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -475,6 +475,10 @@ int clean_status_history_store_install( int current_is_regular, encoded_matches = 0; int checkpoint_fd = -1, ret = -1; +#ifdef GIT_WINDOWS_NATIVE + /* Preserve the unsupported Windows path's original fail-closed behavior. */ + goto done; +#endif if (!snapshot || snapshot->fd < 0 || !clean_status_index_snapshot_still_matches_path( snapshot, index_path, algo)) diff --git a/t/unit-tests/u-clean-status-history-store.c b/t/unit-tests/u-clean-status-history-store.c index 73719517ecc3f8..c5f85a6be87ff5 100644 --- a/t/unit-tests/u-clean-status-history-store.c +++ b/t/unit-tests/u-clean-status-history-store.c @@ -128,6 +128,13 @@ static void require_local_apfs(const char *path MAYBE_UNUSED) #endif } +static void require_supported_history_store(void) +{ +#ifdef GIT_WINDOWS_NATIVE + cl_skip(); +#endif +} + void test_clean_status_history_store__rejects_incomplete_checkpoints(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -189,6 +196,7 @@ void test_clean_status_history_store__keeps_namespaces_independent(void) static const unsigned char second_fsmn[] = "second-fsmn"; static const unsigned char second_fscf[] = "second-fscf"; + require_supported_history_store(); fixture_init(&fixture, algo); memset(first.index_hash, 1, algo->rawsz); first.fsmonitor = first_fsmn; @@ -291,6 +299,7 @@ void test_clean_status_history_store__bounds_namespaces(void) struct utimbuf times; char namespace[32]; + require_supported_history_store(); fixture_init(&fixture, algo); checkpoint.fsmonitor = fsmn; checkpoint.fsmonitor_len = sizeof(fsmn) - 1; From 490e84462327f7922dea002687475c3934c13d2a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 10:50:14 -0700 Subject: [PATCH 109/403] read-cache: reject out-of-bounds index extensions load_index_extensions() enters its extension loop only when a complete eight-byte header fits before the trailing checksum. It nevertheless trusts the declared payload size. An oversized payload can send an extension parser beyond the mapped extension area, while an incomplete trailing header is silently ignored. Compute the checksum boundary once and require the initial offset, each complete header, and each declared payload to fit within it. Advance only by checked header and payload sizes, reject partial trailing headers, and report framing failures as index file corruption. Add a PERL_TEST_HELPERS regression test that overwrites an FSMN payload length with 0xffffffff and checks that porcelain-v2 status fails with the existing corruption diagnostic. Signed-off-by: Taylor Blau --- read-cache.c | 42 ++++++++++++++++++++++++++++++------- t/t7519-status-fsmonitor.sh | 25 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/read-cache.c b/read-cache.c index 6c449f393d8d4d..76941114ba8498 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1999,27 +1999,55 @@ struct load_index_extensions static void *load_index_extensions(void *_data) { struct load_index_extensions *p = _data; - unsigned long src_offset = p->src_offset; + size_t src_offset = p->src_offset; + size_t end; + int extension_error = 0; - while (src_offset <= p->mmap_size - the_hash_algo->rawsz - 8) { + if (p->mmap_size < the_hash_algo->rawsz) { + extension_error = 1; + goto done; + } + end = p->mmap_size - the_hash_algo->rawsz; + if (src_offset > end) { + extension_error = 1; + goto done; + } + + while (src_offset < end) { /* After an array of active_nr index entries, * there can be arbitrary number of extended * sections, each of which is prefixed with * extension name (4-byte) and section length * in 4-byte network byte order. */ - uint32_t extsize = get_be32(p->mmap + src_offset + 4); + uint32_t extsize; + + if (end - src_offset < 8) { + extension_error = 1; + break; + } + extsize = get_be32(p->mmap + src_offset + 4); + if (extsize > end - src_offset - 8) { + extension_error = 1; + break; + } if (read_index_extension(p->istate, p->mmap + src_offset, p->mmap + src_offset + 8, extsize) < 0) { - munmap((void *)p->mmap, p->mmap_size); - die(_("index file corrupt")); + extension_error = 1; + break; } - src_offset += 8; - src_offset += extsize; + src_offset += 8 + extsize; } + if (src_offset != end) + extension_error = 1; +done: + if (extension_error) { + munmap((void *)p->mmap, p->mmap_size); + die(_("index file corrupt")); + } return NULL; } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 93973ed25a448b..2e90955b52c374 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -55,6 +55,31 @@ test_lazy_prereq UNTRACKED_CACHE ' test $ret -ne 1 ' +test_expect_success PERL_TEST_HELPERS \ + 'index reader rejects an out-of-bounds extension size' ' + test_when_finished "rm -rf oversized-index-extension" && + test_create_repo oversized-index-extension && + ( + cd oversized-index-extension && + test_commit base tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + test_grep FSMN .git/index >/dev/null && + perl -0777 -pe " + \$pos = index(\$_, q(FSMN)); + die q(FSMN-not-found) if \$pos < 0; + substr(\$_, \$pos + 4, 4) = pack(q(N), 0xffffffff); + " .git/index >.git/index.bad && + mv .git/index.bad .git/index && + test_must_fail git status --porcelain=v2 2>err && + test_grep "index file corrupt" err + ) +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From 959348fe05a20e4a6e574ddc254e289f487a69b4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:55:56 -0500 Subject: [PATCH 110/403] status: consume definitive bulk changes exactly once A complete APFS preload can already prove that tracked entries are deleted or have definitive size changes. Ordinary status nevertheless refreshes those entries and later asks worktree diff to rediscover them. Skipping refresh without preserving ambiguous content checks would either duplicate work or misreport metadata-only changes. Request terminal-result deferral explicitly from porcelain status and retain a complete per-entry result on its index. Let refresh defer proven modifications and deletions while marking ambiguous entries for the ordinary content check. Insert terminal changes into the normal status change list before running worktree diff, temporarily mark only those entries up to date, and restore their flags afterward. Clear retained results before another preload and release them with the index. Other refresh callers keep their existing behavior. Extend the APFS tests to assert direct modified and deleted results and no redundant refresh stats. Add a metadata-only mismatch that must still reach worktree diff and produce clean porcelain output. Retained terminal state trades additional temporary memory for removing the second classification of proven changes. Signed-off-by: Taylor Blau --- builtin/commit.c | 3 +- preload-index.c | 25 ++++++++-- preload-index.h | 1 + read-cache-ll.h | 3 ++ read-cache.c | 17 ++++++- t/t7529-preload-index-apfs.sh | 18 ++++++- wt-status.c | 93 +++++++++++++++++++++++++++++------ 7 files changed, 139 insertions(+), 21 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index e2f4d08b347707..fa64ba01f2a5e7 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1629,7 +1629,8 @@ struct repository *repo UNUSED) repo_read_index(the_repository); wt_status_start_untracked_cache_preload(&s); wt_status_refresh_index( - &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag, + &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag | + REFRESH_DEFER_BULK_DIRTY, s.show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s.show_ignored_mode); diff --git a/preload-index.c b/preload-index.c index 9e082af764a82d..72d37ae93e67d0 100644 --- a/preload-index.c +++ b/preload-index.c @@ -319,8 +319,26 @@ static unsigned char *preload_bulk_try(struct index_state *index) preload_bulk_result_release(&result); return tracked_state; } + +static void preload_bulk_finish_state(struct index_state *index, + unsigned char **state, + unsigned int refresh_flags) +{ + if ((refresh_flags & REFRESH_DEFER_BULK_DIRTY) && *state) { + index->preload_bulk_tracked_state = *state; + index->preload_bulk_tracked_nr = index->cache_nr; + *state = NULL; + } + FREE_AND_NULL(*state); +} #endif +void preload_index_bulk_result_clear(struct index_state *index) +{ + FREE_AND_NULL(index->preload_bulk_tracked_state); + index->preload_bulk_tracked_nr = 0; +} + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags) @@ -334,6 +352,7 @@ void preload_index(struct index_state *index, int t2_sum_lstat = 0; int core_preload_index = 1; + preload_index_bulk_result_clear(index); repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); if (!core_preload_index) @@ -345,7 +364,7 @@ void preload_index(struct index_state *index, #endif if (!HAVE_THREADS) { #ifdef HAVE_PRELOAD_INDEX_BULK - free(bulk_state); + preload_bulk_finish_state(index, &bulk_state, refresh_flags); #endif return; } @@ -355,7 +374,7 @@ void preload_index(struct index_state *index, threads = 2; if (threads < 2) { #ifdef HAVE_PRELOAD_INDEX_BULK - free(bulk_state); + preload_bulk_finish_state(index, &bulk_state, refresh_flags); #endif return; } @@ -405,7 +424,7 @@ void preload_index(struct index_state *index, } stop_progress(&pd.progress); #ifdef HAVE_PRELOAD_INDEX_BULK - free(bulk_state); + preload_bulk_finish_state(index, &bulk_state, refresh_flags); #endif if (pathspec) { diff --git a/preload-index.h b/preload-index.h index 01d90e06bb6b3f..bb6deb6130cc2f 100644 --- a/preload-index.h +++ b/preload-index.h @@ -20,5 +20,6 @@ void preload_index(struct index_state *index, int repo_read_index_preload(struct repository *, const struct pathspec *pathspec, unsigned refresh_flags); +void preload_index_bulk_result_clear(struct index_state *index); #endif /* PRELOAD_INDEX_H */ diff --git a/read-cache-ll.h b/read-cache-ll.h index d44a946ed04d36..99b95d8e8d5325 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -194,6 +194,8 @@ struct index_state { struct hashmap dir_hash; struct object_id oid; struct untracked_cache *untracked; + unsigned char *preload_bulk_tracked_state; + size_t preload_bulk_tracked_nr; char *fsmonitor_last_update; char *fsmonitor_last_update_pending; char *fsmonitor_untracked_token; @@ -474,6 +476,7 @@ int fake_lstat(const struct cache_entry *ce, struct stat *st); #define REFRESH_IN_PORCELAIN (1 << 5) /* user friendly output, not "needs update" */ #define REFRESH_PROGRESS (1 << 6) /* show progress bar if stderr is tty */ #define REFRESH_IGNORE_SKIP_WORKTREE (1 << 7) /* ignore skip_worktree entries */ +#define REFRESH_DEFER_BULK_DIRTY (1 << 8) /* leave bulk results to diff */ int refresh_index(struct index_state *, unsigned int flags, const struct pathspec *pathspec, char *seen, const char *header_msg); /* * Refresh the index and write it to disk. diff --git a/read-cache.c b/read-cache.c index cf3600f2a337d6..f606bf81ddaca2 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1550,7 +1550,7 @@ int refresh_index(struct index_state *istate, unsigned int flags, * cache entries quickly then in the single threaded loop below, * we only have to do the special cases that are left. */ - preload_index(istate, pathspec, 0); + preload_index(istate, pathspec, flags & REFRESH_DEFER_BULK_DIRTY); trace2_region_enter("index", "refresh", NULL); for (i = 0; i < istate->cache_nr; i++) { @@ -1594,6 +1594,20 @@ int refresh_index(struct index_state *istate, unsigned int flags, if (filtered) continue; + if ((flags & REFRESH_DEFER_BULK_DIRTY) && + istate->preload_bulk_tracked_nr == istate->cache_nr) { + unsigned char state = + istate->preload_bulk_tracked_state[i]; + + if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK) { + ce->ce_flags |= CE_CONTENT_CHECK_REQUIRED; + continue; + } + if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) + continue; + } + new_entry = refresh_cache_ent(istate, ce, options, &cache_errno, &changed, &t2_did_lstat, &t2_did_scan); @@ -2473,6 +2487,7 @@ void release_index(struct index_state *istate) free(istate->fsmonitor_last_update); free(istate->fsmonitor_last_update_pending); free(istate->fsmonitor_untracked_token); + free(istate->preload_bulk_tracked_state); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index 5ad04921ebfda8..c7c399045f2e1e 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -197,8 +197,10 @@ test_expect_success 'definitive size changes are not restated' ' test_file_not_empty actual && check_data dirty.trace preload/bulk_applied 7 && check_data dirty.trace preload/bulk_definitive_modified 1 && + test_trace2_data status preload/direct_modified 1 \ + <"$TRASH_DIRECTORY/dirty.trace" && check_lstat_data dirty.trace 0 && - check_data dirty.trace refresh/sum_lstat 1 + check_data dirty.trace refresh/sum_lstat 0 ' test_expect_success 'missing entries bypass speculative lstat' ' @@ -209,8 +211,20 @@ test_expect_success 'missing entries bypass speculative lstat' ' test_line_count = 5 actual && check_data missing.trace preload/bulk_applied 3 && check_data missing.trace preload/bulk_definitive_deleted 5 && + test_trace2_data status preload/direct_deleted 5 \ + <"$TRASH_DIRECTORY/missing.trace" && check_lstat_data missing.trace 0 && - check_data missing.trace refresh/sum_lstat 5 + check_data missing.trace refresh/sum_lstat 0 +' + +test_expect_success 'metadata-only mismatches are checked by diff' ' + setup_repo metadata && + test-tool chmtime +60 metadata/root && + compare_status metadata metadata.trace && + test_must_be_empty actual && + check_data metadata.trace preload/bulk_content_check 1 && + check_lstat_data metadata.trace 0 && + check_data metadata.trace refresh/sum_lstat 0 ' test_expect_success PIPE \ diff --git a/wt-status.c b/wt-status.c index 57e2321275dc07..7ea206bdc9609b 100644 --- a/wt-status.c +++ b/wt-status.c @@ -14,6 +14,7 @@ #include "hex.h" #include "object-name.h" #include "path.h" +#include "preload-index.h" #include "revision.h" #include "diffcore.h" #include "quote.h" @@ -458,6 +459,19 @@ static char short_submodule_status(struct wt_status_change_data *d) return d->worktree_status; } +static struct wt_status_change_data *wt_status_get_change( + struct wt_status *s, const char *path) +{ + struct string_list_item *it = string_list_insert(&s->change, path); + struct wt_status_change_data *d = it->util; + + if (!d) { + CALLOC_ARRAY(d, 1); + it->util = d; + } + return d; +} + static void wt_status_collect_changed_cb(struct diff_queue_struct *q, struct diff_options *options UNUSED, void *data) @@ -470,16 +484,10 @@ static void wt_status_collect_changed_cb(struct diff_queue_struct *q, s->workdir_dirty = 1; for (i = 0; i < q->nr; i++) { struct diff_filepair *p; - struct string_list_item *it; struct wt_status_change_data *d; p = q->queue[i]; - it = string_list_insert(&s->change, p->two->path); - d = it->util; - if (!d) { - CALLOC_ARRAY(d, 1); - it->util = d; - } + d = wt_status_get_change(s, p->two->path); if (!d->worktree_status) d->worktree_status = p->status; if (S_ISGITLINK(p->two->mode)) { @@ -525,6 +533,64 @@ static void wt_status_collect_changed_cb(struct diff_queue_struct *q, } } +static struct cache_entry **wt_status_collect_preload_changes( + struct wt_status *s, size_t *direct_nr) +{ + struct index_state *istate = s->repo->index; + struct cache_entry **direct = NULL; + size_t direct_alloc = 0; + uint64_t modified = 0, deleted = 0; + + *direct_nr = 0; + if (istate->preload_bulk_tracked_nr != istate->cache_nr) + goto clear; + for (size_t i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + struct wt_status_change_data *d; + unsigned char state = + istate->preload_bulk_tracked_state[i]; + int status; + + if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED) { + status = DIFF_STATUS_MODIFIED; + modified++; + } else if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) { + status = DIFF_STATUS_DELETED; + deleted++; + } else { + continue; + } + + d = wt_status_get_change(s, ce->name); + if (!d->worktree_status) + d->worktree_status = status; + d->mode_index = ce->ce_mode; + d->mode_worktree = status == DIFF_STATUS_MODIFIED ? + ce->ce_mode : 0; + oidcpy(&d->oid_index, &ce->oid); + ce_mark_uptodate(ce); + ALLOC_GROW(direct, *direct_nr + 1, direct_alloc); + direct[(*direct_nr)++] = ce; + s->workdir_dirty = 1; + } + trace2_data_intmax("status", s->repo, "preload/direct_modified", + modified); + trace2_data_intmax("status", s->repo, "preload/direct_deleted", + deleted); + +clear: + preload_index_bulk_result_clear(istate); + return direct; +} + +static void wt_status_release_preload_changes( + struct cache_entry **direct, size_t direct_nr) +{ + for (size_t i = 0; i < direct_nr; i++) + direct[i]->ce_flags &= ~CE_UPTODATE; + free(direct); +} + static int unmerged_mask(struct index_state *istate, const char *path) { int pos, mask; @@ -554,16 +620,10 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q, for (i = 0; i < q->nr; i++) { struct diff_filepair *p; - struct string_list_item *it; struct wt_status_change_data *d; p = q->queue[i]; - it = string_list_insert(&s->change, p->two->path); - d = it->util; - if (!d) { - CALLOC_ARRAY(d, 1); - it->util = d; - } + d = wt_status_get_change(s, p->two->path); if (!d->index_status) d->index_status = p->status; switch (p->status) { @@ -639,8 +699,11 @@ void wt_status_collect_changes_trees(struct wt_status *s, static void wt_status_collect_changes_worktree(struct wt_status *s) { + struct cache_entry **direct; + size_t direct_nr; struct rev_info rev; + direct = wt_status_collect_preload_changes(s, &direct_nr); repo_init_revisions(s->repo, &rev, NULL); setup_revisions(0, NULL, &rev, NULL); rev.diffopt.output_format |= DIFF_FORMAT_CALLBACK; @@ -661,6 +724,7 @@ static void wt_status_collect_changes_worktree(struct wt_status *s) rev.diffopt.rename_score = s->rename_score >= 0 ? s->rename_score : rev.diffopt.rename_score; copy_pathspec(&rev.prune_data, &s->pathspec); run_diff_files(&rev, 0); + wt_status_release_preload_changes(direct, direct_nr); release_revisions(&rev); } @@ -850,6 +914,7 @@ static void wt_status_finish_untracked_cache_preload(struct wt_status *s) if (!index_invalidated) return; + preload_index_bulk_result_clear(istate); trace2_data_intmax("status", s->repo, "fsmonitor/exclude-index-invalidated", index_invalidated); From d8a46b64964a75b344c71e45053bc6fc6f706d7d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:24:24 -0500 Subject: [PATCH 111/403] fsmonitor: honor explicitly invalidated external history --- clean-status-history.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/clean-status-history.c b/clean-status-history.c index ba4c822db01f2a..d2f487d46296e9 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -592,6 +592,19 @@ int clean_status_restore_external_history(struct index_state *istate) on_index_history_is_coherent(istate) || clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate)) goto done; + /* + * An unbound proof for the current configuration records deliberate + * invalidation. A legacy writer removes FSCF entirely, while a proof + * from another configuration must not hide this namespace's checkpoint. + */ + if (state->disk_config_valid && + !memcmp(state->disk_config_hash, state->current_config_hash, + istate->repo->hash_algo->rawsz) && + !clean_status_has_persistent_fsmonitor_semantic_history(istate)) { + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-proof-invalidated", 1); + goto done; + } if (external_history_namespace(istate, proof_namespace)) goto done; if (!clean_status_history_store_load( From d5279d06641cd892226d39bce89b050b87e49c5d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:33 -0500 Subject: [PATCH 112/403] cache-tree: avoid allocations and searches while reading read_one() allocates a subtree array even for leaf cache-tree nodes. It also inserts each serialized child through cache_tree_sub(), which searches children that the writer already emits in increasing order. Allocate a child array only for non-leaf nodes and append increasing child names directly. Retain subtree_nr + 2 pointer slots for each non-leaf, but allocate them without zeroing because only populated slots are inspected. Keep cache_tree_sub() as the compatibility fallback for older, unsorted input. Existing t/t0090-cache-tree.sh tests exercise ordinary cache-tree decoding. This change adds no dedicated unsorted-input regression or isolated benchmark. Signed-off-by: Taylor Blau --- cache-tree.c | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cache-tree.c b/cache-tree.c index d92f5132865f13..c811e23b14705b 100644 --- a/cache-tree.c +++ b/cache-tree.c @@ -676,20 +676,34 @@ static struct cache_tree *read_one(const char **buffer, unsigned long *size_p) /* * Just a heuristic -- we do not add directories that often but * we do not want to have to extend it immediately when we do, - * hence +2. + * hence +2. Avoid a separate allocation for the common leaf case. */ - it->subtree_alloc = subtree_nr + 2; - CALLOC_ARRAY(it->down, it->subtree_alloc); + if (subtree_nr) { + it->subtree_alloc = subtree_nr + 2; + ALLOC_ARRAY(it->down, it->subtree_alloc); + } for (i = 0; i < subtree_nr; i++) { /* read each subtree */ struct cache_tree *sub; struct cache_tree_sub *subtree; const char *name = buf; + int namelen; sub = read_one(&buf, &size); if (!sub) goto free_return; - subtree = cache_tree_sub(it, name); + namelen = strlen(name); + if (!it->subtree_nr || + subtree_name_cmp(it->down[it->subtree_nr - 1]->name, + it->down[it->subtree_nr - 1]->namelen, + name, namelen) < 0) { + FLEX_ALLOC_MEM(subtree, name, name, namelen); + subtree->namelen = namelen; + it->down[it->subtree_nr++] = subtree; + } else { + /* Be liberal in what we accept from older writers. */ + subtree = cache_tree_sub(it, name); + } subtree->cache_tree = sub; } if (subtree_nr != it->subtree_nr) From 1fe83a67cd93c1d6a8810331dd97ab859a273547 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:48:56 -0500 Subject: [PATCH 113/403] read-cache: factor refreshed entry construction Once refresh_cache_ent() verifies that an entry's content still matches, it allocates and copies a replacement, fills its stat data, and preserves a caller-cleared CE_VALID bit under assume_unchanged. Keeping that sequence in one caller would require another verified refresh path to duplicate the allocation and validity handling. Extract make_refreshed_cache_entry() as a private helper and keep refresh_cache_ent() as its first consumer. Pass !ignore_valid through the existing condition so the entry name, observed stat data, and CE_VALID behavior remain unchanged. This is a behavior-preserving refactor. It introduces no new index write, configuration, test claim, or independent performance claim. Signed-off-by: Taylor Blau --- read-cache.c | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/read-cache.c b/read-cache.c index f606bf81ddaca2..088f83559a6b56 100644 --- a/read-cache.c +++ b/read-cache.c @@ -205,6 +205,23 @@ void fill_stat_cache_info(struct index_state *istate, struct cache_entry *ce, st } } +static struct cache_entry *make_refreshed_cache_entry( + struct index_state *istate, const struct cache_entry *ce, + struct stat *st, int preserve_valid) +{ + struct cache_entry *updated = + make_empty_cache_entry(istate, ce_namelen(ce)); + + copy_cache_entry(updated, ce); + memcpy(updated->name, ce->name, ce->ce_namelen + 1); + fill_stat_cache_info(istate, updated, st); + /* Do not let assume-unchanged reacquire a caller-cleared CE_VALID. */ + if (preserve_valid && assume_unchanged && + !(ce->ce_flags & CE_VALID)) + updated->ce_flags &= ~CE_VALID; + return updated; +} + static unsigned int st_mode_from_ce(const struct cache_entry *ce) { switch (ce->ce_mode & S_IFMT) { @@ -1458,19 +1475,7 @@ static struct cache_entry *refresh_cache_ent(struct index_state *istate, return NULL; } - updated = make_empty_cache_entry(istate, ce_namelen(ce)); - copy_cache_entry(updated, ce); - memcpy(updated->name, ce->name, ce->ce_namelen + 1); - fill_stat_cache_info(istate, updated, &st); - /* - * If ignore_valid is not set, we should leave CE_VALID bit - * alone. Otherwise, paths marked with --no-assume-unchanged - * (i.e. things to be edited) will reacquire CE_VALID bit - * automatically, which is not really what we want. - */ - if (!ignore_valid && assume_unchanged && - !(ce->ce_flags & CE_VALID)) - updated->ce_flags &= ~CE_VALID; + updated = make_refreshed_cache_entry(istate, ce, &st, !ignore_valid); /* istate->cache_changed is updated in the caller */ return updated; From a3bd2bc07ef029ed012422edd2d2f56b5cf996dc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:09:31 -0500 Subject: [PATCH 114/403] status: preserve semantic history for scoped queries A pathspec prevents status from closing its fsmonitor token, so every scoped invocation invalidates the attribute manifest and rewrites the index. Repeated commands like "git status -- api" consequently rescan the entire worktree metadata. Allow scoped status to close and checkpoint global semantic history. Validate the complete untracked cache when establishing that proof, but discard its unfiltered results and collect the requested pathspec separately. Keep clean-worktree sidecars restricted to root-wide queries so dirt outside the selected paths cannot be hidden. --- builtin/commit.c | 10 ++++----- t/t7530-status-clean-sidecar.sh | 40 +++++++++++++++++++++++++++++++++ wt-status.c | 20 +++++++++++++---- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 7d564109df7454..a700085df5e4d1 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1750,8 +1750,7 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; - if (!s.pathspec.nr) - clean_status_enable_external_history(the_repository); + clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; if ((exact_clean_query || normal_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { @@ -1769,7 +1768,7 @@ struct repository *repo UNUSED) clean_status_enable_progress(the_repository); } repo_read_index(the_repository); - if (!s.pathspec.nr && use_optional_locks()) + if (use_optional_locks()) clean_status_capture_external_history_source( the_repository->index); if (normal_clean_query && use_optional_locks() && @@ -1827,9 +1826,8 @@ struct repository *repo UNUSED) * entry repair durable. Restored checkpoints stay no-spill * for foreign index writers. */ - if (!s.pathspec.nr) - external_saved = clean_status_save_external_history( - the_repository->index); + external_saved = clean_status_save_external_history( + the_repository->index); if (exact_clean_query) { if (!preserve_entry_changes && external_saved && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 2f66978038829c..b7ab718e009cc6 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -295,6 +295,46 @@ test_expect_success DURABLE_FSMONITOR \ test_line_count = 1 external-nested-status.checkpoints ' +test_expect_success DURABLE_FSMONITOR \ + 'pathspec status preserves history without certifying outside paths' ' + test_when_finished "stop_daemon external-pathspec-status" && + setup_repo external-pathspec-status && + mkdir external-pathspec-status/scoped && + test_commit -C external-pathspec-status scoped scoped/tracked && + test-tool -C external-pathspec-status chmtime -120 \ + tracked scoped/tracked && + git -C external-pathspec-status update-index --refresh && + git -C external-pathspec-status config core.untrackedCache true && + prime_semantic_history external-pathspec-status && + git -C external-pathspec-status config core.autocrlf false && + test_write_lines changed >external-pathspec-status/tracked && + test_write_lines selected >external-pathspec-status/scoped/new && + test_write_lines outside >external-pathspec-status/outside-new && + bulk_status -C external-pathspec-status \ + status --porcelain=v2 -- scoped >external-pathspec-status.first && + test_grep "^? scoped/new$" external-pathspec-status.first && + ! test_grep "tracked\|outside-new" external-pathspec-status.first && + test_path_is_missing external-pathspec-status/.git/index.csts && + cp external-pathspec-status/.git/index \ + external-pathspec-status.before && + test_env GIT_TRACE2_EVENT="$PWD/external-pathspec-status.trace" \ + bulk_status -C external-pathspec-status \ + status --porcelain=v2 -- scoped \ + >external-pathspec-status.second && + test_cmp external-pathspec-status.first \ + external-pathspec-status.second && + test_cmp external-pathspec-status.before \ + external-pathspec-status/.git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + external-pathspec-status.root && + test_grep "^1 \.M .* tracked$" external-pathspec-status.root +' + test_expect_success DURABLE_FSMONITOR \ 'exact dirty status avoids a sparse full-worktree bulk scan' ' test_when_finished "stop_daemon external-sparse-exact" && diff --git a/wt-status.c b/wt-status.c index e5d2e958206058..888377dbe36145 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1229,7 +1229,7 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( int ret; if (!fstat_is_reliable() || istate->split_index || - s->show_ignored_mode || s->pathspec.nr || + s->show_ignored_mode || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || istate->sparse_index != INDEX_EXPANDED || !fsmonitor_has_pending_token(istate) || @@ -1296,12 +1296,24 @@ static void wt_status_discard_staged_untracked( static int wt_status_stage_untracked( struct wt_status_token_closure *closure) { + struct wt_status *s = closure->status; + struct pathspec pathspec = s->pathspec; + wt_status_discard_staged_untracked(closure); + /* A provider token can certify only a complete untracked traversal. */ + if (pathspec.nr) + memset(&s->pathspec, 0, sizeof(s->pathspec)); closure->staged_untracked_ready = wt_status_collect_untracked_1( - closure->status, + s, &closure->staged_untracked, &closure->staged_ignored); + if (pathspec.nr) { + s->pathspec = pathspec; + /* The ordinary scoped traversal supplies the displayed results. */ + string_list_clear(&closure->staged_untracked, 0); + string_list_clear(&closure->staged_ignored, 0); + } if (!closure->staged_untracked_ready) wt_status_discard_staged_untracked(closure); return closure->staged_untracked_ready; @@ -1312,7 +1324,7 @@ static void wt_status_publish_staged_untracked( { struct wt_status *s = closure->status; - if (!closure->staged_untracked_ready) + if (!closure->staged_untracked_ready || s->pathspec.nr) return; if (s->untracked.nr || s->ignored.nr) BUG("publishing untracked results over collected status"); @@ -1663,7 +1675,7 @@ static int wt_status_close_fsmonitor_token( enum wt_status_token_closure_result result; refresh_fsmonitor(istate); - if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || + if (!fsmonitor_has_pending_token(istate) || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) { int attr_inputs_match = wt_status_attr_snapshot_matches(s) && From c7c1f82f97a109caea602345a02a0f29b6df9c3d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:53 -0500 Subject: [PATCH 115/403] read-cache: decode bounded TREE and UNTR extensions in parallel The index extension worker decodes TREE and UNTR serially even though their parsers read the same immutable mapping and publish to different index_state fields. An unconditional additional worker would consume cache-entry workers and interfere with split-index assembly. Use the bounded framing from S02/P01 to select exactly one TREE and one UNTR extension. Require extension-offset metadata and at least four index workers; start an additional TREE worker only when both payloads reach 1 MiB. Leave at least two cache-entry workers available and join the TREE worker before unmapping the index. Keep LINK, duplicate or missing extensions, insufficient workers, small payloads, and auxiliary-worker creation failures on the existing serial path. Malformed framing still reports index file corruption. Allow GIT_TEST_PARALLEL_INDEX_EXTENSIONS to bypass only the payload threshold. Add a PTHREADS, UNTRACKED_CACHE, and SHA1 regression that compares parallel and serial status, cache-tree, and untracked-cache results and checks the extension/parallel/tree-untracked Trace2 marker. The regression unsets GIT_TEST_SPLIT_INDEX because split indexes intentionally remain on the serial path. The eligible path adds one auxiliary worker and its stack. The benchmark covers the complete series, not this patch in isolation. Signed-off-by: Taylor Blau --- read-cache.c | 117 +++++++++++++++++++++++++++++++++--- t/t7519-status-fsmonitor.sh | 39 ++++++++++++ 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/read-cache.c b/read-cache.c index 76941114ba8498..5558c83b01e092 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1994,23 +1994,104 @@ struct load_index_extensions const char *mmap; size_t mmap_size; unsigned long src_offset; + int allow_parallel; + int force_parallel; }; +struct load_index_extension { + pthread_t pthread; + struct index_state *istate; + const char *ext; + const char *data; + unsigned long size; + int result; +}; + +#define PARALLEL_INDEX_EXTENSION_THRESHOLD (1024 * 1024) + +static void *load_one_index_extension(void *_data) +{ + struct load_index_extension *p = _data; + + trace2_thread_start("index-extension"); + trace2_data_intmax("index", p->istate->repo, + "extension/parallel/tree-untracked", 1); + p->result = read_index_extension(p->istate, p->ext, p->data, p->size); + trace2_thread_exit(); + return NULL; +} + +/* + * TREE and UNTR are usually the two largest index extensions. They read the + * same immutable mmap but publish to separate index_state fields, so they can + * be decoded concurrently. Keep split indexes on the established serial + * path because LINK changes how the completed index is assembled. + */ +static int find_parallel_index_extensions(struct load_index_extensions *p, + struct load_index_extension *tree) +{ + size_t offset = p->src_offset; + size_t end = p->mmap_size - the_hash_algo->rawsz; + int tree_nr = 0, untracked_nr = 0, link_nr = 0; + uint32_t untracked_size = 0; + + if (!p->allow_parallel) + return 0; + + while (offset <= end - 8) { + const char *ext = p->mmap + offset; + uint32_t size = get_be32(ext + 4); + + if (size > end - offset - 8) + return 0; + + switch (CACHE_EXT(ext)) { + case CACHE_EXT_TREE: + tree_nr++; + tree->istate = p->istate; + tree->ext = ext; + tree->data = ext + 8; + tree->size = size; + break; + case CACHE_EXT_UNTRACKED: + untracked_nr++; + untracked_size = size; + break; + case CACHE_EXT_LINK: + link_nr++; + break; + } + + offset += 8 + size; + } + + return offset == end && tree_nr == 1 && untracked_nr == 1 && !link_nr && + (p->force_parallel || + (tree->size >= PARALLEL_INDEX_EXTENSION_THRESHOLD && + untracked_size >= PARALLEL_INDEX_EXTENSION_THRESHOLD)); +} + static void *load_index_extensions(void *_data) { struct load_index_extensions *p = _data; size_t src_offset = p->src_offset; size_t end; + struct load_index_extension tree = { 0 }; + int tree_thread = 0; int extension_error = 0; if (p->mmap_size < the_hash_algo->rawsz) { extension_error = 1; - goto done; + goto join_tree; } end = p->mmap_size - the_hash_algo->rawsz; if (src_offset > end) { extension_error = 1; - goto done; + goto join_tree; + } + if (find_parallel_index_extensions(p, &tree) && + !pthread_create(&tree.pthread, NULL, load_one_index_extension, &tree)) { + tree_thread = 1; } while (src_offset < end) { @@ -2021,20 +2102,19 @@ static void *load_index_extensions(void *_data) * in 4-byte network byte order. */ uint32_t extsize; + const char *ext = p->mmap + src_offset; if (end - src_offset < 8) { extension_error = 1; break; } - extsize = get_be32(p->mmap + src_offset + 4); + extsize = get_be32(ext + 4); if (extsize > end - src_offset - 8) { extension_error = 1; break; } - if (read_index_extension(p->istate, - p->mmap + src_offset, - p->mmap + src_offset + 8, - extsize) < 0) { + if ((!tree_thread || CACHE_EXT(ext) != CACHE_EXT_TREE) && + read_index_extension(p->istate, ext, ext + 8, extsize) < 0) { extension_error = 1; break; } @@ -2043,11 +2123,22 @@ static void *load_index_extensions(void *_data) if (src_offset != end) extension_error = 1; -done: +join_tree: + if (tree_thread) { + int err = pthread_join(tree.pthread, NULL); + + if (err) + die(_("unable to join load_index_extension thread: %s"), + strerror(err)); + if (tree.result < 0) + extension_error = 1; + } + if (extension_error) { munmap((void *)p->mmap, p->mmap_size); die(_("index file corrupt")); } + return NULL; } @@ -2279,6 +2370,8 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) p.istate = istate; p.mmap = mmap; p.mmap_size = mmap_size; + p.allow_parallel = 0; + p.force_parallel = git_env_bool("GIT_TEST_PARALLEL_INDEX_EXTENSIONS", 0); src_offset = sizeof(*hdr); @@ -2300,13 +2393,21 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) extension_offset = read_eoie_extension(mmap, mmap_size); if (extension_offset) { int err; + struct load_index_extension tree = { 0 }; p.src_offset = extension_offset; + /* Keep at least two workers available for cache entries. */ + p.allow_parallel = nr_threads > 3; + if (p.allow_parallel) + p.allow_parallel = + find_parallel_index_extensions(&p, &tree); err = pthread_create(&p.pthread, NULL, load_index_extensions, &p); if (err) die(_("unable to create load_index_extensions thread: %s"), strerror(err)); nr_threads--; + if (p.allow_parallel) + nr_threads--; } } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 2e90955b52c374..273cda0a0f6bc8 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -502,4 +502,43 @@ test_expect_success 'status succeeds with sparse index' ' ) ' +test_expect_success PTHREADS,UNTRACKED_CACHE,SHA1 'load cache-tree and untracked-cache extensions in parallel' ' + test_create_repo parallel-extensions && + ( + cd parallel-extensions && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir dir && + echo tracked >dir/tracked && + git config index.threads 4 && + git add dir/tracked && + git commit -m initial && + git config core.untrackedCache true && + git status --porcelain >/dev/null && + echo modified >>dir/tracked && + echo untracked >dir/untracked && + GIT_TEST_INDEX_THREADS=1 \ + git --no-optional-locks status --porcelain >"$TRASH_DIRECTORY/parallel-serial.status" && + GIT_TEST_INDEX_THREADS=1 \ + test-tool dump-cache-tree >"$TRASH_DIRECTORY/parallel-serial.tree" && + GIT_TEST_INDEX_THREADS=1 \ + test-tool dump-untracked-cache >"$TRASH_DIRECTORY/parallel-serial.untracked" && + GIT_TEST_INDEX_THREADS=4 \ + GIT_TEST_PARALLEL_INDEX_EXTENSIONS=1 \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/parallel-extensions.trace" \ + git --no-optional-locks status --porcelain >"$TRASH_DIRECTORY/parallel-parallel.status" && + GIT_TEST_INDEX_THREADS=4 GIT_TEST_PARALLEL_INDEX_EXTENSIONS=1 \ + test-tool dump-cache-tree >"$TRASH_DIRECTORY/parallel-parallel.tree" && + GIT_TEST_INDEX_THREADS=4 GIT_TEST_PARALLEL_INDEX_EXTENSIONS=1 \ + test-tool dump-untracked-cache >"$TRASH_DIRECTORY/parallel-parallel.untracked" && + test_grep "extension/parallel/tree-untracked" \ + "$TRASH_DIRECTORY/parallel-extensions.trace" && + test_cmp "$TRASH_DIRECTORY/parallel-serial.status" \ + "$TRASH_DIRECTORY/parallel-parallel.status" && + test_cmp "$TRASH_DIRECTORY/parallel-serial.tree" \ + "$TRASH_DIRECTORY/parallel-parallel.tree" && + test_cmp "$TRASH_DIRECTORY/parallel-serial.untracked" \ + "$TRASH_DIRECTORY/parallel-parallel.untracked" + ) +' + test_done From 36a5a94cc22d88b175970f42d6d6cb091c8efef8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:51:15 -0500 Subject: [PATCH 116/403] status: refresh verified writable bulk-preload entries Bulk preload defers metadata-mismatched entries to run_diff_files() for a content check. When writable status confirms that such an entry is clean, it still leaves old stat data in the index. The next status must therefore repeat a content check already known to match. Request DIFF_UPDATE_INDEX_STAT only when status holds the index lock and bulk preload covers every indexed entry. After a real stat and a successful content and mode check, refresh only entries marked CE_CONTENT_CHECK_REQUIRED. Build the replacement with the helper from S15/P01 and install it with replace_index_entry(), preserving existing CE_VALID and index-change handling. Read-only status, incomplete bulk scans, dirty entries, and ordinary diff callers keep their existing behavior. The APFS regression compares both status output and the written index with ordinary status, and requires one bulk content check with no refresh-time lstat. Signed-off-by: Taylor Blau --- builtin/commit.c | 4 ++++ diff-lib.c | 10 ++++++++-- diff.h | 2 ++ read-cache-ll.h | 3 +++ read-cache.c | 7 +++++++ t/t7529-preload-index-apfs.sh | 30 ++++++++++++++++++++++++++++++ wt-status.c | 3 ++- wt-status.h | 1 + 8 files changed, 57 insertions(+), 3 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index fa64ba01f2a5e7..be04f9a6943590 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1638,6 +1638,10 @@ struct repository *repo UNUSED) fd = repo_hold_locked_index(the_repository, &index_lock, 0); else fd = -1; + s.bulk_update_index_stat = + 0 <= fd && + the_repository->index->preload_bulk_tracked_nr == + the_repository->index->cache_nr; s.is_initial = repo_get_oid(the_repository, s.reference, &oid) ? 1 : 0; if (!s.is_initial) diff --git a/diff-lib.c b/diff-lib.c index 33b4233738cac3..8f8aba3038a8a6 100644 --- a/diff-lib.c +++ b/diff-lib.c @@ -132,6 +132,8 @@ void run_diff_files(struct rev_info *revs, unsigned int option) unsigned int oldmode, newmode; int fsmonitor_valid = 0; struct cache_entry *ce = istate->cache[i]; + struct stat st; + int has_stat = 0; int changed; unsigned dirty_submodule = 0; const struct object_id *old_oid, *new_oid; @@ -253,8 +255,6 @@ void run_diff_files(struct rev_info *revs, unsigned int option) fsmonitor_valid = !!(ce->ce_flags & CE_FSMONITOR_VALID); } else { - struct stat st; - changed = check_removed(ce, &st); if (changed) { if (changed < 0) { @@ -276,11 +276,17 @@ void run_diff_files(struct rev_info *revs, unsigned int option) changed = match_stat_with_submodule(&revs->diffopt, ce, &st, ce_option, &dirty_submodule); + has_stat = 1; newmode = ce_mode_from_stat(revs->repo, ce, st.st_mode); fsmonitor_valid = fsmonitor_stat_can_be_valid(&st); } if (!changed && !dirty_submodule) { + if ((option & DIFF_UPDATE_INDEX_STAT) && has_stat && + (ce->ce_flags & CE_CONTENT_CHECK_REQUIRED)) { + refresh_index_entry_stat(istate, i, &st); + ce = istate->cache[i]; + } ce_mark_uptodate(ce); if (fsmonitor_valid) mark_fsmonitor_valid(istate, ce); diff --git a/diff.h b/diff.h index bb5cddaf3499e9..eb81289415f8f3 100644 --- a/diff.h +++ b/diff.h @@ -698,6 +698,8 @@ void diff_get_merge_base(const struct rev_info *revs, struct object_id *mb); #define DIFF_SILENT_ON_REMOVED 01 /* report racily-clean paths as modified */ #define DIFF_RACY_IS_MODIFIED 02 +/* update index stat data for content-checked entries */ +#define DIFF_UPDATE_INDEX_STAT 04 void run_diff_files(struct rev_info *revs, unsigned int option); #define DIFF_INDEX_CACHED 01 diff --git a/read-cache-ll.h b/read-cache-ll.h index 99b95d8e8d5325..84d5d1a015b2b9 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -497,6 +497,9 @@ int repo_refresh_and_write_index(struct repository*, unsigned int refresh_flags, struct cache_entry *refresh_cache_entry(struct index_state *, struct cache_entry *, unsigned int); +/* The caller must first verify the entry's content and mode against st. */ +void refresh_index_entry_stat(struct index_state *, int, struct stat *); + void set_alternate_index_output(const char *); extern int verify_index_checksum; diff --git a/read-cache.c b/read-cache.c index 088f83559a6b56..8c61bc7b7253e9 100644 --- a/read-cache.c +++ b/read-cache.c @@ -222,6 +222,13 @@ static struct cache_entry *make_refreshed_cache_entry( return updated; } +void refresh_index_entry_stat(struct index_state *istate, int nr, + struct stat *st) +{ + replace_index_entry(istate, nr, make_refreshed_cache_entry( + istate, istate->cache[nr], st, 1)); +} + static unsigned int st_mode_from_ce(const struct cache_entry *ce) { switch (ce->ce_mode & S_IFMT) { diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index c7c399045f2e1e..46c265196b8881 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -59,6 +59,22 @@ bulk_status () { git -C "$repo" status --porcelain=v2 >"$output" } +writable_ordinary_status () { + git -C "$1" -c core.preloadIndex=false \ + status --porcelain=v2 >"$2" +} + +writable_bulk_status () { + repo=$1 && + output=$2 && + writable_trace=$TRASH_DIRECTORY/$3 && + rm -f "$writable_trace" && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$writable_trace" \ + git -C "$repo" status --porcelain=v2 >"$output" +} + check_data () { test_trace2_data index "$2" "$3" <"$TRASH_DIRECTORY/$1" } @@ -241,6 +257,20 @@ test_expect_success PIPE \ check_data tracked-types.trace refresh/sum_lstat 2 ' +test_expect_success 'writable status retains refreshed stat data' ' + setup_repo writable-stat && + test-tool chmtime +60 writable-stat/root && + cp writable-stat/.git/index before.index && + writable_ordinary_status writable-stat expect && + cp writable-stat/.git/index ordinary.index && + cp before.index writable-stat/.git/index && + writable_bulk_status writable-stat actual writable-stat.trace && + test_cmp expect actual && + test_cmp ordinary.index writable-stat/.git/index && + check_data writable-stat.trace preload/bulk_content_check 1 && + check_data writable-stat.trace refresh/sum_lstat 0 +' + test_expect_success CASE_INSENSITIVE_FS \ 'case aliases retain parallel preload' ' setup_repo case-alias && diff --git a/wt-status.c b/wt-status.c index 7ea206bdc9609b..f9734d63c75494 100644 --- a/wt-status.c +++ b/wt-status.c @@ -723,7 +723,8 @@ static void wt_status_collect_changes_worktree(struct wt_status *s) rev.diffopt.rename_limit = s->rename_limit >= 0 ? s->rename_limit : rev.diffopt.rename_limit; rev.diffopt.rename_score = s->rename_score >= 0 ? s->rename_score : rev.diffopt.rename_score; copy_pathspec(&rev.prune_data, &s->pathspec); - run_diff_files(&rev, 0); + run_diff_files(&rev, s->bulk_update_index_stat ? + DIFF_UPDATE_INDEX_STAT : 0); wt_status_release_preload_changes(direct, direct_nr); release_revisions(&rev); } diff --git a/wt-status.h b/wt-status.h index 34beac22576fc9..e5cdc803f885e4 100644 --- a/wt-status.h +++ b/wt-status.h @@ -140,6 +140,7 @@ struct wt_status { int committable; int workdir_dirty; unsigned untracked_from_token_closure : 1; + unsigned bulk_update_index_stat : 1; const char *index_file; FILE *fp; const char *prefix; From d067582291e88e0ea59ac72dc52518b8d0d8fe55 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:57 -0500 Subject: [PATCH 117/403] status: reuse closed proofs for scoped queries A root-wide clean proof also proves that a literal scoped status is clean, but scoped status must not create a new root-wide proof. Reuse an existing clean sidecar and render the current branch and HEAD state. Dirty scoped status bypasses the untracked cache because directory traversal disables it for non-empty pathspecs. Reuse the selected cached subtree after checking its builtin fsmonitor token, directory flags, exclusion identities, and expanded index. An fsmonitor event below a directory containing tracked entries cannot make that directory an untracked collapsed parent. Keep its cached ancestors valid and mark their recursive proofs stale. For an ordinary file event, retain the authenticated directory contents and reconcile only that path against its ignore rules instead of reopening the entire directory. Recompute proofs only along the affected ancestor path. Accept a legacy exclude identity containing the parser's synthetic newline when the actual file still matches the indexed blob. Otherwise the next root status invalidates and reopens its entire cached tree. Preserve ordinary invalidation for index additions and removals. Fall back to ordinary traversal for directories, changed exclusions, complex pathspecs, sparse indexes, and unsupported directories. --- builtin/commit.c | 12 +- dir.c | 333 ++++++++++++++++++++++++++++++-- dir.h | 7 + t/t7530-status-clean-sidecar.sh | 101 +++++++++- wt-status.c | 175 ++++++++++++++++- 5 files changed, 598 insertions(+), 30 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index a700085df5e4d1..0c38b9c1d32fbb 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1651,6 +1651,7 @@ struct repository *repo UNUSED) !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); int exact_clean_query; int normal_clean_query; + int scoped_clean_query; int normal_has_head; struct object_id oid; static struct option builtin_status_options[] = { @@ -1750,11 +1751,18 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; + scoped_clean_query = s.pathspec.nr && + status_format == STATUS_FORMAT_NONE && + !s.show_branch && !s.show_stash && !s.show_ignored_mode && + !s.null_termination && !s.verbose && !s.submodule_summary && + s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !repo_config_values(the_repository)->apply_sparse_checkout && + !repo_get_oid(the_repository, s.reference, &oid); clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; - if ((exact_clean_query || normal_clean_query) && + if ((exact_clean_query || normal_clean_query || scoped_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { - if (!normal_clean_query || + if (exact_clean_query || print_normal_clean_sidecar(&s, prefix)) { wt_status_collect_free_buffers(&s); return 0; diff --git a/dir.c b/dir.c index 4645f4a42a911c..e2069134249857 100644 --- a/dir.c +++ b/dir.c @@ -211,7 +211,8 @@ static int exclude_path_matches_fd(const char *path, static int cached_exclude_file_matches( const struct git_hash_algo *algo, const char *path, const struct object_id *cached_oid, - struct object_id *raw_oid_out, unsigned int *mode_out) + struct object_id *raw_oid_out, struct object_id *normalized_oid_out, + unsigned int *mode_out) { struct object_id raw_oid, normalized_oid; struct stat st, st_after; @@ -242,14 +243,17 @@ static int cached_exclude_file_matches( hash_object_file(algo, buf, size, OBJ_BLOB, &raw_oid); if (raw_oid_out) oidcpy(raw_oid_out, &raw_oid); - if (oideq(&raw_oid, cached_oid)) { + if (oideq(&raw_oid, cached_oid) && !normalized_oid_out) { ret = 1; goto out; } buf[size] = '\n'; hash_object_file(algo, buf, size + 1, OBJ_BLOB, &normalized_oid); - ret = oideq(&normalized_oid, cached_oid); + if (normalized_oid_out) + oidcpy(normalized_oid_out, &normalized_oid); + ret = oideq(&raw_oid, cached_oid) || + oideq(&normalized_oid, cached_oid); out: free(buf); out_close: @@ -481,11 +485,11 @@ static void *preload_untracked_cache_thread(void *_data) strbuf_release(&exclude_path); continue; } - task->exclude_matches = cached_exclude_file_matches( + task->exclude_matches = cached_exclude_file_matches( preload->repo->hash_algo, exclude_path.buf, &task->exclude_oid, &raw_oid, - &task->exclude_mode); + NULL, &task->exclude_mode); if (task->exclude_matches && task->exclude_index_present && oideq(&preload->exclude_index_oids[i], @@ -527,7 +531,7 @@ static void *preload_untracked_cache_thread(void *_data) strbuf_addstr(&exclude_path, preload->exclude_per_dir); task->exclude_matches = cached_exclude_file_matches( preload->repo->hash_algo, exclude_path.buf, - &task->exclude_oid, NULL, NULL); + &task->exclude_oid, NULL, NULL, NULL); strbuf_release(&exclude_path); } return NULL; @@ -598,13 +602,18 @@ static int compute_untracked_cache_fsmonitor_valid_recursive( struct untracked_cache_dir *ucd) { size_t i; - int valid = ucd->valid; + int valid = ucd->valid && !ucd->fsmonitor_dirty; + int has_untracked = !!ucd->untracked_nr; - for (i = 0; i < ucd->dirs_nr; i++) + for (i = 0; i < ucd->dirs_nr; i++) { if (!compute_untracked_cache_fsmonitor_valid_recursive( ucd->dirs[i])) valid = 0; + if (ucd->dirs[i]->recurse && ucd->dirs[i]->has_untracked) + has_untracked = 1; + } ucd->valid_recursive = valid; + ucd->has_untracked = has_untracked; return valid; } @@ -1967,6 +1976,7 @@ static void do_invalidate_gitignore(struct untracked_cache_dir *dir) int i; dir->valid = 0; dir->valid_recursive = 0; + dir->fsmonitor_dirty = 0; dir->has_untracked = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); @@ -2007,6 +2017,7 @@ static void invalidate_directory(struct untracked_cache *uc, dir->valid = 0; dir->valid_recursive = 0; + dir->fsmonitor_dirty = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); dir->untracked_nr = 0; @@ -2729,7 +2740,19 @@ static void prep_exclude(struct dir_struct *dir, */ if (untracked && !oideq(&oid_stat.oid, &untracked->exclude_oid)) { - invalidate_gitignore(dir->untracked, untracked); + struct object_id raw_oid, normalized_oid; + int compatible; + + /* Older caches include the parser's synthetic final LF. */ + compatible = oid_stat.valid && + cached_exclude_file_matches(the_hash_algo, pl->src, + &untracked->exclude_oid, + &raw_oid, &normalized_oid, + NULL) && + (oideq(&raw_oid, &oid_stat.oid) || + oideq(&normalized_oid, &oid_stat.oid)); + if (!compatible) + invalidate_gitignore(dir->untracked, untracked); oidcpy(&untracked->exclude_oid, &oid_stat.oid); } dir->internal.exclude_stack = stk; @@ -3474,6 +3497,88 @@ static void add_untracked(struct untracked_cache_dir *dir, const char *name) dir->has_untracked = 1; } +static int refresh_cached_fsmonitor_files( + struct dir_struct *dir, + struct index_state *istate, + struct untracked_cache_dir *untracked, + struct strbuf *directory) +{ + struct untracked_cache *uc = dir->untracked; + struct strbuf path = STRBUF_INIT; + const char *event, *end; + size_t base_len, refreshed = 0; + int valid; + + if (!uc || !untracked->valid || !untracked->fsmonitor_dirty || + !uc->fsmonitor_dirty_paths.len) + return 0; + + strbuf_addbuf(&path, directory); + strbuf_complete(&path, '/'); + base_len = path.len; + event = uc->fsmonitor_dirty_paths.buf; + end = event + uc->fsmonitor_dirty_paths.len; + while (event < end) { + struct cached_dir cdir = { 0 }; + enum path_treatment state; + const char *name; + size_t i; + + if (strncmp(event, path.buf, base_len)) + goto next; + name = event + base_len; + if (!*name || strchr(name, '/')) + goto next; + + for (i = 0; i < untracked->untracked_nr; i++) { + if (strcmp(untracked->untracked[i], name)) + continue; + free(untracked->untracked[i]); + MOVE_ARRAY(untracked->untracked + i, + untracked->untracked + i + 1, + untracked->untracked_nr - i - 1); + untracked->untracked_nr--; + break; + } + + cdir.d_name = name; + cdir.d_type = DT_UNKNOWN; + state = treat_path(dir, untracked, &cdir, istate, &path, + base_len, NULL); + dir->internal.visited_paths++; + if (state == path_recurse) { + strbuf_release(&path); + return 0; + } + if (state == path_untracked) + add_untracked(untracked, name); + refreshed++; + +next: + event += strlen(event) + 1; + strbuf_setlen(&path, base_len); + } + strbuf_release(&path); + if (!refreshed || !untracked->valid) + return 0; + + untracked->fsmonitor_dirty = 0; + untracked->has_untracked = !!untracked->untracked_nr; + valid = untracked->valid; + for (size_t i = 0; i < untracked->dirs_nr; i++) { + struct untracked_cache_dir *child = untracked->dirs[i]; + + if (!child->valid_recursive) + valid = 0; + if (child->recurse && child->has_untracked) + untracked->has_untracked = 1; + } + untracked->valid_recursive = valid; + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/targeted-refresh", refreshed); + return 1; +} + static int valid_cached_dir(struct dir_struct *dir, struct untracked_cache_dir *untracked, struct index_state *istate, @@ -3529,7 +3634,12 @@ static int valid_cached_dir(struct dir_struct *dir, prep_exclude(dir, istate, path->buf, path->len); /* hopefully prep_exclude() haven't invalidated this entry... */ - return untracked->valid; + if (!untracked->valid) + return 0; + if (untracked->fsmonitor_dirty && + !refresh_cached_fsmonitor_files(dir, istate, untracked, path)) + return 0; + return 1; } static int open_cached_dir(struct cached_dir *cdir, @@ -4214,6 +4324,96 @@ int read_directory(struct dir_struct *dir, struct index_state *istate, return dir->nr; } +static void recompute_cached_fsmonitor_ancestors( + struct untracked_cache *uc, + const char *path, + int len) +{ + struct untracked_cache_dir **parents = NULL; + struct untracked_cache_dir *current = uc->root; + size_t nr = 0, alloc = 0; + int offset = 0; + + ALLOC_GROW(parents, nr + 1, alloc); + parents[nr++] = current; + while (offset < len) { + const char *slash; + int component_len; + + while (offset < len && path[offset] == '/') + offset++; + if (offset == len) + break; + slash = memchr(path + offset, '/', len - offset); + component_len = slash ? slash - (path + offset) : len - offset; + current = lookup_untracked(uc, current, + path + offset, component_len); + ALLOC_GROW(parents, nr + 1, alloc); + parents[nr++] = current; + offset += component_len; + } + + while (nr) { + struct untracked_cache_dir *parent = parents[--nr]; + int valid = parent->valid && !parent->fsmonitor_dirty; + int has_untracked = !!parent->untracked_nr; + + for (size_t i = 0; i < parent->dirs_nr; i++) { + struct untracked_cache_dir *child = parent->dirs[i]; + + if (!child->valid_recursive) + valid = 0; + if (child->recurse && child->has_untracked) + has_untracked = 1; + } + parent->valid_recursive = valid; + parent->has_untracked = has_untracked; + } + free(parents); +} + +int read_directory_cached_subtree(struct dir_struct *dir, + struct index_state *istate, + struct untracked_cache_dir *untracked, + const char *path, int len, + const struct pathspec *pathspec) +{ + int repaired = 0; + + if (!untracked || !dir->untracked || + dir->untracked != istate->untracked || + !dir->untracked->use_fsmonitor || + !istate->fsmonitor_untracked_valid || + has_symlink_leading_path(path, len)) + return -1; + + trace2_region_enter("dir", "read_cached_subtree", istate->repo); + dir->internal.visited_paths = 0; + dir->internal.visited_directories = 0; + if (treat_leading_path(dir, istate, path, len, pathspec)) { + if (untracked->valid && untracked->fsmonitor_dirty) { + struct strbuf directory = STRBUF_INIT; + + strbuf_add(&directory, path, len); + repaired = valid_cached_dir( + dir, untracked, istate, &directory, 0) && + untracked->valid_recursive; + strbuf_release(&directory); + } + if (!repaired) { + read_directory_recursive(dir, istate, path, len, + untracked, 0, 0, pathspec); + compute_untracked_cache_fsmonitor_valid_recursive(untracked); + } + } + QSORT(dir->entries, dir->nr, cmp_dir_entry); + QSORT(dir->ignored, dir->ignored_nr, cmp_dir_entry); + recompute_cached_fsmonitor_ancestors(dir->untracked, path, len); + emit_traversal_statistics(dir, istate->repo, path, len); + trace2_region_leave("dir", "read_cached_subtree", istate->repo); + return dir->internal.traversal_failed ? -1 : dir->nr; +} + int file_exists(const char *f) { struct stat sb; @@ -4781,6 +4981,7 @@ void free_untracked_cache(struct untracked_cache *uc) free(uc->exclude_per_dir_to_free); strbuf_release(&uc->ident); + strbuf_release(&uc->fsmonitor_dirty_paths); free_untracked(uc->root); free(uc); } @@ -5011,11 +5212,90 @@ static void invalidate_one_directory(struct untracked_cache *uc, uc->dir_invalidated++; ucd->valid = 0; ucd->valid_recursive = 0; + ucd->fsmonitor_dirty = 0; for (size_t i = 0; i < ucd->untracked_nr; i++) free(ucd->untracked[i]); ucd->untracked_nr = 0; } +static int directory_has_indexed_children( + struct index_state *istate, + const char *path, + size_t len) +{ + int pos = index_name_pos(istate, path, len); + + if (pos >= 0) + return 0; + pos = -pos - 1; + return pos < istate->cache_nr && + ce_namelen(istate->cache[pos]) > len && + istate->cache[pos]->name[len] == '/' && + !strncmp(istate->cache[pos]->name, path, len); +} + +static int record_cached_fsmonitor_file( + struct untracked_cache *uc, + struct untracked_cache_dir *dir, + struct index_state *istate, + const char *full_path, + const char *name) +{ + struct stat st; + const char *event, *end; + size_t parent_len, path_len = strlen(full_path); + int first, last; + + if (!istate->fsmonitor_untracked_valid || + istate->fsmonitor_legacy_untracked_fallback || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + !dir->valid || !dir->recurse || + dir->check_only || name == full_path || + (uc->exclude_per_dir && !strcmp(name, uc->exclude_per_dir))) + return 0; + parent_len = name - full_path - 1; + if (!directory_has_indexed_children(istate, full_path, parent_len) || + directory_has_indexed_children(istate, full_path, path_len)) + return 0; + if (lstat(full_path, &st)) { + if (!is_missing_file_error(errno)) + return 0; + } else if (!S_ISREG(st.st_mode) && !S_ISLNK(st.st_mode)) { + return 0; + } + + first = 0; + last = dir->dirs_nr; + while (last > first) { + int next = first + ((last - first) >> 1); + int compare = strcmp(name, dir->dirs[next]->name); + + if (!compare) + return 0; + if (compare < 0) + last = next; + else + first = next + 1; + } + + if (uc->fsmonitor_dirty_paths.len) { + event = uc->fsmonitor_dirty_paths.buf; + end = event + uc->fsmonitor_dirty_paths.len; + while (event < end) { + if (!strcmp(event, full_path)) + goto recorded; + event += strlen(event) + 1; + } + } + strbuf_addstr(&uc->fsmonitor_dirty_paths, full_path); + strbuf_addch(&uc->fsmonitor_dirty_paths, '\0'); + +recorded: + dir->fsmonitor_dirty = 1; + dir->valid_recursive = 0; + return 1; +} + /* * Normally when an entry is added or removed from a directory, * invalidating that directory is enough. No need to touch its @@ -5042,7 +5322,10 @@ static void invalidate_one_directory(struct untracked_cache *uc, */ static int invalidate_one_component(struct untracked_cache *uc, struct untracked_cache_dir *dir, - const char *path, int len) + struct index_state *istate, + const char *path, int len, + const char *full_path, + int allow_tracked_stop) { const char *rest = strchr(path, '/'); @@ -5051,14 +5334,30 @@ static int invalidate_one_component(struct untracked_cache *uc, struct untracked_cache_dir *d = lookup_untracked(uc, dir, path, component_len); int ret = - invalidate_one_component(uc, d, rest + 1, - len - (component_len + 1)); - if (ret) - invalidate_one_directory(uc, dir); + invalidate_one_component(uc, d, istate, rest + 1, + len - (component_len + 1), + full_path, allow_tracked_stop); + if (ret) { + size_t directory_len = rest - full_path; + if (allow_tracked_stop && uc->use_fsmonitor && + directory_has_indexed_children( + istate, full_path, directory_len)) { + dir->valid_recursive = 0; + ret = 0; + } else { + invalidate_one_directory(uc, dir); + } + } + if (!d->valid_recursive) + dir->valid_recursive = 0; return ret; } - invalidate_one_directory(uc, dir); + if (!allow_tracked_stop || + !record_cached_fsmonitor_file(uc, dir, istate, full_path, path)) + invalidate_one_directory(uc, dir); + else + return 0; return uc->dir_flags & DIR_SHOW_OTHER_DIRECTORIES; } @@ -5070,7 +5369,7 @@ void untracked_cache_invalidate_path(struct index_state *istate, if (!safe_path && !verify_path(path, 0)) return; invalidate_one_component(istate->untracked, istate->untracked->root, - path, strlen(path)); + istate, path, strlen(path), path, !safe_path); } void untracked_cache_invalidate_trimmed_path(struct index_state *istate, diff --git a/dir.h b/dir.h index de1782a3f254f6..d674df9a493a18 100644 --- a/dir.h +++ b/dir.h @@ -190,6 +190,7 @@ struct untracked_cache_dir { unsigned int stat_matches : 1; unsigned int exclude_matches : 1; unsigned int valid_recursive : 1; + unsigned int fsmonitor_dirty : 1; /* * A null object ID means this directory does not have .gitignore. * The empty-tree ID records a present source that could not be read. @@ -215,6 +216,7 @@ struct untracked_cache { int gitignore_invalidated; int dir_invalidated; int dir_opened; + struct strbuf fsmonitor_dirty_paths; /* fsmonitor invalidation data */ unsigned int use_fsmonitor : 1; }; @@ -420,6 +422,11 @@ int fill_directory(struct dir_struct *dir, int read_directory(struct dir_struct *, struct index_state *istate, const char *path, int len, const struct pathspec *pathspec); +int read_directory_cached_subtree(struct dir_struct *, + struct index_state *istate, + struct untracked_cache_dir *untracked, + const char *path, int len, + const struct pathspec *pathspec); enum pattern_match_result { UNDECIDED = -1, diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index b7ab718e009cc6..9a59a18ec4f3e6 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -313,7 +313,7 @@ test_expect_success DURABLE_FSMONITOR \ bulk_status -C external-pathspec-status \ status --porcelain=v2 -- scoped >external-pathspec-status.first && test_grep "^? scoped/new$" external-pathspec-status.first && - ! test_grep "tracked\|outside-new" external-pathspec-status.first && + test_grep ! "tracked\|outside-new" external-pathspec-status.first && test_path_is_missing external-pathspec-status/.git/index.csts && cp external-pathspec-status/.git/index \ external-pathspec-status.before && @@ -335,6 +335,105 @@ test_expect_success DURABLE_FSMONITOR \ test_grep "^1 \.M .* tracked$" external-pathspec-status.root ' +test_expect_success DURABLE_FSMONITOR \ + 'clean pathspec status reuses an existing root-wide clean proof' ' + test_when_finished "stop_daemon clean-pathspec-status" && + setup_repo clean-pathspec-status && + mkdir clean-pathspec-status/scoped && + test_commit -C clean-pathspec-status scoped scoped/tracked && + test-tool -C clean-pathspec-status chmtime -120 \ + tracked scoped/tracked && + git -C clean-pathspec-status update-index --refresh && + git -C clean-pathspec-status config core.untrackedCache true && + issue_sidecar clean-pathspec-status && + cp clean-pathspec-status/.git/index clean-pathspec-status.before && + git -C clean-pathspec-status status >clean-pathspec-status.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/clean-pathspec-status.trace" \ + git -C clean-pathspec-status status -- scoped \ + >clean-pathspec-status.actual && + test_cmp clean-pathspec-status.expect clean-pathspec-status.actual && + test_cmp clean-pathspec-status.before clean-pathspec-status/.git/index && + test_trace2_data status clean-proof/hit 1 \ + clean-pathspec-nested.actual && + test_cmp clean-pathspec-status.expect clean-pathspec-nested.actual && + test_trace2_data status clean-proof/hit 1 \ + clean-pathspec-status/tracked && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/clean-pathspec-outside.trace" \ + git -C clean-pathspec-status status -- scoped \ + >clean-pathspec-outside.actual && + test_grep "nothing to commit, working tree clean" \ + clean-pathspec-outside.actual && + test_grep ! "\"key\":\"clean-proof/hit\"" \ + clean-pathspec-outside.trace && + git -C clean-pathspec-status status --porcelain=v2 \ + >clean-pathspec-outside.root && + test_grep "^1 \.M .* tracked$" clean-pathspec-outside.root && + + test_write_lines selected >clean-pathspec-status/scoped/new && + mkdir clean-pathspec-status/scoped/newdir && + test_write_lines nested >clean-pathspec-status/scoped/newdir/file && + test_write_lines outside >clean-pathspec-status/outside-new && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/clean-pathspec-selected.trace" \ + git -C clean-pathspec-status status -- scoped \ + >clean-pathspec-selected.actual && + test_grep "scoped/new" clean-pathspec-selected.actual && + test_grep "scoped/newdir/" clean-pathspec-selected.actual && + test_grep ! "outside-new" clean-pathspec-selected.actual && + test_grep ! "\"key\":\"clean-proof/hit\"" \ + clean-pathspec-selected.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'tracked-directory pathspec reuses a valid untracked-cache subtree' ' + test_when_finished "stop_daemon cached-pathspec-status" && + setup_repo cached-pathspec-status && + mkdir cached-pathspec-status/scoped && + test_commit -C cached-pathspec-status scoped scoped/tracked && + test-tool -C cached-pathspec-status chmtime -120 \ + tracked scoped/tracked && + git -C cached-pathspec-status update-index --refresh && + git -C cached-pathspec-status config core.untrackedCache true && + test_write_lines selected >cached-pathspec-status/scoped/new && + test_write_lines outside >cached-pathspec-status/outside-new && + git -C cached-pathspec-status status >cached-pathspec-status.root && + git -C cached-pathspec-status status >/dev/null && + cp cached-pathspec-status/.git/index cached-pathspec-status.before && + GIT_TRACE2_EVENT="$PWD/cached-pathspec-status.trace" \ + git -C cached-pathspec-status status -- scoped \ + >cached-pathspec-status.actual && + test_grep "scoped/new" cached-pathspec-status.actual && + test_grep ! "outside-new" cached-pathspec-status.actual && + test_cmp cached-pathspec-status.before \ + cached-pathspec-status/.git/index && + test_trace2_data status untracked/pathspec-cache 1 \ + cached-pathspec-nested.actual && + test_grep "new" cached-pathspec-nested.actual && + test_grep ! "outside-new" cached-pathspec-nested.actual && + test_trace2_data status untracked/pathspec-cache 1 \ + dirs_nr; i++) { + struct untracked_cache_dir *candidate = dir->dirs[i]; + + if (strlen(candidate->name) == component_len && + !strncmp(candidate->name, path, component_len)) { + child = candidate; + break; + } + } + if (!child || !child->recurse || child->check_only) + return NULL; + dir = child; + path += component_len; + if (path < end) + path++; + } + return dir; +} + +static void wt_status_collect_cached_directory( + const struct untracked_cache_dir *dir, + struct strbuf *path, + struct index_state *istate, + const struct pathspec *pathspec, + struct string_list *untracked) +{ + size_t base_len = path->len; + + if (!dir->has_untracked) + return; + for (size_t i = 0; i < dir->untracked_nr; i++) { + const char *name = dir->untracked[i]; + + strbuf_setlen(path, base_len); + strbuf_addstr(path, name); + if (index_name_is_other(istate, path->buf, path->len) && + match_pathspec(istate, pathspec, + path->buf, path->len, 0, NULL, + path->len && path->buf[path->len - 1] == '/')) + string_list_append(untracked, path->buf); + } + for (size_t i = 0; i < dir->dirs_nr; i++) { + const struct untracked_cache_dir *child = dir->dirs[i]; + + if (!child->recurse || child->check_only || + !child->has_untracked) + continue; + strbuf_setlen(path, base_len); + strbuf_addstr(path, child->name); + strbuf_addch(path, '/'); + wt_status_collect_cached_directory( + child, path, istate, pathspec, untracked); + } + strbuf_setlen(path, base_len); +} + +static int wt_status_collect_cached_pathspec( + struct wt_status *s, + struct dir_struct *dir, + struct string_list *untracked) +{ + struct index_state *istate = s->repo->index; + struct untracked_cache *uc = istate->untracked; + const struct pathspec_item *item; + const struct cache_entry *ce; + struct untracked_cache_dir *selected; + struct strbuf path = STRBUF_INIT; + size_t len; + int pos; + + if (s->pathspec.nr != 1 || s->pathspec.has_wildcard || + (s->pathspec.magic & ~(PATHSPEC_FROMTOP | PATHSPEC_LITERAL)) || + s->show_ignored_mode || + s->show_untracked_files != SHOW_NORMAL_UNTRACKED_FILES || + istate->sparse_index != INDEX_EXPANDED || + !istate->fsmonitor_untracked_valid || + fsmonitor_has_pending_token(istate) || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || + !uc || !uc->root || !uc->use_fsmonitor || + dir->untracked != uc || dir->flags != uc->dir_flags || + dir->internal.unmanaged_exclude_files || + dir->internal.exclude_list_group[EXC_CMDL].nr || + !oideq(&dir->internal.ss_info_exclude.oid, + &uc->ss_info_exclude.oid) || + !oideq(&dir->internal.ss_excludes_file.oid, + &uc->ss_excludes_file.oid)) + return 0; + + item = &s->pathspec.items[0]; + if (item->nowildcard_len != item->len) + return 0; + len = item->len; + if (len && item->match[len - 1] == '/') + len--; + if (!len) + return 0; + + pos = index_name_pos(istate, item->match, len); + if (pos >= 0) + return 0; + pos = -pos - 1; + if (pos >= istate->cache_nr) + return 0; + ce = istate->cache[pos]; + if (ce_namelen(ce) <= len || ce->name[len] != '/' || + strncmp(ce->name, item->match, len)) + return 0; + + selected = wt_status_find_cached_directory( + uc->root, item->match, len); + if (!selected) + return 0; + + strbuf_add(&path, item->match, len); + strbuf_addch(&path, '/'); + if (!uc->root->valid || !selected->valid || + !selected->valid_recursive) { + if (read_directory_cached_subtree( + dir, istate, selected, path.buf, path.len, + &s->pathspec) < 0) { + strbuf_release(&path); + return 0; + } + trace2_data_intmax("status", s->repo, + "untracked/pathspec-refreshed", 1); + } + wt_status_collect_cached_directory( + selected, &path, istate, &s->pathspec, untracked); + strbuf_release(&path); + trace2_data_intmax("status", s->repo, + "untracked/pathspec-cache", 1); + return 1; +} + static int wt_status_collect_untracked_1( struct wt_status *s, struct string_list *untracked, @@ -1182,16 +1333,20 @@ static int wt_status_collect_untracked_1( dir.internal.untracked_cache_preloaded = s->untracked_cache_preloaded; - fill_directory(&dir, istate, &s->pathspec); - if (s->certify_clean_status && dir.internal.traversal_failed) - s->certify_untracked_scan_failed = 1; - used_untracked_cache = dir.untracked && - dir.untracked == istate->untracked; - - for (i = 0; i < dir.nr; i++) { - struct dir_entry *ent = dir.entries[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(untracked, ent->name); + if (wt_status_collect_cached_pathspec(s, &dir, untracked)) { + used_untracked_cache = 1; + } else { + fill_directory(&dir, istate, &s->pathspec); + if (s->certify_clean_status && dir.internal.traversal_failed) + s->certify_untracked_scan_failed = 1; + used_untracked_cache = dir.untracked && + dir.untracked == istate->untracked; + + for (i = 0; i < dir.nr; i++) { + struct dir_entry *ent = dir.entries[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(untracked, ent->name); + } } string_list_sort_u(untracked, 0); From cd5e01d87a0107829ff7ab64a8dbd8c6f27773b7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:48:46 -0500 Subject: [PATCH 118/403] fsmonitor: preserve authenticated legacy daemon history Bound fsmonitor queries prevent a shared Git directory from borrowing events from another worktree. An older daemon cannot interpret those queries, though, and replacing it discards the index token and all existing event history. Concurrent legacy clients can also recreate the socket before the replacement observes the original daemon exit. Authenticate a legacy Unix-socket peer against its effective user and watched worktree before replaying the existing token. Verify the daemon's open root on macOS and its root inotify watch on Linux. Cache successful checks under the canonical root, peer identity and start time, and socket generation so large Linux watch lists are read once. Track socket generations when an incompatible daemon must be replaced. The untracked-cache identity also changed between versions, causing add_untracked_cache() to discard the legacy directory tree before the daemon can be authenticated. Retain only the matching older identity until a nontrivial response proves the daemon watches this worktree; then upgrade the identity, preserve invalid directory frontiers, and close the existing forward-baseline proof. Keep ordinary invalidation for unmatched roots, missing tokens, weak stat settings, and failed authentication. Linux system Git without daemon support instead writes the placeholder token "builtin:fake" while retaining the legacy directory tree. That token is not a usable event boundary. When an authenticated daemon returns a full invalidation for it, preserve the old cache identity and validate tracked entries and directory timestamps normally. Avoid semantic fast paths, private FSUC/FSCF index extensions, and optional rewrites of an otherwise unchanged shared index during this fallback. Continue accepting unbound token requests from older clients once a bound-aware daemon is running. Mixed-version clients can therefore share one correctly identified daemon without restart loops or whole-worktree cache rebuilds. --- builtin/commit.c | 6 +- builtin/fsmonitor--daemon.c | 3 +- dir.c | 54 ++++- dir.h | 2 + fsmonitor-ipc.c | 372 ++++++++++++++++++++++++++++--- fsmonitor-ipc.h | 3 +- fsmonitor.c | 79 ++++++- fsmonitor.h | 1 + read-cache-ll.h | 2 + read-cache.c | 4 +- t/helper/test-fsmonitor-client.c | 4 +- t/t7527-builtin-fsmonitor.sh | 31 +++ wt-status.c | 37 +-- 13 files changed, 549 insertions(+), 49 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index f2737389e9c643..b27ac2e201180c 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1841,7 +1841,11 @@ struct repository *repo UNUSED) external_saved = clean_status_save_external_history( the_repository->index); - if (exact_clean_query) { + if (the_repository->index->fsmonitor_legacy_untracked_fallback && + !preserve_entry_changes && !external_saved) { + rollback_lock_file(&index_lock); + fd = -1; + } else if (exact_clean_query) { if (!preserve_entry_changes && external_saved && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 0)) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 953f68b4fc1185..65780205798554 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -710,7 +710,8 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, if (strcmp(command, "quit") && strcmp(command, "flush") && - strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { + strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND) && + !starts_with(command, "builtin:")) { const char *identity; const char *query; diff --git a/dir.c b/dir.c index e2069134249857..27f13a51569848 100644 --- a/dir.c +++ b/dir.c @@ -4043,6 +4043,19 @@ static int ident_in_untracked(const struct untracked_cache *uc) return !strcmp(uc->ident.buf, get_ident_string()); } +static int legacy_ident_in_untracked(const struct untracked_cache *uc) +{ + static const char suffix[] = ", cache version 2"; + const char *current = get_ident_string(); + size_t current_len = strlen(current); + size_t suffix_len = sizeof(suffix) - 1; + + return current_len > suffix_len && + !strcmp(current + current_len - suffix_len, suffix) && + strlen(uc->ident.buf) == current_len - suffix_len && + !memcmp(uc->ident.buf, current, current_len - suffix_len); +} + static void set_untracked_ident(struct untracked_cache *uc) { strbuf_reset(&uc->ident); @@ -4093,12 +4106,49 @@ void add_untracked_cache(struct index_state *istate) new_untracked_cache(istate, -1); } else { if (!ident_in_untracked(istate->untracked)) { + if (istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + starts_with(istate->fsmonitor_last_update, + "builtin:") && + !istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + !istate->split_index && + fsm_settings__get_mode(istate->repo) == + FSMONITOR_MODE_IPC && + legacy_ident_in_untracked(istate->untracked)) { + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-preserved", 1); + return; + } free_untracked_cache(istate->untracked); new_untracked_cache(istate, -1); } } } +int untracked_cache_adopt_legacy(struct index_state *istate) +{ + if (!istate->untracked || + !legacy_ident_in_untracked(istate->untracked)) + return 0; + set_untracked_ident(istate->untracked); + untracked_cache_recompute_fsmonitor_valid_recursive( + istate->untracked); + istate->cache_changed |= UNTRACKED_CHANGED; + return 1; +} + +void untracked_cache_discard_legacy(struct index_state *istate) +{ + if (!istate->untracked || + !legacy_ident_in_untracked(istate->untracked)) + return; + free_untracked_cache(istate->untracked); + new_untracked_cache(istate, -1); + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-discarded", 1); +} + void remove_untracked_cache(struct index_state *istate) { if (istate->untracked) { @@ -4162,7 +4212,9 @@ static struct untracked_cache_dir *validate_untracked_cache(struct dir_struct *d if (dir->internal.exclude_list_group[EXC_CMDL].nr) return NULL; - if (!ident_in_untracked(dir->untracked)) { + if (!ident_in_untracked(dir->untracked) && + !(istate->fsmonitor_legacy_untracked_fallback && + legacy_ident_in_untracked(dir->untracked))) { warning(_("untracked cache is disabled on this system or location")); return NULL; } diff --git a/dir.h b/dir.h index d674df9a493a18..c13d0db2866eaf 100644 --- a/dir.h +++ b/dir.h @@ -652,6 +652,8 @@ struct untracked_cache *read_untracked_extension(const void *data, unsigned long void write_untracked_extension(struct strbuf *out, struct untracked_cache *untracked); void add_untracked_cache(struct index_state *istate); void remove_untracked_cache(struct index_state *istate); +int untracked_cache_adopt_legacy(struct index_state *istate); +void untracked_cache_discard_legacy(struct index_state *istate); /* * Connect a worktree to a git directory by creating (or overwriting) a diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index f6eb03cfd9442f..38f3843bbbb9bb 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -16,6 +16,15 @@ #include "strbuf.h" #include "trace2.h" +#ifdef __APPLE__ +#include +#include +#endif + +#ifdef __linux__ +#include +#endif + int fsmonitor_ipc__get_worktree_identity(struct repository *r, struct strbuf *identity) { @@ -79,7 +88,8 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) } int fsmonitor_ipc__send_query(const char *since_token UNUSED, - struct strbuf *answer UNUSED) + struct strbuf *answer UNUSED, + int *legacy_worktree_authenticated UNUSED) { return -1; } @@ -251,12 +261,297 @@ static int server_supports_bound_queries(void) return ret; } -static int wait_for_daemon_exit(void) +#if defined(__APPLE__) || defined(__linux__) +static int legacy_peer_credentials( + struct ipc_client_connection *connection, pid_t *pid) +{ +#ifdef __APPLE__ + uid_t uid; + gid_t gid; + socklen_t size = sizeof(*pid); + + if (getpeereid(connection->fd, &uid, &gid) || + uid != geteuid() || + getsockopt(connection->fd, SOL_LOCAL, LOCAL_PEERPID, + pid, &size) || size != sizeof(*pid)) + return 0; +#else + struct ucred peer; + socklen_t size = sizeof(peer); + + if (getsockopt(connection->fd, SOL_SOCKET, SO_PEERCRED, + &peer, &size) || size != sizeof(peer) || + peer.uid != geteuid()) + return 0; + *pid = peer.pid; +#endif + return *pid > 0; +} + +static int legacy_peer_start_identity(pid_t pid, struct strbuf *identity) +{ +#ifdef __APPLE__ + struct proc_bsdinfo info; + + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, + &info, sizeof(info)) != sizeof(info) || + info.pbi_pid != (uint32_t)pid || + info.pbi_uid != geteuid()) + return 0; + strbuf_addf(identity, "%"PRIu64".%"PRIu64, + info.pbi_start_tvsec, info.pbi_start_tvusec); +#else + struct strbuf path = STRBUF_INIT; + struct strbuf stat = STRBUF_INIT; + const char *value, *end; + int valid = 0; + + strbuf_addf(&path, "/proc/%"PRIuMAX"/stat", (uintmax_t)pid); + if (strbuf_read_file(&stat, path.buf, 4096) < 0 || + !(value = strrchr(stat.buf, ')')) || + value[1] != ' ') + goto done; + value += 2; + for (int field = 3; field < 22; field++) { + value = strchr(value, ' '); + if (!value) + goto done; + while (*value == ' ') + value++; + } + end = strchr(value, ' '); + if (!end || end == value) + goto done; + for (const char *p = value; p < end; p++) + if (!isdigit(*p)) + goto done; + strbuf_add(identity, value, end - value); + valid = 1; +done: + strbuf_release(&path); + strbuf_release(&stat); + return valid; +#endif + return 1; +} + +#ifdef __APPLE__ +static int legacy_peer_watches_worktree( + pid_t pid, const char *worktree, const struct stat *root) +{ + struct proc_fdinfo *fds = NULL; + int size, bytes, matches = 0; + + size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0); + if (size <= 0 || size > 1024 * 1024 - + 16 * (int)sizeof(*fds)) + return 0; + size += 16 * sizeof(*fds); + fds = xmalloc(size); + bytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, size); + if (bytes < 0 || bytes % sizeof(*fds)) + goto done; + for (int i = 0; i < bytes / (int)sizeof(*fds); i++) { + struct vnode_fdinfowithpath vnode; + const struct vinfo_stat *stat; + + if (fds[i].proc_fdtype != PROX_FDTYPE_VNODE || + proc_pidfdinfo(pid, fds[i].proc_fd, + PROC_PIDFDVNODEPATHINFO, + &vnode, sizeof(vnode)) != sizeof(vnode)) + continue; + stat = &vnode.pvip.vip_vi.vi_stat; + if ((uintmax_t)stat->vst_dev == (uintmax_t)root->st_dev && + (uintmax_t)stat->vst_ino == (uintmax_t)root->st_ino && + !strcmp(vnode.pvip.vip_path, worktree)) { + matches = 1; + break; + } + } +done: + free(fds); + return matches; +} +#else +static int legacy_peer_watches_worktree( + pid_t pid, const char *worktree UNUSED, const struct stat *root) +{ + struct strbuf directory = STRBUF_INIT; + struct strbuf path = STRBUF_INIT; + struct strbuf target = STRBUF_INIT; + struct strbuf line = STRBUF_INIT; + uintmax_t device = ((uintmax_t)major(root->st_dev) << 20) | + (uintmax_t)minor(root->st_dev); + DIR *fds = NULL; + struct dirent *entry; + int matches = 0; + + strbuf_addf(&directory, "/proc/%"PRIuMAX"/fd", (uintmax_t)pid); + fds = opendir(directory.buf); + if (!fds) + goto done; + while ((entry = readdir(fds)) != NULL) { + FILE *info; + + if (!strcmp(entry->d_name, ".") || + !strcmp(entry->d_name, "..")) + continue; + strbuf_reset(&path); + strbuf_addf(&path, "%s/%s", directory.buf, entry->d_name); + strbuf_reset(&target); + if (strbuf_readlink(&target, path.buf, 32) < 0 || + strcmp(target.buf, "anon_inode:inotify")) + continue; + strbuf_reset(&path); + strbuf_addf(&path, "/proc/%"PRIuMAX"/fdinfo/%s", + (uintmax_t)pid, entry->d_name); + info = fopen(path.buf, "r"); + if (!info) + continue; + while (!strbuf_getline_lf(&line, info)) { + uintmax_t inode, source_device; + unsigned int watch; + + if (!starts_with(line.buf, "inotify wd:1 ")) + continue; + if (sscanf(line.buf, + "inotify wd:%x ino:%"SCNxMAX" sdev:%"SCNxMAX, + &watch, &inode, &source_device) == 3 && + watch == 1 && inode == (uintmax_t)root->st_ino && + source_device == device) + matches = 1; + break; + } + fclose(info); + if (matches) + break; + } +done: + if (fds) + closedir(fds); + strbuf_release(&directory); + strbuf_release(&path); + strbuf_release(&target); + strbuf_release(&line); + return matches; +} +#endif + +static int legacy_identity_cache_matches( + const char *path, const struct strbuf *expected) +{ + struct strbuf actual = STRBUF_INIT; + struct stat st; + int fd, matches = 0; + + fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) + return 0; + if (!fstat(fd, &st) && S_ISREG(st.st_mode) && + st.st_uid == geteuid() && !(st.st_mode & 022) && + st.st_size >= 0 && (uintmax_t)st.st_size == expected->len && + strbuf_read(&actual, fd, expected->len) == (ssize_t)expected->len) + matches = !strbuf_cmp(&actual, expected); + close(fd); + strbuf_release(&actual); + return matches; +} + +static void cache_legacy_peer_identity( + const char *path, const struct strbuf *identity) +{ + struct lock_file lock = LOCK_INIT; + int fd = hold_lock_file_for_update(&lock, path, LOCK_NO_DEREF); + + if (fd < 0) + return; + if (fchmod(fd, 0600) || + write_in_full(fd, identity->buf, identity->len) != + (ssize_t)identity->len || + commit_lock_file(&lock)) + rollback_lock_file(&lock); +} + +static int try_send_attested_legacy_query( + const char *token, const struct strbuf *identity, + struct strbuf *answer) +{ + struct ipc_client_connect_options options = + IPC_CLIENT_CONNECT_OPTIONS_INIT; + struct ipc_client_connection *connection = NULL; + struct strbuf worktree = STRBUF_INIT; + struct strbuf path = STRBUF_INIT; + struct strbuf expected = STRBUF_INIT; + struct strbuf peer_start = STRBUF_INIT; + struct stat root, socket; + pid_t pid; + int cached, ret = -1; + + if (!token || !starts_with(token, "builtin:") || + !repo_get_work_tree(the_repository) || + !strbuf_realpath(&worktree, + repo_get_work_tree(the_repository), 0) || + stat(worktree.buf, &root) || !S_ISDIR(root.st_mode)) + goto done; + options.wait_if_busy = 1; + if (ipc_client_try_connect( + fsmonitor_ipc__get_path(the_repository), + &options, &connection) != IPC_STATE__LISTENING || + !legacy_peer_credentials(connection, &pid) || + !legacy_peer_start_identity(pid, &peer_start) || + lstat(fsmonitor_ipc__get_path(the_repository), &socket) || + !S_ISSOCK(socket.st_mode)) + goto done; + strbuf_addf(&path, "%s.legacy-identity", + fsmonitor_ipc__get_path(the_repository)); + strbuf_addf(&expected, + "v1\n%s\n%"PRIuMAX"\n%"PRIuMAX"\n%s\n%"PRIuMAX"\n%"PRIuMAX"\n", + identity->buf, (uintmax_t)geteuid(), (uintmax_t)pid, + peer_start.buf, + (uintmax_t)socket.st_dev, (uintmax_t)socket.st_ino); + cached = legacy_identity_cache_matches(path.buf, &expected); + if (!cached && + !legacy_peer_watches_worktree(pid, worktree.buf, &root)) + goto done; + if (!cached) + cache_legacy_peer_identity(path.buf, &expected); + trace2_data_intmax("fsm_client", NULL, + cached ? "query/legacy-peer-cached" : + "query/legacy-peer-authenticated", 1); + ret = ipc_client_send_command_to_connection( + connection, token, strlen(token), answer); +done: + ipc_client_close_connection(connection); + strbuf_release(&worktree); + strbuf_release(&path); + strbuf_release(&expected); + strbuf_release(&peer_start); + return ret; +} +#else +static int try_send_attested_legacy_query( + const char *token UNUSED, const struct strbuf *identity UNUSED, + struct strbuf *answer UNUSED) +{ + return -1; +} +#endif + +static int wait_for_daemon_exit(const struct stat *original_socket) { uintmax_t elapsed_ms = 0; uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; while (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (original_socket) { + struct stat current_socket; + + if (!lstat(fsmonitor_ipc__get_path(the_repository), + ¤t_socket) && + (current_socket.st_dev != original_socket->st_dev || + current_socket.st_ino != original_socket->st_ino)) + return 1; + } if (elapsed_ms >= timeout_ms) return -1; sleep_millisec(50); @@ -273,6 +568,7 @@ static int restart_incompatible_daemon(void) uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; long lock_timeout_ms = timeout_ms > LONG_MAX ? LONG_MAX : (long)timeout_ms; + unsigned int restart_attempts = 0; int have_lock = 0; int ret = -1; @@ -291,35 +587,47 @@ static int restart_incompatible_daemon(void) } have_lock = 1; - /* Another client may have replaced the daemon while we waited. */ - if (server_supports_bound_queries()) - goto success; - trace2_data_intmax("fsm_client", NULL, "query/incompatible-daemon", 1); - if (try_send_command("quit", &answer, NULL)) { - /* - * The connection state describes the failed attempt, not - * necessarily the state after the failure. Re-read it before - * deciding whether there is still a daemon to replace. - */ - if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { - if (server_supports_bound_queries()) - ret = 0; - goto done; + while (restart_attempts++ < 32) { + struct stat socket_stat; + const struct stat *original_socket = NULL; + int wait_result; + + /* Another client may have replaced the daemon while we waited. */ + if (server_supports_bound_queries()) + goto success; + if (!lstat(fsmonitor_ipc__get_path(the_repository), + &socket_stat)) + original_socket = &socket_stat; + if (try_send_command("quit", &answer, NULL)) { + /* + * The failed connection may already have been replaced. + * Re-read its state before abandoning the upgrade. + */ + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (server_supports_bound_queries()) + ret = 0; + goto done; + } } - } - if (wait_for_daemon_exit()) - goto done; + wait_result = wait_for_daemon_exit(original_socket); + if (wait_result < 0) + goto done; + if (wait_result > 0) { + trace2_data_intmax("fsm_client", NULL, + "query/restart-raced", 1); + continue; + } - /* - * A concurrent client may already have started a replacement. - * The retried bound query will verify its capability if needed. - */ - if (fsmonitor_ipc__get_state() != IPC_STATE__LISTENING && - spawn_daemon()) - goto done; + /* The retried bound query still verifies any raced replacement. */ + if (fsmonitor_ipc__get_state() != IPC_STATE__LISTENING && + spawn_daemon()) + goto done; + goto success; + } + goto done; success: ret = 0; @@ -333,7 +641,8 @@ static int restart_incompatible_daemon(void) } int fsmonitor_ipc__send_query(const char *since_token, - struct strbuf *answer) + struct strbuf *answer, + int *legacy_worktree_authenticated) { struct strbuf command = STRBUF_INIT; struct strbuf identity = STRBUF_INIT; @@ -345,6 +654,8 @@ int fsmonitor_ipc__send_query(const char *since_token, = IPC_CLIENT_CONNECT_OPTIONS_INIT; const char *tok = since_token ? since_token : ""; + if (legacy_worktree_authenticated) + *legacy_worktree_authenticated = 0; trace2_region_enter("fsm_client", "query", NULL); if (fsmonitor_ipc__get_worktree_identity(the_repository, &identity)) { trace2_data_intmax("fsm_client", NULL, @@ -377,6 +688,13 @@ int fsmonitor_ipc__send_query(const char *since_token, "query/response-length", answer->len); if (!ret && is_trivial_response(answer) && !server_supports_bound_queries()) { + if (!try_send_attested_legacy_query( + tok, &identity, answer)) { + if (legacy_worktree_authenticated) + *legacy_worktree_authenticated = 1; + ret = 0; + goto done; + } /* * A daemon predating bound queries treats query-v1 as * garbage and returns a valid trivial response. Never diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index 006ee0750cf134..daddca5b67fc9b 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -44,7 +44,8 @@ enum ipc_active_state fsmonitor_ipc__get_state(void); * Returns -1 on error; 0 on success. */ int fsmonitor_ipc__send_query(const char *since_token, - struct strbuf *answer); + struct strbuf *answer, + int *legacy_worktree_authenticated); /* * Connect to a `git-fsmonitor--daemon` process via simple-ipc and diff --git a/fsmonitor.c b/fsmonitor.c index 76bb7e051176a0..795109d1169c3e 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -848,6 +848,7 @@ enum fsmonitor_query_outcome query_builtin_fsmonitor( const char *test_sequence = getenv("GIT_TEST_FSMONITOR_QUERY_SEQUENCE"); struct strbuf raw = STRBUF_INIT; + int legacy_authenticated = 0; /* * Tests may script clean, delta, trivial, and error responses with @@ -883,8 +884,12 @@ enum fsmonitor_query_outcome query_builtin_fsmonitor( return result->outcome; } - if (!fsmonitor_ipc__send_query(since_token, &raw)) + if (!fsmonitor_ipc__send_query( + since_token, &raw, &legacy_authenticated)) { fsmonitor_parse_builtin_response(&raw, result); + result->legacy_worktree_authenticated = + legacy_authenticated; + } strbuf_release(&raw); return result->outcome; } @@ -906,6 +911,55 @@ static int apply_fsmonitor_paths(struct index_state *istate, return count; } +static void adopt_legacy_untracked_cache( + struct index_state *istate, + const struct fsmonitor_query_result *result, + int semantic_baseline_needed) +{ + if (fstat_is_reliable() && !istate->split_index && + istate->repo->config_values_private_.trust_ctime && + istate->repo->config_values_private_.check_stat && + result->outcome == FSMONITOR_QUERY_TRIVIAL && + istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + !strcmp(istate->fsmonitor_last_update, "builtin:fake") && + !istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + istate->untracked && istate->untracked->root) { + /* + * A client without daemon support records builtin:fake. Its + * UNTR tree is still useful with ordinary directory timestamp + * validation, but it cannot certify fsmonitor acceleration. + */ + istate->fsmonitor_legacy_untracked_fallback = 1; + istate->untracked->use_fsmonitor = 0; + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-stat-fallback", 1); + return; + } + if (!semantic_baseline_needed || + !result->legacy_worktree_authenticated || + result->outcome != FSMONITOR_QUERY_DELTA || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + !istate->untracked || !istate->untracked->root) { + untracked_cache_discard_legacy(istate); + return; + } + if (!untracked_cache_adopt_legacy(istate)) + return; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_untracked_valid = 1; + istate->fsmonitor_legacy_untracked_adopted = 1; + istate->untracked->use_fsmonitor = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-adopted", 1); +} + static void invalidate_all_fsmonitor(struct index_state *istate) { unsigned int i; @@ -933,8 +987,14 @@ static void invalidate_all_fsmonitor_for_baseline( struct index_state *istate) { unsigned int i; + int preserve_untracked = istate->fsmonitor_legacy_untracked_adopted && + istate->fsmonitor_untracked_valid; invalidate_all_fsmonitor(istate); + if (preserve_untracked) { + istate->fsmonitor_untracked_valid = 1; + istate->untracked->use_fsmonitor = 1; + } for (i = 0; i < istate->cache_nr; i++) istate->cache[i]->ce_flags &= ~CE_UPTODATE; } @@ -957,6 +1017,7 @@ static void invalidate_all_fsmonitor_strong(struct index_state *istate) void fsmonitor_invalidate_semantics(struct index_state *istate) { + istate->fsmonitor_legacy_untracked_adopted = 0; clean_status_invalidate_current_proof(istate); git_attr_invalidate_all(); invalidate_all_fsmonitor_strong(istate); @@ -979,6 +1040,12 @@ static void invalidate_fsmonitor_for_bootstrap( } if (physical_history_unavailable) { + if (istate->fsmonitor_legacy_untracked_fallback) { + invalidate_all_fsmonitor_for_baseline(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/legacy-stat-fallback", 1); + return; + } clean_status_refresh_worktree_manifest(istate); fsmonitor_invalidate_semantics(istate); untracked_cache_invalidate_all(istate); @@ -1053,6 +1120,8 @@ void refresh_fsmonitor(struct index_state *istate) istate->fsmonitor_last_update ? istate->fsmonitor_last_update : "builtin:fake", &result); + adopt_legacy_untracked_cache( + istate, &result, semantic_baseline_needed); if (result.outcome != FSMONITOR_QUERY_ERROR) { query_success = 1; strbuf_addbuf(&last_update_token, &result.token); @@ -1288,7 +1357,13 @@ void refresh_fsmonitor(struct index_state *istate) istate->fsmonitor_pending_token_from_provider = query_success && (fsm_mode == FSMONITOR_MODE_IPC || !is_trivial); - istate->fsmonitor_untracked_valid = 0; + if (istate->fsmonitor_legacy_untracked_adopted) { + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update_pending); + } else { + istate->fsmonitor_untracked_valid = 0; + } } else { /* * The applied delta carries an existing proof forward: diff --git a/fsmonitor.h b/fsmonitor.h index 136f4769c36fc2..ef178d61a225d9 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -26,6 +26,7 @@ struct fsmonitor_query_result { enum fsmonitor_query_outcome outcome; struct strbuf token; struct strbuf paths; + unsigned int legacy_worktree_authenticated : 1; }; #define FSMONITOR_QUERY_RESULT_INIT { \ diff --git a/read-cache-ll.h b/read-cache-ll.h index 81faab1478ddca..2051bbd974e442 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -193,6 +193,8 @@ struct index_state { fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, + fsmonitor_legacy_untracked_adopted : 1, + fsmonitor_legacy_untracked_fallback : 1, fsmonitor_pending_token_from_provider : 1, preload_untracked_complete : 1, preload_bulk_provider_pending : 1, diff --git a/read-cache.c b/read-cache.c index 0a8f726ddbd427..91575f07b5da69 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3300,7 +3300,8 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, if (write_extensions & WRITE_FSMONITOR_EXTENSION && istate->untracked && istate->fsmonitor_last_update && - istate->fsmonitor_untracked_valid) { + istate->fsmonitor_untracked_valid && + !istate->fsmonitor_legacy_untracked_fallback) { strbuf_reset(&sb); write_fsmonitor_untracked_extension(&sb, istate); @@ -3314,6 +3315,7 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, } } if (write_extensions & WRITE_FSCF_EXTENSION && + !istate->fsmonitor_legacy_untracked_fallback && clean_status_should_write_fsmonitor_config(istate)) { strbuf_reset(&sb); clean_status_write_fsmonitor_config(&sb, istate); diff --git a/t/helper/test-fsmonitor-client.c b/t/helper/test-fsmonitor-client.c index dc1dff23fb8ed5..b5e428a0730a61 100644 --- a/t/helper/test-fsmonitor-client.c +++ b/t/helper/test-fsmonitor-client.c @@ -53,7 +53,7 @@ static int do_send_query(const char *token) if (!token || !*token) token = get_token_from_index(); - ret = fsmonitor_ipc__send_query(token, &answer); + ret = fsmonitor_ipc__send_query(token, &answer, NULL); if (ret < 0) die("could not query fsmonitor--daemon"); @@ -109,7 +109,7 @@ static void *hammer_thread_proc(void *_hammer_thread_data) for (k = 0; k < data->nr_requests; k++) { strbuf_reset(&answer); - ret = fsmonitor_ipc__send_query(data->token, &answer); + ret = fsmonitor_ipc__send_query(data->token, &answer, NULL); if (ret < 0) data->sum_errors++; else diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 4edbd1f87386a7..304e020206b1bb 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1562,6 +1562,37 @@ test_expect_success 'bound query replaces a legacy daemon' ' ) ' +test_expect_success 'bound daemon also serves legacy token queries' ' + test_when_finished "stop_daemon_delete_repo legacy-client-query" && + test_create_repo legacy-client-query && + ( + cd legacy-client-query && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TRACE2_EVENT="$PWD/.git/daemon.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test-tool dump-fsmonitor >.git/fsmonitor && + token=$(sed -n "s/^fsmonitor last update //p" \ + .git/fsmonitor) && + test -n "$token" && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc send --name="$ipc_path" \ + --token="$token" >.git/legacy-response && + test_grep "^builtin:" .git/legacy-response && + ! test_trace2_data fsmonitor query/worktree-mismatch 1 \ + <.git/daemon.trace && + GIT_TRACE2_EVENT="$PWD/.git/legacy-client.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/legacy-client.trace + ) +' + test_expect_success MACOS 'daemon token reset closes a skipHash index' ' test_when_finished \ "stop_daemon_delete_repo daemon-token-reset" && diff --git a/wt-status.c b/wt-status.c index b31f5d14163840..018dcc36efb705 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1059,11 +1059,12 @@ static int wt_status_begin_attr_snapshot(struct wt_status *s) } if (s->attr_source_snapshot) git_attr_source_snapshot_begin(s->attr_source_snapshot); - if ((ret > 0 && - (!hook_provider || - (ret & CLEAN_STATUS_ATTR_CONTENT_CHANGED))) || - (clean_status_fsmonitor_strong_mismatch(s->repo->index) && - !hook_provider)) { + if (!s->repo->index->fsmonitor_legacy_untracked_fallback && + ((ret > 0 && + (!hook_provider || + (ret & CLEAN_STATUS_ATTR_CONTENT_CHANGED))) || + (clean_status_fsmonitor_strong_mismatch(s->repo->index) && + !hook_provider))) { /* * Hook providers have no closing query with which to adopt * missing semantic history, so absence alone must preserve @@ -1371,6 +1372,7 @@ static int wt_status_can_use_bulk_provider( struct wt_status *s, unsigned int refresh_flags) { return !s->show_ignored_mode && !s->pathspec.nr && + !s->repo->index->fsmonitor_legacy_untracked_fallback && !clean_status_filter_scope_needs_validation(s->repo->index) && (refresh_flags & REFRESH_DEFER_BULK_DIRTY) && preload_index_bulk_can_close_provider(s->repo->index); @@ -1385,6 +1387,7 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( int ret; if (!fstat_is_reliable() || istate->split_index || + istate->fsmonitor_legacy_untracked_fallback || s->show_ignored_mode || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || istate->sparse_index != INDEX_EXPANDED || @@ -1582,20 +1585,23 @@ static int wt_status_close_ordinary_fsmonitor_token( struct index_state *istate = s->repo->index; struct clean_status_proof_epoch *scan_epoch = NULL; int reliable_stat = fstat_is_reliable(); + int validate_epoch = reliable_stat && + !istate->fsmonitor_legacy_untracked_fallback; /* * A pending token must close a refresh begun after its epoch was * captured. A refresh performed before entering token closure cannot * be validated by capturing its inputs afterward. */ - if (reliable_stat) { + if (validate_epoch) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, closure->use_bulk_provider, &closure->refresh_result); if (!scan_epoch) return 0; - } else if (!refreshed_before_closure) { + } else if (!refreshed_before_closure || + istate->fsmonitor_legacy_untracked_fallback) { closure->refresh_result |= refresh_index( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); @@ -1616,7 +1622,7 @@ static int wt_status_close_ordinary_fsmonitor_token( while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { enum fsmonitor_token_result result; - if (reliable_stat && + if (validate_epoch && !clean_status_proof_epoch_start_token_matches( istate, scan_epoch)) break; @@ -1625,7 +1631,7 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, wt_status_untracked_cache_valid(closure)); if (result == FSMONITOR_TOKEN_CLEAN) { - if (reliable_stat && + if (validate_epoch && !clean_status_proof_epoch_matches( istate, scan_epoch)) { wt_status_reset_attr_snapshot_if_changed(s); @@ -1635,7 +1641,7 @@ static int wt_status_close_ordinary_fsmonitor_token( !closure->require_untracked) { if (preload_index_bulk_result_accept(istate) < 0) break; - if (reliable_stat) + if (validate_epoch) clean_status_mark_fsmonitor_config_valid( istate, istate->fsmonitor_last_update_pending); @@ -1661,7 +1667,7 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); if (wt_status_refresh_invalidated_manifest(s)) break; - if (reliable_stat) { + if (validate_epoch) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, closure->use_bulk_provider, @@ -1878,9 +1884,14 @@ static int wt_status_close_fsmonitor_token( closure.use_bulk_provider = wt_status_can_use_bulk_provider(s, refresh_flags); closure.untracked_ready = !istate->untracked || - !istate->untracked->root; + !istate->untracked->root || + (istate->fsmonitor_legacy_untracked_adopted && + istate->fsmonitor_untracked_valid && + istate->untracked->root->valid_recursive); closure.untracked_proof_complete = - !require_untracked || !istate->untracked; + !require_untracked || !istate->untracked || + (istate->fsmonitor_legacy_untracked_adopted && + closure.untracked_ready); if (require_untracked && !closure.can_prime && !closure.untracked_ready) BUG("cannot close required untracked scan"); From 3dd50ee3496e7131402ec6a3805f98695303e8eb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:25:59 -0500 Subject: [PATCH 119/403] t7527: guard invalidated external fsmonitor history --- t/t7527-builtin-fsmonitor.sh | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 304e020206b1bb..eea3e10d132656 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2192,6 +2192,48 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ + 'dirty stash cannot resurrect an invalidated external checkpoint' ' + test_when_finished "rm -rf stash-checkpoint-history" && + test_create_repo stash-checkpoint-history && + ( + cd stash-checkpoint-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --porcelain=v2 >.git/checkpoint && + test_must_be_empty .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git stash push >.git/stash && + test_grep "Saved working directory" .git/stash && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/actual && + test_grep "nothing to commit, working tree clean" .git/actual && + test_trace2_data fsmonitor history/external-proof-invalidated 1 \ + <.git/status.trace && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/status.trace && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <.git/status.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'mixed reset to a same-tree commit preserves closed history' ' test_when_finished "rm -rf reset-mixed-same-tree" && From cb70e963e1d9f2abffda94b8e2dd2e7d32be5e5b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 5 Aug 2026 21:28:42 -0700 Subject: [PATCH 120/403] t7529: open the resume FIFO before publishing readiness The APFS bulk-preload race tests pause status until the test driver writes a byte to a resume FIFO. The child currently publishes its ready file before it opens the FIFO. If it is descheduled between those operations, the parent can observe readiness, write and close its descriptor, and discard the byte before a reader exists. Status then blocks forever in strbuf_read_file(), leaving a macOS CI job apparently hung. Open the resume FIFO first and read from that descriptor after publishing readiness. The parent opens the FIFO read/write before starting status, so the child open cannot block. Readiness now proves a reader is attached, and the resume byte cannot be lost. Signed-off-by: Taylor Blau --- preload-index-bulk.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 92ce36e8fe4850..6dd2d1e552ec56 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -45,6 +45,7 @@ int preload_bulk_test_barrier(struct preload_bulk_scan *scan, const char *path) { struct strbuf buf = STRBUF_INIT; + int fd; int result; if (!scan->test_barrier_path || @@ -53,9 +54,12 @@ int preload_bulk_test_barrier(struct preload_bulk_scan *scan, if (!scan->test_barrier_ready || !scan->test_barrier_resume) return -1; + fd = open(scan->test_barrier_resume, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; write_file(scan->test_barrier_ready, "ready"); - result = strbuf_read_file(&buf, scan->test_barrier_resume, 1) > 0 ? - 0 : -1; + result = strbuf_read(&buf, fd, 1) > 0 ? 0 : -1; + close(fd); strbuf_release(&buf); return result; } From e62172803b84bd4610ce77fe665590daf2821924 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:43:46 -0500 Subject: [PATCH 121/403] t7527: disable split index for legacy daemon queries --- t/t7527-builtin-fsmonitor.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index eea3e10d132656..f8096ebc086426 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1567,6 +1567,7 @@ test_expect_success 'bound daemon also serves legacy token queries' ' test_create_repo legacy-client-query && ( cd legacy-client-query && + sane_unset GIT_TEST_SPLIT_INDEX && test_commit base tracked && git config core.preloadIndex false && git config core.untrackedCache true && From 43f995bf76bef1070e49c11efa867a2c3eef7add Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 10:44:40 -0500 Subject: [PATCH 122/403] t7527: allow a clean read-tree reset to skip refresh --- t/t7527-builtin-fsmonitor.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index f8096ebc086426..0ace97706f7d7d 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2441,8 +2441,6 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "nothing to commit, working tree clean" .git/actual && test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace && - test_trace2_data index refresh/sum_lstat 1 \ - <.git/status.trace && ! test_trace2_data status semantic_verify/prepared 1 \ <.git/status.trace ) From dc7cad0baf7cb4ff9f2f3f14dd0cf345d187ebe5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:11:23 -0500 Subject: [PATCH 123/403] t7527: cover scoped fsmonitor history reuse Exercise pathspec-scoped status with selected untracked files and dirt outside the selected directory. Ensure a repeat query retains semantic history without rescanning metadata, rewriting the index, or creating a root-wide clean proof. --- t/t7527-builtin-fsmonitor.sh | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 0ace97706f7d7d..8b61e773d2eb2e 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2235,6 +2235,48 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ + 'pathspec status preserves global history without hiding outside dirt' ' + test_when_finished "rm -rf pathspec-checkpoint-history" && + test_create_repo pathspec-checkpoint-history && + ( + cd pathspec-checkpoint-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + test_write_lines changed >tracked && + test_write_lines selected >scoped/new && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status --porcelain=v2 -- scoped >.git/first && + test_grep "^? scoped/new$" .git/first && + ! test_grep "tracked\|outside-new" .git/first && + test_path_is_missing .git/index.csts && + cp .git/index .git/before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 -- scoped >.git/second && + test_cmp .git/first .git/second && + test_cmp .git/before .git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/status.trace && + test_path_is_missing .git/index.csts && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/root && + test_grep "^1 \.M .* tracked$" .git/root + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'mixed reset to a same-tree commit preserves closed history' ' test_when_finished "rm -rf reset-mixed-same-tree" && From b15691d9015bfe035c132e8758c8d35e55ccd86a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:13:44 -0500 Subject: [PATCH 124/403] t7527: tolerate unpersisted fsmonitor-valid bits A clean path observed by status can remain uppercase in a subsequent ls-files invocation when external history avoids rewriting the main index. A late directory event can produce the same representation. Accept either fsmonitor marker while continuing to verify case-alias events and the final modified-path results. This removes a macOS CI failure without forcing an otherwise unnecessary index rewrite. --- t/t7527-builtin-fsmonitor.sh | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 8b61e773d2eb2e..af73c4939f9c9e 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1362,30 +1362,14 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep ! -q "fsmonitor_refresh_callback.*FILE-4-A.*pos" "$PWD/file_case_wrong-try2.log" && test_grep ! -q "fsmonitor_refresh_callback.*file-4-a.*pos" "$PWD/file_case_wrong-try2.log" && - # A late directory event can arrive without repeating the file - # events checked above. Such an event invalidates its entire cone, - # so those entries remain "H" until the next quiet refresh. + # A late directory event can invalidate the whole cone. External + # history can also retain refreshed fsmonitor bits without writing + # them back into the index, so either marker is valid here. git -C file_case_wrong ls-files -f >"$PWD/file_case_wrong-lsf2.out" && - if test_grep -E -q \ - "fsmonitor_refresh_callback .dir1(/dir2(/dir3)?)?/?. .*pos " \ - "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null - then - expected_3=H - else - expected_3=h - fi && - test_grep -q "$expected_3 dir1/dir2/dir3/file-3-a" \ + test_grep -E -q "^[Hh] dir1/dir2/dir3/file-3-a$" \ "$PWD/file_case_wrong-lsf2.out" && - if test_grep -E -q \ - "fsmonitor_refresh_callback .dir1(/dir2(/dir4)?)?/?. .*pos " \ - "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null - then - expected_4=H - else - expected_4=h - fi && - test_grep -q "$expected_4 dir1/dir2/dir4/FILE-4-A" \ + test_grep -E -q "^[Hh] dir1/dir2/dir4/FILE-4-A$" \ "$PWD/file_case_wrong-lsf2.out" && From 09d60887ef90f9c522d494ddb8dbea894dbef06c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:59 -0500 Subject: [PATCH 125/403] t7527: cover closed scoped untracked-cache reuse Exercise repeated tracked-directory queries with root and nested working directories after builtin fsmonitor proves the selected untracked-cache subtree is closed. Require selected files to remain visible, outside files to stay hidden, and the index to stay untouched. Create and remove an untracked child after the initial root-wide cache population. Verify each scoped query reports the correct result, visits exactly one path, opens no directory, and leaves the subsequent ordinary root status clean. Include tracked root and scoped ignore files and reject a subsequent root-wide ignore invalidation. Spell the adjacent scoped-history assertion as the lint-approved negated test_grep invocation. --- t/t7527-builtin-fsmonitor.sh | 114 ++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index af73c4939f9c9e..29343deb8db68e 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2242,7 +2242,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ git status --porcelain=v2 -- scoped >.git/first && test_grep "^? scoped/new$" .git/first && - ! test_grep "tracked\|outside-new" .git/first && + test_grep ! "tracked\|outside-new" .git/first && test_path_is_missing .git/index.csts && cp .git/index .git/before && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ @@ -2261,6 +2261,118 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-directory pathspec reads a closed untracked-cache subtree' ' + test_when_finished "rm -rf pathspec-cached-subtree" && + test_create_repo pathspec-cached-subtree && + ( + cd pathspec-cached-subtree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines selected >scoped/new && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/root && + test_grep "^? scoped/new$" .git/root && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/root-repeat && + cp .git/index .git/before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/scoped.trace" \ + git status --porcelain=v2 -- scoped >.git/scoped && + test_grep "^? scoped/new$" .git/scoped && + test_grep ! "outside-new" .git/scoped && + test_cmp .git/before .git/index && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/scoped.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/scoped.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested.trace" \ + git -C scoped status --porcelain=v2 -- . >.git/nested && + test_grep "^? new$" .git/nested && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/nested.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-directory pathspec repairs changed untracked children' ' + test_when_finished "rm -rf pathspec-repaired-subtree" && + test_create_repo pathspec-repaired-subtree && + ( + cd pathspec-repaired-subtree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped outside && + test_commit selected scoped/tracked && + test_commit unrelated outside/tracked && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "ignored-dir/" >scoped/.gitignore && + git add .gitignore scoped/.gitignore && + git commit -qm "add tracked ignore files" && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + test_write_lines created >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/created.trace" \ + git status --porcelain=v2 -- scoped >.git/created && + test_grep "^? scoped/new$" .git/created && + test_trace2_data status untracked/pathspec-refreshed 1 \ + <.git/created.trace && + test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + <.git/created.trace && + test_trace2_data read_directory paths-visited 1 \ + <.git/created.trace && + test_trace2_data read_directory opendir 0 \ + <.git/created.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/created.trace && + + rm scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/removed.trace" \ + git status --porcelain=v2 -- scoped >.git/removed && + test_must_be_empty .git/removed && + test_trace2_data status untracked/pathspec-refreshed 1 \ + <.git/removed.trace && + test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + <.git/removed.trace && + test_trace2_data read_directory paths-visited 1 \ + <.git/removed.trace && + test_trace2_data read_directory opendir 0 \ + <.git/removed.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/removed.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/root-after-remove.trace" \ + git status --porcelain=v2 >.git/root-after-remove && + test_must_be_empty .git/root-after-remove && + test_grep ! "\"key\":\"gitignore-invalidation\",\"value\":\"[1-9]" \ + .git/root-after-remove.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'mixed reset to a same-tree commit preserves closed history' ' test_when_finished "rm -rf reset-mixed-same-tree" && From cbe5d5357782dc4adc63c50e88b778dabf08ed33 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 6 Aug 2026 23:00:24 -0700 Subject: [PATCH 126/403] diff: honor --no-optional-locks when refreshing the index `git diff` hides a stat-only mismatch when the working-tree contents still match the index, then refreshes and writes the index after producing its result. This write is opportunistic: failure to take the lock is already ignored. 27344d6a6c (git: add --no-optional-locks option, 2017-09-27) made background callers able to suppress optional lock-taking work and called out this refresh as a possible future user. But refresh_index_quietly() never consulted use_optional_locks(), so `git --no-optional-locks diff` still took the index lock and rewrote the index for a stat-only match. Return before taking the lock when optional locks are disabled. The diff result has already been computed at this point, so the only effect is to leave the refreshed stat data unpersisted, matching the documented tradeoff of the option. Add a regression that checks the index mtime stays put under --no-optional-locks while an ordinary `git diff` still writes the refresh. --- builtin/diff.c | 4 ++++ t/t7508-status.sh | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/builtin/diff.c b/builtin/diff.c index 18b1083e984a35..c597935957c74e 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -13,6 +13,7 @@ #include "lockfile.h" #include "color.h" #include "commit.h" +#include "environment.h" #include "gettext.h" #include "tag.h" #include "diff.h" @@ -239,6 +240,9 @@ static void refresh_index_quietly(void) struct lock_file lock_file = LOCK_INIT; int fd; + if (!use_optional_locks()) + return; + fd = repo_hold_locked_index(the_repository, &lock_file, 0); if (fd < 0) return; diff --git a/t/t7508-status.sh b/t/t7508-status.sh index 8059c64940f165..beb84cbf3d657c 100755 --- a/t/t7508-status.sh +++ b/t/t7508-status.sh @@ -1681,6 +1681,23 @@ test_expect_success '--no-optional-locks prevents index update' ' ! test_is_magic_mtime .git/index ' +test_expect_success '--no-optional-locks prevents diff index update' ' + test_when_finished "rm -rf optional-locks-diff" && + test_create_repo optional-locks-diff && + ( + cd optional-locks-diff && + test_commit base tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + git --no-optional-locks diff -- tracked >actual && + test_must_be_empty actual && + test_is_magic_mtime .git/index +1 && + git diff -- tracked >actual && + test_must_be_empty actual && + ! test_is_magic_mtime .git/index +1 + ) +' + test_expect_success 'racy timestamps will be fixed for clean worktree' ' echo content >racy-dirty && echo content >racy-racy && From a01a3ff7b327623ac3c94234afce86fca5489832 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:30:24 -0500 Subject: [PATCH 127/403] describe: honor optional locks for dirty-worktree checks A dirty-worktree check refreshes cached stat information before comparing the index with HEAD. Persisting that refresh is opportunistic, but both describe --dirty implementations currently rewrite the index even when optional locks are disabled. Keep the in-process refresh for accurate dirty detection, but avoid taking the index lock when it is optional. For --broken, retain child-process isolation and use the non-refreshing diff path instead of invoking update-index. Cover clean stat mismatches, actual modifications, broken submodules, and the ordinary mode that still persists refreshed stat information. --- builtin/describe.c | 36 ++++++++++++++++++++++------------ t/t6120-describe.sh | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/builtin/describe.c b/builtin/describe.c index b39df0937ecd14..8e216206bcc19f 100644 --- a/builtin/describe.c +++ b/builtin/describe.c @@ -741,14 +741,23 @@ int cmd_describe(int argc, if (broken) { struct child_process cp = CHILD_PROCESS_INIT; - strvec_pushv(&cp.args, update_index_args); - cp.git_cmd = 1; - cp.no_stdin = 1; - cp.no_stdout = 1; - run_command(&cp); - - child_process_init(&cp); - strvec_pushv(&cp.args, diff_index_args); + if (use_optional_locks()) { + strvec_pushv(&cp.args, update_index_args); + cp.git_cmd = 1; + cp.no_stdin = 1; + cp.no_stdout = 1; + run_command(&cp); + + child_process_init(&cp); + strvec_pushv(&cp.args, diff_index_args); + } else { + strvec_pushl(&cp.args, "-c", + "diff.autoRefreshIndex=true", + "diff", "--quiet", + "--no-ext-diff", "--no-textconv", + "--ignore-submodules=untracked", + "HEAD", "--", NULL); + } cp.git_cmd = 1; cp.no_stdin = 1; cp.no_stdout = 1; @@ -784,10 +793,13 @@ int cmd_describe(int argc, repo_read_index(the_repository); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED, NULL, NULL, NULL); - fd = repo_hold_locked_index(the_repository, - &index_lock, 0); - if (0 <= fd) - repo_update_index_if_able(the_repository, &index_lock); + if (use_optional_locks()) { + fd = repo_hold_locked_index(the_repository, + &index_lock, 0); + if (0 <= fd) + repo_update_index_if_able(the_repository, + &index_lock); + } repo_init_revisions(the_repository, &revs, prefix); diff --git a/t/t6120-describe.sh b/t/t6120-describe.sh index 7a7c46658a3a81..77a14b73a638f8 100755 --- a/t/t6120-describe.sh +++ b/t/t6120-describe.sh @@ -392,6 +392,19 @@ test_expect_success 'setup and absorb a submodule' ' test_cmp expect out ' +test_expect_success 'describe --broken ignores diff submodule presentation settings' ' + test_when_finished "git -C sub1 checkout -- initial.t && rm -f sub1/untracked" && + test_config diff.ignoreSubmodules all && + test_write_lines untracked >sub1/untracked && + git --no-optional-locks describe --dirty --broken >out && + test_grep ! ".*-dirty$" out && + test_write_lines changed >sub1/initial.t && + test_set_magic_mtime .git/index && + git --no-optional-locks describe --dirty --broken >out && + test_grep ".*-dirty$" out && + test_is_magic_mtime .git/index +' + test_expect_success 'describe chokes on severely broken submodules' ' mv .git/modules/sub1/ .git/modules/sub_moved && test_must_fail git describe --dirty @@ -402,6 +415,13 @@ test_expect_success 'describe ignoring a broken submodule' ' test_grep broken out ' +test_expect_success 'describe --broken honors --no-optional-locks' ' + test_set_magic_mtime .git/index && + git --no-optional-locks describe --broken >out && + test_grep broken out && + test_is_magic_mtime .git/index +' + test_expect_success 'describe with --work-tree ignoring a broken submodule' ' ( cd "$TEST_DIRECTORY" && @@ -791,6 +811,34 @@ test_expect_success 'describe --broken --dirty with a file with changed stat' ' ) ' +for broken in '' '--broken' +do + test_expect_success "describe --dirty $broken honors --no-optional-locks" ' + test_when_finished "rm -fr describe-optional-locks" && + git init describe-optional-locks && + ( + cd describe-optional-locks && + test_commit --annotate base tracked && + git config diff.autoRefreshIndex false && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + git --no-optional-locks describe --dirty $broken >actual && + test_grep "^base$" actual && + test_is_magic_mtime .git/index +1 && + test_write_lines changed >tracked && + git --no-optional-locks describe --dirty $broken >actual && + test_grep "^base-dirty$" actual && + test_is_magic_mtime .git/index +1 && + git checkout -- tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + git describe --dirty $broken >actual && + test_grep "^base$" actual && + ! test_is_magic_mtime .git/index +1 + ) + ' +done + test_expect_success '--always with no refs falls back to commit hash' ' git rev-parse HEAD >expect && git describe --no-abbrev --always --match=no-such-tag >actual && From f1598f83c4b3db521a23c981c4a1837f7e8767e9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:24 -0500 Subject: [PATCH 128/403] stash: avoid rewriting the index when nothing can be saved A stash push currently refreshes and writes the index before checking whether the requested paths contain any changes. Even a no-op stash therefore replaces the physical index, invalidates its clean-status proof, and can make the next status scan the entire worktree. Take the existing index lock and perform the refresh in memory, then check for changes while the lock is held. Roll the lock back when there is nothing to stash, including stat-only mismatches; publish the refreshed index before continuing only when a real stash will be created. Preserve locked-index and unmerged-index failures, cover ordinary and optional-lock no-op sequences, and assert that fsmonitor history remains valid without an index write. --- builtin/stash.c | 22 ++++++++++------ t/t3903-stash.sh | 49 ++++++++++++++++++++++++++++++++++++ t/t7527-builtin-fsmonitor.sh | 4 +++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 4fd7ec0c6258ad..6458ca7f9d91f8 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1677,6 +1677,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q { int ret = 0; int preserve_clean_history = !ps->nr && !include_untracked; + struct lock_file index_lock = LOCK_INIT; struct stash_info info = STASH_INFO_INIT; struct strbuf patch = STRBUF_INIT; struct strbuf stash_msg_buf = STRBUF_INIT; @@ -1705,11 +1706,9 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q } /* - * A clean stash push returns after its initial stat refresh. Keep - * that rewrite bound only for whole-worktree forms; paths and - * untracked discovery can change the index or its status inputs. - * If changes are found below, invalidate before the real stash - * machinery mutates the index or worktree. + * Keep whole-worktree history bound while inspecting the worktree. + * If changes are found, invalidate it before stash machinery + * mutates the index or worktree. */ if (preserve_clean_history) clean_status_set_config_digest(the_repository, @@ -1733,17 +1732,25 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q free(ps_matched); } - if (repo_refresh_and_write_index(the_repository, REFRESH_QUIET, 0, 0, - NULL, NULL, NULL)) { + if (repo_hold_locked_index(the_repository, &index_lock, + LOCK_REPORT_ON_ERROR) < 0 || + refresh_index(the_repository->index, REFRESH_QUIET, + NULL, NULL, NULL)) { ret = error(_("could not write index")); goto done; } if (!check_changes(ps, include_untracked, &untracked_files)) { + rollback_lock_file(&index_lock); if (!quiet) printf_ln(_("No local changes to save")); goto done; } + if (write_locked_index(the_repository->index, &index_lock, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) { + ret = error(_("could not write index")); + goto done; + } if (preserve_clean_history) clean_status_invalidate_current_proof(the_repository->index); @@ -1910,6 +1917,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q } done: + rollback_lock_file(&index_lock); strbuf_release(&patch); strbuf_release(&out); free_stash_info(&info); diff --git a/t/t3903-stash.sh b/t/t3903-stash.sh index da27a6599a6a79..8ac681e41df64a 100755 --- a/t/t3903-stash.sh +++ b/t/t3903-stash.sh @@ -1290,6 +1290,33 @@ test_expect_success 'push : show no changes when there are none' ' test_cmp expect actual ' +test_expect_success 'clean stash push does not rewrite an unchanged index' ' + test_when_finished "rm -rf clean-stash-index" && + test_create_repo clean-stash-index && + ( + cd clean-stash-index && + test_commit base tracked && + test_set_magic_mtime .git/index +1 && + git stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + git --no-optional-locks stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + test_set_magic_mtime tracked && + git --no-optional-locks stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + git stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + test_write_lines changed >tracked && + git stash push >actual && + test_grep "Saved working directory" actual && + ! test_is_magic_mtime .git/index +1 + ) +' + test_expect_success 'push: not in the repository errors out' ' >untracked && test_must_fail git stash push untracked && @@ -1697,6 +1724,28 @@ test_expect_success 'stash push reports a locked index' ' ) ' +test_expect_success 'stash push rolls back its lock for an unmerged index' ' + test_when_finished "rm -rf stash-unmerged-lock" && + test_create_repo stash-unmerged-lock && + ( + cd stash-unmerged-lock && + test_commit base tracked && + git checkout -b side && + test_write_lines side >tracked && + git commit -am side && + git checkout - && + test_write_lines main >tracked && + git commit -am main && + test_must_fail git merge side && + test_must_fail git stash push >actual 2>err && + test_grep "needs merge" actual && + test_grep "could not write index" err && + test_path_is_missing .git/index.lock && + git ls-files --unmerged >stages && + test_line_count = 3 stages + ) +' + test_expect_success 'stash apply reports a locked index' ' test_when_finished "rm -rf repo" && git init repo && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 29343deb8db68e..c68f14443f3cdb 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2132,9 +2132,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty .git/prime && test_grep FSCF .git/index && + cp .git/index .git/index.before && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ git stash push >.git/stash && test_grep "No local changes to save" .git/stash && + test_cmp .git/index.before .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/stash.trace && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status >.git/actual && From 379ba8f1e39e0e33a69b2b7884f2fecd91181541 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:31 -0500 Subject: [PATCH 129/403] status: reuse clean proofs across equivalent query shapes A clean-status sidecar certifies that the complete worktree has no tracked or visible untracked changes. That fact does not depend on status formatting, the current subdirectory, literal pathspecs, branch headers, stash headers, or whether untracked entries would be displayed. Replace separate exact, normal, and scoped consumption predicates with one conservative eligibility check, then use the existing live status printer for every supported clean query. Keep proof issuance restricted to the existing exact and normal cases, and continue rejecting ignored output, verbose output, submodule summaries, sparse checkouts, unborn HEADs, and changed proof inputs. Record the supported command sequences with output-oracle comparisons and Trace2 assertions that no index read, refresh, preload, or directory traversal occurs. Exercise live branch, stash, merge, rebase, configuration, prefix, and pathspec changes alongside fail-closed controls. --- builtin/commit.c | 47 ++--- t/t7530-status-clean-sidecar.sh | 309 +++++++++++++++++++++++++++++++- 2 files changed, 334 insertions(+), 22 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index b27ac2e201180c..fc0e043ac109f8 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -36,6 +36,7 @@ #include "refs.h" #include "repository.h" #include "string-list.h" +#include "submodule.h" #include "rerere.h" #include "unpack-trees.h" #include "column.h" @@ -1606,12 +1607,11 @@ static int git_status_config(const char *k, const char *v, /* * A clean-proof hit certifies the tracked and untracked lists, but it - * deliberately does not cache human-readable status output. Refresh the - * cheap state which the long printer derives from refs and administrative - * files before printing those empty lists. + * deliberately does not cache status output. Refresh the cheap state which + * the selected printer derives from refs and administrative files before + * printing those empty lists. */ -static int print_normal_clean_sidecar(struct wt_status *s, - const char *prefix) +static int print_clean_sidecar(struct wt_status *s, const char *prefix) { struct object_id oid; @@ -1621,7 +1621,8 @@ static int print_normal_clean_sidecar(struct wt_status *s, oidcpy(&s->oid_commit, &oid); s->ignore_submodule_arg = ignore_submodule_arg; s->status_format = status_format; - s->verbose = verbose; + /* A globally clean proof guarantees that both verbose diffs are empty. */ + s->verbose = 0; FREE_AND_NULL(s->branch); s->branch = refs_resolve_refdup(get_main_ref_store(s->repo), "HEAD", 0, NULL, NULL); @@ -1651,7 +1652,7 @@ struct repository *repo UNUSED) !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); int exact_clean_query; int normal_clean_query; - int scoped_clean_query; + int reusable_clean_query; int normal_has_head; struct object_id oid; static struct option builtin_status_options[] = { @@ -1727,6 +1728,7 @@ struct repository *repo UNUSED) handle_untracked_files_arg(&s); handle_ignored_arg(&s); + s.ignore_submodule_arg = ignore_submodule_arg; if (s.show_ignored_mode == SHOW_MATCHING_IGNORED && s.show_untracked_files == SHOW_NO_UNTRACKED_FILES) @@ -1735,10 +1737,12 @@ struct repository *repo UNUSED) parse_pathspec(&s.pathspec, 0, PATHSPEC_PREFER_FULL, prefix, argv); - s.allow_clean_status_shortcuts = - default_status_command && !s.pathspec.nr; - normal_has_head = default_status_command && - !repo_get_oid(the_repository, s.reference, &oid); + if (s.ignore_submodule_arg) { + struct diff_options diffopt = { 0 }; + + handle_ignore_submodules_arg(&diffopt, s.ignore_submodule_arg); + } + normal_has_head = !repo_get_oid(the_repository, s.reference, &oid); exact_clean_query = exact_clean_command && status_format == STATUS_FORMAT_PORCELAIN_V2 && !s.pathspec.nr && !s.show_branch && !s.show_stash && @@ -1751,19 +1755,21 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; - scoped_clean_query = s.pathspec.nr && - status_format == STATUS_FORMAT_NONE && - !s.show_branch && !s.show_stash && !s.show_ignored_mode && - !s.null_termination && !s.verbose && !s.submodule_summary && - s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && - !repo_config_values(the_repository)->apply_sparse_checkout && - !repo_get_oid(the_repository, s.reference, &oid); + reusable_clean_query = normal_has_head && + !s.show_ignored_mode && !s.submodule_summary && + /* A clean merge still prints a staged-changes header with -vv. */ + !(verbose > 1 && + file_exists(git_path_merge_head(the_repository))) && + !repo_config_values(the_repository)->apply_sparse_checkout; + s.allow_clean_status_shortcuts = normal_has_head && + !s.submodule_summary && + !repo_config_values(the_repository)->apply_sparse_checkout; clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; - if ((exact_clean_query || normal_clean_query || scoped_clean_query) && + if (reusable_clean_query && clean_status_try_sidecar(the_repository, &clean_digest)) { if (exact_clean_query || - print_normal_clean_sidecar(&s, prefix)) { + print_clean_sidecar(&s, prefix)) { wt_status_collect_free_buffers(&s); return 0; } @@ -1803,7 +1809,6 @@ struct repository *repo UNUSED) if (!s.is_initial) oidcpy(&s.oid_commit, &oid); - s.ignore_submodule_arg = ignore_submodule_arg; s.status_format = status_format; s.verbose = verbose; if (no_renames != -1) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 9a59a18ec4f3e6..eddcc3e8d08f51 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -70,6 +70,63 @@ issue_sidecar () { test_path_is_file "$repo/.git/index.csts" } +assert_clean_sidecar_result () { + sidecar_result=$1 && + sidecar_repo=$2 && + sidecar_cwd=$3 && + sidecar_label=$4 && + shift 4 && + cp "$sidecar_repo/.git/index" "$sidecar_label.index" && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C "$sidecar_cwd" \ + status "$@" >"$sidecar_label.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/$sidecar_label.trace" \ + git -C "$sidecar_cwd" status "$@" \ + >"$sidecar_label.actual" && + test_cmp_bin "$sidecar_label.expect" "$sidecar_label.actual" && + test_cmp_bin "$sidecar_label.index" "$sidecar_repo/.git/index" || + return 1 + + if test "$sidecar_result" = hit + then + test_trace2_data status clean-proof/hit 1 \ + <"$sidecar_label.trace" && + test_grep ! "\"label\":\"do_read_index\"" \ + "$sidecar_label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + "$sidecar_label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"preload" \ + "$sidecar_label.trace" && + test_grep ! "\"label\":\"read_directory\"" \ + "$sidecar_label.trace" + else + test_grep ! "\"key\":\"clean-proof/hit\"" \ + "$sidecar_label.trace" + fi +} + +assert_clean_sidecar_hit () { + assert_clean_sidecar_result hit "$@" +} + +assert_clean_sidecar_fallback () { + assert_clean_sidecar_result fallback "$@" +} + +assert_tracked_clean_fallback () { + tracked_trace=$3.trace && + assert_clean_sidecar_fallback "$@" && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <"$tracked_trace" && + test_trace2_data status index/cache-tree-match 1 \ + <"$tracked_trace" && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + "$tracked_trace" && + test_grep ! "\"category\":\"index\",\"label\":\"preload" \ + "$tracked_trace" +} + assert_fallback_matches_oracle () { repo=$1 && sidecar_trace=$2 && @@ -228,6 +285,252 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'a clean sidecar serves every index-independent status shape' ' + shapes=sidecar-query-shapes && + test_when_finished "stop_daemon $shapes" && + setup_repo "$shapes" && + mkdir "$shapes/scoped" && + test_commit -C "$shapes" scoped scoped/tracked && + test_write_lines "*.ignored" >"$shapes/.gitignore" && + git -C "$shapes" add .gitignore && + git -C "$shapes" commit -qm ignores && + git -C "$shapes" branch sidecar-upstream && + git -C "$shapes" branch --set-upstream-to=sidecar-upstream && + git -C "$shapes" commit --allow-empty -qm ahead && + test_write_lines stashed >"$shapes/tracked" && + git -C "$shapes" stash push -qm sidecar-stash && + test-tool -C "$shapes" chmtime -120 \ + tracked scoped/tracked .gitignore && + git -C "$shapes" update-index --refresh && + test_write_lines ignored >"$shapes/root.ignored" && + test_write_lines ignored >"$shapes/scoped/nested.ignored" && + git -C "$shapes" config core.untrackedCache true && + issue_sidecar "$shapes" && + + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-default && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-long --long && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose --verbose && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-twice -vv && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-long --verbose --long && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-short --verbose --short && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-v2 --verbose --porcelain=v2 && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-null --verbose -z && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-branch --verbose --branch && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-stash --verbose --show-stash && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-scoped --verbose -- scoped && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" \ + sidecar-verbose-nested --verbose && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-short --short && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-porcelain --porcelain && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-porcelain-v1 --porcelain=v1 && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-porcelain-v2 --porcelain=v2 && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-null -z && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-short-branch --short --branch && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-v1-branch-null \ + --porcelain=v1 --branch -z && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-v2-branch \ + --porcelain=v2 --branch && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-stash --show-stash && + test_grep "Your stash currently has 1 entry" sidecar-stash.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-v2-stash \ + --porcelain=v2 --show-stash && + test_grep "^# stash 1$" sidecar-v2-stash.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-daemon \ + --porcelain=v2 -z --branch --show-stash \ + --no-ahead-behind --untracked-files=normal \ + --ignore-submodules=all && + for ignore_mode in all dirty untracked none + do + assert_clean_sidecar_hit "$shapes" "$shapes" \ + "sidecar-ignore-$ignore_mode" \ + "--ignore-submodules=$ignore_mode" || return 1 + done && + test_must_fail git -C "$shapes" status \ + --ignore-submodules=bogus >sidecar-invalid-ignore.out \ + 2>sidecar-invalid-ignore.err && + test_must_be_empty sidecar-invalid-ignore.out && + test_grep "bad --ignore-submodules argument: bogus" \ + sidecar-invalid-ignore.err && + test_must_fail git -C "$shapes" status \ + --ignore-submodules=bogus -- ":(bogus)tracked" \ + >sidecar-invalid-order.out 2>sidecar-invalid-order.err && + test_must_be_empty sidecar-invalid-order.out && + test_grep "Invalid pathspec magic.*bogus" \ + sidecar-invalid-order.err && + test_grep ! "bad --ignore-submodules argument" \ + sidecar-invalid-order.err && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-untracked-no -uno && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-untracked-all -uall && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-no-renames --no-renames && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-find-renames --find-renames=50% && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-scoped -- scoped && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-scoped-slash -- scoped/ && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-scoped-file -- scoped/tracked && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-multiple -- tracked scoped && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-scoped-v2 \ + --porcelain=v2 -- scoped && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" sidecar-nested && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" sidecar-nested-v2 \ + --porcelain=v2 -- tracked && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" sidecar-nested-root \ + --porcelain=v1 -- ":(top)tracked" && + + stash_oid=$(git -C "$shapes" rev-parse refs/stash) && + git -C "$shapes" stash drop -q && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-stash-dropped --show-stash && + test_grep ! "Your stash currently has" \ + sidecar-stash-dropped.actual && + git -C "$shapes" stash store -m restored "$stash_oid" && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-stash-restored \ + --porcelain=v2 --show-stash && + test_grep "^# stash 1$" sidecar-stash-restored.actual && + + current_ref=$(git -C "$shapes" symbolic-ref HEAD) && + git -C "$shapes" branch sidecar-live HEAD && + git -C "$shapes" symbolic-ref HEAD refs/heads/sidecar-live && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-branch-moved \ + --porcelain=v2 --branch && + test_grep "^# branch.head sidecar-live$" \ + sidecar-branch-moved.actual && + git -C "$shapes" symbolic-ref HEAD "$current_ref" && + + git -C "$shapes" rev-parse HEAD >"$shapes/.git/MERGE_HEAD" && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-merge --long && + test_grep "All conflicts fixed but you are still merging" \ + sidecar-merge.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-merge-verbose --verbose && + assert_clean_sidecar_fallback "$shapes" "$shapes" \ + sidecar-merge-verbose-twice -vv && + test_grep "Changes to be committed:" \ + sidecar-merge-verbose-twice.actual && + rm "$shapes/.git/MERGE_HEAD" && + mkdir "$shapes/.git/rebase-merge" && + git -C "$shapes" symbolic-ref HEAD \ + >"$shapes/.git/rebase-merge/head-name" && + git -C "$shapes" rev-parse HEAD \ + >"$shapes/.git/rebase-merge/onto" && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-rebase --long && + test_grep "You are currently rebasing" sidecar-rebase.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-rebase-verbose --verbose && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-rebase-verbose-twice -vv && + rm -rf "$shapes/.git/rebase-merge" +' + +test_expect_success DURABLE_FSMONITOR \ + 'a clean sidecar respects configured short and branch output' ' + test_when_finished "stop_daemon sidecar-configured-shapes" && + setup_repo sidecar-configured-shapes && + git -C sidecar-configured-shapes config status.short true && + git -C sidecar-configured-shapes config status.branch true && + issue_sidecar sidecar-configured-shapes && + assert_clean_sidecar_hit sidecar-configured-shapes \ + sidecar-configured-shapes sidecar-configured-short && + test_grep "^## " sidecar-configured-short.actual && + assert_clean_sidecar_hit sidecar-configured-shapes \ + sidecar-configured-shapes sidecar-configured-long \ + --no-short --no-branch && + assert_clean_sidecar_hit sidecar-configured-shapes \ + sidecar-configured-shapes sidecar-configured-v2 \ + --porcelain=v2 --branch +' + +test_expect_success DURABLE_FSMONITOR \ + 'a clean sidecar never answers unsupported or dirty status shapes' ' + test_when_finished "stop_daemon sidecar-unsafe-shapes" && + setup_repo sidecar-unsafe-shapes && + mkdir sidecar-unsafe-shapes/scoped && + test_commit -C sidecar-unsafe-shapes scoped scoped/tracked && + test_write_lines "*.ignored" >sidecar-unsafe-shapes/.gitignore && + git -C sidecar-unsafe-shapes add .gitignore && + git -C sidecar-unsafe-shapes commit -qm ignores && + test-tool -C sidecar-unsafe-shapes chmtime -120 \ + tracked scoped/tracked .gitignore && + git -C sidecar-unsafe-shapes update-index --refresh && + test_write_lines ignored >sidecar-unsafe-shapes/root.ignored && + test_write_lines ignored \ + >sidecar-unsafe-shapes/scoped/nested.ignored && + git -C sidecar-unsafe-shapes config core.untrackedCache true && + issue_sidecar sidecar-unsafe-shapes && + + assert_tracked_clean_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-ignored --ignored && + test_grep "root.ignored" sidecar-ignored.actual && + test_grep "\"label\":\"read_directory\"" sidecar-ignored.trace && + assert_tracked_clean_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-ignored-matching \ + --ignored=matching && + assert_tracked_clean_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-ignored-scoped \ + --ignored -- scoped && + test_grep "scoped/nested.ignored" sidecar-ignored-scoped.actual && + assert_clean_sidecar_hit sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-verbose-clean --verbose && + + git -C sidecar-unsafe-shapes config core.sparseCheckout true && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-sparse --long && + git -C sidecar-unsafe-shapes config --unset core.sparseCheckout && + current_ref=$(git -C sidecar-unsafe-shapes symbolic-ref HEAD) && + git -C sidecar-unsafe-shapes symbolic-ref \ + HEAD refs/heads/sidecar-unborn && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-unborn \ + --porcelain=v2 --branch && + test_grep "^# branch.oid (initial)$" sidecar-unborn.actual && + git -C sidecar-unsafe-shapes symbolic-ref HEAD "$current_ref" && + + test_write_lines changed >sidecar-unsafe-shapes/tracked && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-dirty-verbose --verbose && + test_grep "tracked" sidecar-dirty-verbose.actual && + test_grep "\"category\":\"diff\"" \ + sidecar-dirty-verbose.trace && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-dirty --porcelain=v2 && + test_grep "^1 \.M .* tracked$" sidecar-dirty.actual && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-dirty-outside \ + --porcelain=v2 -- scoped && + test_must_be_empty sidecar-dirty-outside.actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'submodule summaries reject an otherwise valid clean sidecar' ' + test_when_finished "stop_daemon sidecar-submodule-summary" && + setup_repo sidecar-submodule-summary && + git -C sidecar-submodule-summary \ + config status.submoduleSummary true && + issue_sidecar sidecar-submodule-summary && + assert_clean_sidecar_fallback sidecar-submodule-summary \ + sidecar-submodule-summary sidecar-summary --long +' + test_expect_success DURABLE_FSMONITOR \ 'dirty exact status checkpoints history without certifying cleanliness' ' test_when_finished "stop_daemon external-dirty-exact" && @@ -671,8 +974,12 @@ test_expect_success DURABLE_FSMONITOR \ test_must_be_empty actual && test_path_is_missing sidecar-shape/.git/index.csts && - bulk_status -C sidecar-shape status --porcelain=v2 --branch >actual && + test_env GIT_TRACE2_EVENT="$PWD/shape-branch.trace" \ + bulk_status -C sidecar-shape \ + status --porcelain=v2 --branch >actual && test_grep "^# branch.oid " actual && + test_grep ! "\"key\":\"clean-proof/sidecar\"" \ + shape-branch.trace && test_path_is_missing sidecar-shape/.git/index.csts && echo changed >sidecar-shape/tracked && From 1fe740503ebc57e06beb7f4558eb67e66fd1d9b2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:11:47 -0700 Subject: [PATCH 130/403] status: pin semantic verification to the worktree root A worktree pathname can be replaced while a content verifier is opening files. Resolving later paths against that name can therefore hash a different tree from the one the index was meant to describe. Retain a no-follow descriptor and the complete stat identity of the repository worktree. On Linux, probe openat2() and require beneath-root resolution without symlink, magic-link, or mount crossings. Resolve SYS_openat2 through __NR_openat2 or the x86 syscall number 437 when older headers omit it; still require the complete runtime probe. On macOS, provide no-follow descriptor-relative opens. If the required platform support or the stable root is unavailable, return an error instead of attempting an unanchored proof. Register the root implementation with both Make and Meson. This patch introduces a compiled, isolated root primitive; it does not yet publish a proof, add a status caller, or check final root stability. Signed-off-by: Taylor Blau --- Makefile | 1 + meson.build | 1 + semantic-verify-internal.h | 43 ++++++++++ semantic-verify-root.c | 155 +++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 semantic-verify-internal.h create mode 100644 semantic-verify-root.c diff --git a/Makefile b/Makefile index 97a4936b146df3..d31f118893a214 100644 --- a/Makefile +++ b/Makefile @@ -1317,6 +1317,7 @@ LIB_OBJS += resolve-undo.o LIB_OBJS += revision.o LIB_OBJS += run-command.o LIB_OBJS += send-pack.o +LIB_OBJS += semantic-verify-root.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o LIB_OBJS += server-info.o diff --git a/meson.build b/meson.build index a777f4722a2061..408b0c27ca288d 100644 --- a/meson.build +++ b/meson.build @@ -523,6 +523,7 @@ libgit_sources = [ 'run-command.c', 'send-pack.c', 'sequencer.c', + 'semantic-verify-root.c', 'serve.c', 'server-info.c', 'setup.c', diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h new file mode 100644 index 00000000000000..fd7dd9897f8e45 --- /dev/null +++ b/semantic-verify-internal.h @@ -0,0 +1,43 @@ +#ifndef SEMANTIC_VERIFY_INTERNAL_H +#define SEMANTIC_VERIFY_INTERNAL_H + +#include "statinfo.h" + +#ifdef __linux__ +#include +#if !defined(SYS_openat2) && defined(__NR_openat2) +#define SYS_openat2 __NR_openat2 +#elif !defined(SYS_openat2) && \ + (defined(__x86_64__) || defined(__i386__)) +#define SYS_openat2 437 +#endif +#endif + +#if defined(__APPLE__) && defined(O_NONBLOCK) && \ + defined(O_NOFOLLOW) && defined(O_DIRECTORY) && \ + defined(AT_SYMLINK_NOFOLLOW) +#define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 1 +#elif defined(__linux__) && defined(SYS_openat2) && \ + defined(O_CLOEXEC) && defined(O_NONBLOCK) && \ + defined(O_NOFOLLOW) && defined(O_DIRECTORY) && \ + defined(AT_SYMLINK_NOFOLLOW) +#define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 1 +#else +#define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 0 +#endif + +struct repository; + +struct semantic_verify_root { + int fd; + char *path; + struct stat stat; +}; + +int semantic_verify_root_init(struct repository *repo, + struct semantic_verify_root **root_out); +void semantic_verify_root_clear(struct semantic_verify_root *root); + +int semantic_verify_openat(int dirfd, const char *path, int flags); + +#endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-root.c b/semantic-verify-root.c new file mode 100644 index 00000000000000..e896cbe51f7c94 --- /dev/null +++ b/semantic-verify-root.c @@ -0,0 +1,155 @@ +#include "git-compat-util.h" +#include "path-namespace.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "wrapper.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN && defined(__linux__) +struct semantic_open_how { + uint64_t flags; + uint64_t mode; + uint64_t resolve; +}; + +#define SEMANTIC_RESOLVE_NO_XDEV 0x01 +#define SEMANTIC_RESOLVE_NO_MAGICLINKS 0x02 +#define SEMANTIC_RESOLVE_NO_SYMLINKS 0x04 +#define SEMANTIC_RESOLVE_BENEATH 0x08 +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN && !defined(__linux__) +static int set_fd_cloexec(int fd) +{ +#if defined(F_GETFD) && defined(F_SETFD) && defined(FD_CLOEXEC) + int flags = fcntl(fd, F_GETFD); + + if (flags < 0 || fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) + return -1; +#endif + return 0; +} +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +int semantic_verify_openat(int dirfd, const char *path, int flags) +{ +#ifdef __linux__ + struct semantic_open_how how = { + .flags = flags | O_CLOEXEC, + .resolve = SEMANTIC_RESOLVE_BENEATH | + SEMANTIC_RESOLVE_NO_SYMLINKS | + SEMANTIC_RESOLVE_NO_MAGICLINKS | + SEMANTIC_RESOLVE_NO_XDEV, + }; + + return syscall(SYS_openat2, dirfd, path, &how, sizeof(how)); +#else + int fd; + int saved_errno; + +#ifdef O_CLOEXEC + fd = openat(dirfd, path, flags | O_CLOEXEC); + if (fd >= 0) + return fd; + if (errno != EINVAL) + return -1; +#endif + fd = openat(dirfd, path, flags); + if (fd < 0) + return -1; + if (!set_fd_cloexec(fd)) + return fd; + saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; +#endif +} +#else +int semantic_verify_openat(int dirfd UNUSED, const char *path UNUSED, + int flags UNUSED) +{ + errno = ENOSYS; + return -1; +} +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +int semantic_verify_root_init(struct repository *repo, + struct semantic_verify_root **root_out) +{ + struct semantic_verify_root *root; + const char *path = repo_get_work_tree(repo); + + if (!path) { + errno = ENOENT; + return -1; + } + CALLOC_ARRAY(root, 1); + root->fd = -1; + root->path = xstrdup(path); + root->fd = git_open_cloexec(root->path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (root->fd < 0 || fstat(root->fd, &root->stat) || + !S_ISDIR(root->stat.st_mode)) { + int saved_errno = errno ? errno : ENOTDIR; + + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } +#ifdef __linux__ + { + struct stat probe_stat; + int probe_fd = semantic_verify_openat( + root->fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + int saved_errno; + + if (probe_fd < 0) { + saved_errno = errno; + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } + if (fstat(probe_fd, &probe_stat)) { + saved_errno = errno; + close(probe_fd); + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } + if (!path_namespace_stat_equal(&root->stat, &probe_stat)) { + close(probe_fd); + semantic_verify_root_clear(root); + errno = EAGAIN; + return -1; + } + if (close(probe_fd)) { + saved_errno = errno; + semantic_verify_root_clear(root); + errno = saved_errno; + return -1; + } + } +#endif + *root_out = root; + return 0; +} +#else +int semantic_verify_root_init(struct repository *repo UNUSED, + struct semantic_verify_root **root_out UNUSED) +{ + errno = ENOSYS; + return -1; +} +#endif + +void semantic_verify_root_clear(struct semantic_verify_root *root) +{ + if (!root) + return; + if (root->fd >= 0) + close(root->fd); + free(root->path); + free(root); +} From 4fce6a326c8bdbe42a94a73cfd77bba2052c0259 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:12:41 -0700 Subject: [PATCH 131/403] status: resolve semantic proof paths through pinned parents Holding the worktree root does not keep a nested directory from being renamed or replaced while indexed paths are visited. Reopening a whole pathname can silently switch verification into the replacement tree. Resolve each indexed path one component at a time beneath the retained root. Keep matching ancestor descriptors while walking sorted index names, reject empty and dot components and device crossings, and reopen each outgoing directory through its still-pinned parent. If the reopened identity changes, record the earliest affected index position so a caller cannot retain a clean result from that namespace. Register the resolver in both Make and Meson. Unsupported platforms return ENOSYS rather than falling back to ordinary pathname resolution. The resolver is an internal, independently buildable primitive; this patch does not yet run a status scan or construct a complete proof. Signed-off-by: Taylor Blau --- Makefile | 1 + meson.build | 1 + semantic-verify-internal.h | 10 ++ semantic-verify-path.c | 186 +++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 semantic-verify-path.c diff --git a/Makefile b/Makefile index d31f118893a214..afb57d02604aec 100644 --- a/Makefile +++ b/Makefile @@ -1317,6 +1317,7 @@ LIB_OBJS += resolve-undo.o LIB_OBJS += revision.o LIB_OBJS += run-command.o LIB_OBJS += send-pack.o +LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o diff --git a/meson.build b/meson.build index 408b0c27ca288d..35c693e1f1503e 100644 --- a/meson.build +++ b/meson.build @@ -523,6 +523,7 @@ libgit_sources = [ 'run-command.c', 'send-pack.c', 'sequencer.c', + 'semantic-verify-path.c', 'semantic-verify-root.c', 'serve.c', 'server-info.c', diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index fd7dd9897f8e45..1f881af44fba6f 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -27,6 +27,7 @@ #endif struct repository; +struct semantic_verify_path; struct semantic_verify_root { int fd; @@ -40,4 +41,13 @@ void semantic_verify_root_clear(struct semantic_verify_root *root); int semantic_verify_openat(int dirfd, const char *path, int flags); +struct semantic_verify_path *semantic_verify_path_new( + struct semantic_verify_root *root); +int semantic_verify_resolve_parent(struct semantic_verify_path *path, + const char *name, size_t cache_pos, + int *parent_fd, const char **basename); +void semantic_verify_path_free(struct semantic_verify_path *path, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from); + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-path.c b/semantic-verify-path.c new file mode 100644 index 00000000000000..db7fd874084c42 --- /dev/null +++ b/semantic-verify-path.c @@ -0,0 +1,186 @@ +#include "git-compat-util.h" +#include "path-namespace.h" +#include "semantic-verify-internal.h" +#include "strbuf.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +struct anchored_dir { + char *component; + int fd; + struct stat stat; + size_t first_cache_pos; +}; + +struct semantic_verify_path { + struct semantic_verify_root *root; + struct anchored_dir *dirs; + size_t dirs_nr; + size_t dirs_alloc; + size_t namespace_unstable_from; + unsigned int namespace_unstable; + struct strbuf component; +}; + +static void note_namespace_unstable(struct semantic_verify_path *path, + size_t from) +{ + path->namespace_unstable = 1; + if (from < path->namespace_unstable_from) + path->namespace_unstable_from = from; +} + +static void pop_anchored_dir(struct semantic_verify_path *path) +{ + struct anchored_dir *dir = &path->dirs[path->dirs_nr - 1]; + int parent_fd = path->dirs_nr == 1 ? path->root->fd : + path->dirs[path->dirs_nr - 2].fd; + int named_fd; + struct stat named_stat; + + named_fd = semantic_verify_openat(parent_fd, dir->component, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (named_fd < 0 || fstat(named_fd, &named_stat) || + !path_namespace_stat_equal(&dir->stat, &named_stat)) + note_namespace_unstable(path, dir->first_cache_pos); + if (named_fd >= 0) + close(named_fd); + close(dir->fd); + free(dir->component); + path->dirs_nr--; +} + +struct semantic_verify_path *semantic_verify_path_new( + struct semantic_verify_root *root) +{ + struct semantic_verify_path *path; + + CALLOC_ARRAY(path, 1); + path->root = root; + path->namespace_unstable_from = SIZE_MAX; + path->component = (struct strbuf)STRBUF_INIT; + return path; +} + +int semantic_verify_resolve_parent(struct semantic_verify_path *path, + const char *name, size_t cache_pos, + int *parent_fd, const char **basename) +{ + const char *slash = strrchr(name, '/'); + size_t parent_len = slash ? (size_t)(slash - name) : 0; + size_t begin = 0, depth = 0; + + *basename = slash ? slash + 1 : name; + if (!**basename) { + errno = EINVAL; + return -1; + } + + /* Find the component-aligned prefix already pinned by this worker. */ + while (begin < parent_len && depth < path->dirs_nr) { + size_t end = begin; + struct anchored_dir *dir = &path->dirs[depth]; + + while (end < parent_len && name[end] != '/') + end++; + if (strlen(dir->component) != end - begin || + memcmp(dir->component, name + begin, end - begin)) + break; + depth++; + begin = end + 1; + } + while (path->dirs_nr > depth) + pop_anchored_dir(path); + + while (begin < parent_len) { + size_t end = begin; + struct anchored_dir *dir; + int dirfd, fd; + struct stat st; + + while (end < parent_len && name[end] != '/') + end++; + if (end == begin || + (end - begin == 1 && name[begin] == '.') || + (end - begin == 2 && name[begin] == '.' && + name[begin + 1] == '.')) { + errno = EINVAL; + return -1; + } + strbuf_reset(&path->component); + strbuf_add(&path->component, name + begin, end - begin); + dirfd = path->dirs_nr ? path->dirs[path->dirs_nr - 1].fd : + path->root->fd; + fd = semantic_verify_openat(dirfd, path->component.buf, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (fd < 0) + return -1; + if (fstat(fd, &st)) { + int saved_errno = errno; + + close(fd); + errno = saved_errno; + return -1; + } + if (!S_ISDIR(st.st_mode) || st.st_dev != path->root->stat.st_dev) { + close(fd); + errno = EXDEV; + return -1; + } + ALLOC_GROW(path->dirs, path->dirs_nr + 1, path->dirs_alloc); + dir = &path->dirs[path->dirs_nr++]; + dir->component = xstrdup(path->component.buf); + dir->fd = fd; + memcpy(&dir->stat, &st, sizeof(st)); + dir->first_cache_pos = cache_pos; + begin = end + 1; + } + + *parent_fd = path->dirs_nr ? path->dirs[path->dirs_nr - 1].fd : + path->root->fd; + return 0; +} + +void semantic_verify_path_free(struct semantic_verify_path *path, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from) +{ + if (!path) + return; + while (path->dirs_nr) + pop_anchored_dir(path); + if (namespace_unstable) + *namespace_unstable = path->namespace_unstable; + if (namespace_unstable_from) + *namespace_unstable_from = path->namespace_unstable_from; + free(path->dirs); + strbuf_release(&path->component); + free(path); +} +#else +struct semantic_verify_path *semantic_verify_path_new( + struct semantic_verify_root *root UNUSED) +{ + errno = ENOSYS; + return NULL; +} + +int semantic_verify_resolve_parent( + struct semantic_verify_path *path UNUSED, + const char *name UNUSED, size_t cache_pos UNUSED, + int *parent_fd UNUSED, const char **basename UNUSED) +{ + errno = ENOSYS; + return -1; +} + +void semantic_verify_path_free( + struct semantic_verify_path *path UNUSED, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from) +{ + if (namespace_unstable) + *namespace_unstable = 0; + if (namespace_unstable_from) + *namespace_unstable_from = SIZE_MAX; +} +#endif From e80b3440f97139a053af5bd128eca1c9110c0902 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:45:06 -0500 Subject: [PATCH 132/403] path-namespace: check reopened component identity A successful descriptor-relative reopen proves only that a component currently exists. If its parent entry has been replaced, the reopened descriptor can refer to a different object from the file that was originally observed. Add path_namespace_reopen_component() to reopen exactly one component with a caller-supplied anchored-open function and compare the complete stat identity with the expected object. Reject empty components, dot components, and embedded separators with EINVAL; report a replacement as EAGAIN and close the reopened descriptor on every outcome. When file identity is unreliable, return EAGAIN before opening the component. Extend the already registered path-namespace unit suite with matching and replaced temporary objects and a parent-traversal attempt. The primitive does not infer that equal content or a successful open is sufficient to establish namespace identity. Signed-off-by: Taylor Blau --- path-namespace.c | 42 +++++++++++++++++++++++++++++ path-namespace.h | 5 ++++ t/unit-tests/u-path-namespace.c | 48 +++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/path-namespace.c b/path-namespace.c index 48fc0aae434ef7..151634b886b663 100644 --- a/path-namespace.c +++ b/path-namespace.c @@ -45,3 +45,45 @@ int path_namespace_stat_equal(const struct stat *a, const struct stat *b) path_stat_identity_init(&second, b); return path_stat_identity_equal(&first, &second); } + +int path_namespace_reopen_component( + int parent_fd, const char *component, int flags, + path_namespace_open_fn open_fn, const struct stat *expected) +{ + struct stat reopened; + int fd, saved_errno; + + if (!open_fn || !component || !*component || + !strcmp(component, ".") || !strcmp(component, "..")) { + errno = EINVAL; + return -1; + } + for (const char *p = component; *p; p++) { + if (is_dir_sep(*p)) { + errno = EINVAL; + return -1; + } + } + if (!fstat_is_reliable()) { + errno = EAGAIN; + return -1; + } + + fd = open_fn(parent_fd, component, flags); + if (fd < 0) + return -1; + if (fstat(fd, &reopened)) { + saved_errno = errno; + goto error; + } + if (!path_namespace_stat_equal(expected, &reopened)) { + saved_errno = EAGAIN; + goto error; + } + return close(fd); + +error: + close(fd); + errno = saved_errno; + return -1; +} diff --git a/path-namespace.h b/path-namespace.h index 0a93683e0b2de1..c26f4f12aebd48 100644 --- a/path-namespace.h +++ b/path-namespace.h @@ -3,6 +3,8 @@ struct stat; +typedef int (*path_namespace_open_fn)(int dirfd, const char *path, int flags); + #define PATH_STAT_IDENTITY_FIELDS 14 struct path_stat_identity { @@ -14,5 +16,8 @@ void path_stat_identity_init(struct path_stat_identity *identity, int path_stat_identity_equal(const struct path_stat_identity *a, const struct path_stat_identity *b); int path_namespace_stat_equal(const struct stat *a, const struct stat *b); +int path_namespace_reopen_component( + int parent_fd, const char *component, int flags, + path_namespace_open_fn open_fn, const struct stat *expected); #endif /* PATH_NAMESPACE_H */ diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c index 702104597b1df9..4e0d9dfab24d5d 100644 --- a/t/unit-tests/u-path-namespace.c +++ b/t/unit-tests/u-path-namespace.c @@ -1,6 +1,7 @@ #include "unit-test.h" #include "path-namespace.h" +#include "tempfile.h" #define ASSERT_STAT_FIELD_MATTERS(base, changed, field) do { \ (changed) = (base); \ @@ -67,3 +68,50 @@ void test_path_namespace__stat_fields(void) ASSERT_STAT_FIELD_MATTERS(st, changed, st_gen); #endif } + +static int source_fd = -1; + +static int reopen_source(int dirfd UNUSED, const char *path, int flags UNUSED) +{ + if (strcmp(path, "source")) { + errno = ENOENT; + return -1; + } + return dup(source_fd); +} + +void test_path_namespace__reopen_component(void) +{ + struct tempfile *first = mks_tempfile_t("path-namespace-one-XXXXXX"); + struct tempfile *second = mks_tempfile_t("path-namespace-two-XXXXXX"); + struct stat expected; + + cl_assert(first != NULL); + cl_assert(second != NULL); + cl_must_pass(fstat(get_tempfile_fd(first), &expected)); + + source_fd = get_tempfile_fd(first); + if (!fstat_is_reliable()) { + cl_assert(path_namespace_reopen_component( + -1, "source", O_RDONLY, + reopen_source, &expected) < 0); + cl_assert_equal_i(errno, EAGAIN); + goto invalid_component; + } + cl_must_pass(path_namespace_reopen_component( + -1, "source", O_RDONLY, reopen_source, &expected)); + + source_fd = get_tempfile_fd(second); + cl_assert(path_namespace_reopen_component( + -1, "source", O_RDONLY, reopen_source, &expected) < 0); + cl_assert_equal_i(errno, EAGAIN); + +invalid_component: + cl_assert(path_namespace_reopen_component( + -1, "../source", O_RDONLY, reopen_source, &expected) < 0); + cl_assert_equal_i(errno, EINVAL); + + source_fd = -1; + cl_must_pass(delete_tempfile(&first)); + cl_must_pass(delete_tempfile(&second)); +} From 7599cf5862dd8ef3db87a04fadf3e68dd1bd980a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:08:32 -0500 Subject: [PATCH 133/403] fsmonitor: require content checks for reported paths Clearing CE_FSMONITOR_VALID is not enough to make a provider event authoritative. With core.trustctime disabled, core.checkStat set to minimal, and a restored modification time, stat matching can still accept changed file contents. The same stale match can affect diff, apply, checkout, and unpack-trees. Mark a reported entry with the in-memory CE_CONTENT_CHECK_REQUIRED flag, clear CE_UPTODATE, and discard its cached stat data. Route diff, apply, checkout, and unpack-trees comparisons through ie_match_stat_with_content_check(), which calls ie_modified() only for marked non-gitlinks. Other direct ie_match_stat() callers retain their existing paths. Marking an entry up to date clears the transient flag. Ordinary entries, gitlinks, and unmarked zero-stat entries retain their existing stat behavior. Add hook regressions for restored timestamps, diff and status, indexed apply, checkout, case-insensitive unpacking, unchanged reset, and ordinary zero-stat behavior in t/t7519-status-fsmonitor.sh. Signed-off-by: Taylor Blau --- apply.c | 5 +- diff-lib.c | 5 +- entry.c | 5 +- fsmonitor.c | 17 +++-- fsmonitor.h | 7 ++ read-cache-ll.h | 21 +++++- read-cache.c | 26 +++++++ t/helper/test-read-cache.c | 37 +++++++++ t/t7519-status-fsmonitor.sh | 142 +++++++++++++++++++++++++++++++++++ t/t7527-builtin-fsmonitor.sh | 27 ++++++- unpack-trees.c | 12 ++- 11 files changed, 280 insertions(+), 24 deletions(-) diff --git a/apply.c b/apply.c index f00b7ba4d3a7e6..1f5dda3b6f3fcc 100644 --- a/apply.c +++ b/apply.c @@ -3539,8 +3539,9 @@ static int verify_index_match(struct apply_state *state, return -1; return 0; } - return ie_match_stat(state->repo->index, ce, st, - CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); + return ie_match_stat_with_content_check( + state->repo->index, ce, st, + CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); } #define SUBMODULE_PATCH_WITHOUT_INDEX 1 diff --git a/diff-lib.c b/diff-lib.c index 46cae637ecda83..748f9ec3f3e7e7 100644 --- a/diff-lib.c +++ b/diff-lib.c @@ -90,7 +90,10 @@ static int match_stat_with_submodule(struct diff_options *diffopt, struct stat *st, unsigned ce_option, unsigned *dirty_submodule) { - int changed = ie_match_stat(diffopt->repo->index, ce, st, ce_option); + int changed; + + changed = ie_match_stat_with_content_check( + diffopt->repo->index, ce, st, ce_option); if (S_ISGITLINK(ce->ce_mode)) { struct diff_flags orig_flags = diffopt->flags; if (!diffopt->flags.override_submodule_config) diff --git a/entry.c b/entry.c index 1c4f0f44070ea3..9284e0d0d49123 100644 --- a/entry.c +++ b/entry.c @@ -512,8 +512,9 @@ int checkout_entry_ca(struct cache_entry *ce, struct conv_attrs *ca, if (!check_path(path.buf, path.len, &st, state->base_dir_len)) { const struct submodule *sub; - unsigned changed = ie_match_stat(state->istate, ce, &st, - CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); + unsigned changed = ie_match_stat_with_content_check( + state->istate, ce, &st, + CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); /* * Needs to be checked before !changed returns early, * as the possibly empty directory was not changed diff --git a/fsmonitor.c b/fsmonitor.c index 107767527ebec7..175377982d6ec9 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -189,13 +189,14 @@ static int query_fsmonitor_hook(struct repository *r, } /* - * Invalidate the FSM bit on this CE. This is like mark_fsmonitor_invalid() - * but we've already handled the untracked-cache, so let's not repeat that - * work. This also lets us have a different trace message so that we can - * see everything that was done as part of the refresh-callback. + * Strongly invalidate one cache entry without touching attributes or the + * untracked cache. Callers choose those wider invalidation scopes explicitly. */ -static void invalidate_ce_fsm(struct cache_entry *ce) +void fsmonitor_invalidate_cache_entry(struct cache_entry *ce) { + ce->ce_flags &= ~CE_UPTODATE; + memset(&ce->ce_stat_data, 0, sizeof(ce->ce_stat_data)); + ce->ce_flags |= CE_CONTENT_CHECK_REQUIRED; if (ce->ce_flags & CE_FSMONITOR_VALID) { trace_printf_key(&trace_fsmonitor, "fsmonitor_refresh_callback INV: '%s'", @@ -254,7 +255,7 @@ static size_t handle_using_name_hash_icase( */ untracked_cache_invalidate_trimmed_path(istate, ce->name, 0); - invalidate_ce_fsm(ce); + fsmonitor_invalidate_cache_entry(ce); return 1; } @@ -347,7 +348,7 @@ static size_t handle_path_without_trailing_slash( * cache-entry with the same pathname, nor for a cone * at that directory. (That is, assume no D/F conflicts.) */ - invalidate_ce_fsm(istate->cache[pos]); + fsmonitor_invalidate_cache_entry(istate->cache[pos]); return 1; } else { size_t nr_in_cone; @@ -425,7 +426,7 @@ static size_t handle_path_with_trailing_slash( for (i = pos; i < istate->cache_nr; i++) { if (!starts_with(istate->cache[i]->name, name)) break; - invalidate_ce_fsm(istate->cache[i]); + fsmonitor_invalidate_cache_entry(istate->cache[i]); nr_in_cone++; } diff --git a/fsmonitor.h b/fsmonitor.h index 5195a8624db82b..4027ca83f2b8cd 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -8,6 +8,13 @@ #include "read-cache-ll.h" #include "trace.h" +/* + * Force the next stat-aware caller to verify this entry's content. Wider + * invalidation, such as attributes or untracked-cache state, is the caller's + * responsibility. + */ +void fsmonitor_invalidate_cache_entry(struct cache_entry *ce); + /* * Check if refresh_fsmonitor has been called at least once. * refresh_fsmonitor is idempotent. Returns true if fsmonitor is diff --git a/read-cache-ll.h b/read-cache-ll.h index 71b87615ebc6d3..ed7d7aac38257e 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -69,14 +69,18 @@ struct cache_entry { */ #define CE_INTENT_TO_ADD (1 << 29) #define CE_SKIP_WORKTREE (1 << 30) -/* CE_EXTENDED2 is for future extension */ -#define CE_EXTENDED2 (1U << 31) +/* + * In-memory only. The cached stat data cannot be trusted, and callers which + * normally trust stat differences must verify content. This occupies the + * former never-persisted extension slot. + */ +#define CE_CONTENT_CHECK_REQUIRED (1U << 31) #define CE_EXTENDED_FLAGS (CE_INTENT_TO_ADD | CE_SKIP_WORKTREE) /* * Safeguard to avoid saving wrong flags: - * - CE_EXTENDED2 won't get saved until its semantic is known + * - CE_CONTENT_CHECK_REQUIRED is transient and must not be saved * - Bits in 0x0000FFFF have been saved in ce_flags already * - Bits in 0x003F0000 are currently in-memory flags */ @@ -120,7 +124,9 @@ static inline unsigned create_ce_flags(unsigned stage) #define ce_stage(ce) ((CE_STAGEMASK & (ce)->ce_flags) >> CE_STAGESHIFT) #define ce_uptodate(ce) ((ce)->ce_flags & CE_UPTODATE) #define ce_skip_worktree(ce) ((ce)->ce_flags & CE_SKIP_WORKTREE) -#define ce_mark_uptodate(ce) ((ce)->ce_flags |= CE_UPTODATE) +#define ce_mark_uptodate(ce) \ + ((ce)->ce_flags = ((ce)->ce_flags | CE_UPTODATE) & \ + ~CE_CONTENT_CHECK_REQUIRED) #define ce_intent_to_add(ce) ((ce)->ce_flags & CE_INTENT_TO_ADD) #define cache_entry_size(len) (offsetof(struct cache_entry,name) + (len) + 1) @@ -431,6 +437,13 @@ int is_racy_timestamp(const struct index_state *istate, int has_racy_timestamp(struct index_state *istate); int ie_match_stat(struct index_state *, const struct cache_entry *, struct stat *, unsigned int); int ie_modified(struct index_state *, const struct cache_entry *, struct stat *, unsigned int); +/* + * Unlike ie_match_stat(), verify content for marked non-gitlinks. Ordinary + * entries, including unmarked zero-stat entries, retain stat-only matching. + */ +int ie_match_stat_with_content_check(struct index_state *, + const struct cache_entry *, + struct stat *, unsigned int); int match_stat_data_racy(const struct index_state *istate, const struct stat_data *sd, struct stat *st); diff --git a/read-cache.c b/read-cache.c index 6c449f393d8d4d..fe989ce636003b 100644 --- a/read-cache.c +++ b/read-cache.c @@ -492,6 +492,32 @@ int ie_modified(struct index_state *istate, return 0; } +int ie_match_stat_with_content_check(struct index_state *istate, + const struct cache_entry *ce, + struct stat *st, unsigned int options) +{ + struct cache_entry *current; + int changed, pos; + + if (S_ISGITLINK(ce->ce_mode) || + !(ce->ce_flags & CE_CONTENT_CHECK_REQUIRED)) + return ie_match_stat(istate, ce, st, options); + + changed = ie_modified(istate, ce, st, options); + if (changed) + return changed; + + pos = index_name_pos(istate, ce->name, ce_namelen(ce)); + if (pos < 0 || istate->cache[pos] != ce) + return 0; + + current = istate->cache[pos]; + fill_stat_data(¤t->ce_stat_data, st); + current->ce_flags |= CE_UPDATE_IN_BASE; + istate->cache_changed |= CE_ENTRY_CHANGED; + return 0; +} + static int cache_name_stage_compare(const char *name1, int len1, int stage1, const char *name2, int len2, int stage2) { diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 6b08ba8f078d00..f5dae8ecfcc485 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -3,15 +3,52 @@ #include "test-tool.h" #include "config.h" #include "environment.h" +#include "fsmonitor.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" +static int test_fsmonitor_content_recovery(const char *path) +{ + struct index_state *istate; + struct cache_entry *ce; + struct stat_data empty = { 0 }; + struct stat st; + int pos; + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + istate = the_repository->index; + pos = index_name_pos(istate, path, strlen(path)); + if (pos < 0) + return error("path is not indexed: %s", path); + ce = istate->cache[pos]; + if (lstat(path, &st)) + return error_errno("unable to stat indexed path"); + + fsmonitor_invalidate_cache_entry(ce); + if (memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("invalidation did not poison cached stat data"); + if (ie_match_stat_with_content_check(istate, ce, &st, 0)) + return error("clean content did not match"); + if (!memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("verified clean entry retained poisoned stat data"); + if (!(ce->ce_flags & CE_UPDATE_IN_BASE) || + !(istate->cache_changed & CE_ENTRY_CHANGED)) + return error("verified stat refresh was not marked for persistence"); + return 0; +} + int cmd__read_cache(int argc, const char **argv) { int i, cnt = 1; const char *name = NULL; + if (argc == 3 && + !strcmp(argv[1], "--test-fsmonitor-content-recovery")) + return test_fsmonitor_content_recovery(argv[2]); if (argc > 1 && skip_prefix(argv[1], "--print-and-refresh=", &name)) { argc--; argv++; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 93973ed25a448b..1160612ea82177 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -477,4 +477,146 @@ test_expect_success 'status succeeds with sparse index' ' ) ' +test_expect_success 'reported events poison weak stat-cache matches' ' + test_create_repo reported-event && + ( + cd reported-event && + printf "aaaa\n" >tracked && + printf "clean\n" >clean && + git add tracked clean && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + test-tool chmtime =-60 tracked clean && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked) && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0clean\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime tracked && + + GIT_OPTIONAL_LOCKS=0 git diff-index --name-status HEAD \ + >.git/diff-index && + test_grep "^M.*tracked$" .git/diff-index && + test_grep ! "clean$" .git/diff-index && + git status --porcelain=v2 >.git/status && + test_grep "^1 \.M .* tracked$" .git/status && + test_grep ! " clean$" .git/status + ) +' + +test_expect_success 'reported path permits apply --index content match' ' + test_create_repo apply-marker && + ( + cd apply-marker && + test_commit base tracked && + test_write_lines next >tracked && + git diff >../apply-marker.patch && + git checkout -- tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git apply --index ../apply-marker.patch + ) +' + +test_expect_success 'reported path permits checkout-index content match' ' + test_create_repo checkout-marker && + ( + cd checkout-marker && + test_commit base tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git checkout-index tracked + ) +' + +test_expect_success CASE_INSENSITIVE_FS \ + 'reported path permits case-folded unpack match' ' + test_create_repo icase-marker && + ( + cd icase-marker && + test_write_lines same >foo && + git add foo && + git commit -m base && + base=$(git rev-parse HEAD) && + git mv foo intermediate && + git mv intermediate FOO && + git commit -m target && + target=$(git rev-parse HEAD) && + git checkout "$base" && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0foo\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git read-tree -m -u "$target" && + echo FOO >expect && + git ls-files >actual && + test_cmp expect actual + ) +' + +test_expect_success 'reported unchanged path avoids reset checkout' ' + test_create_repo reset-marker && + ( + cd reset-marker && + test_commit base tracked && + git config core.trustctime false && + git config core.checkStat minimal && + test-tool chmtime =-60 tracked && + git update-index --refresh && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + before=$(test-tool chmtime --get tracked) && + git reset --hard HEAD && + after=$(test-tool chmtime --get tracked) && + test "$before" = "$after" + ) +' + +test_expect_success 'ordinary zero-stat entries retain diff-index behavior' ' + test_create_repo ordinary-zero-stat && + ( + cd ordinary-zero-stat && + echo content >tracked && + git add tracked && + git commit -m base && + oid=$(git rev-parse :tracked) && + git update-index --cacheinfo 100644,$oid,tracked && + git -c core.fsmonitor=false diff-index --name-status HEAD >actual && + test_grep "^M.*tracked$" actual + ) +' + +test_expect_success 'verified reported paths restore poisoned stat data' ' + test_create_repo fsmonitor-stat-recovery && + ( + cd fsmonitor-stat-recovery && + echo content >tracked && + git add tracked && + git commit -m base && + test-tool read-cache \ + --test-fsmonitor-content-recovery tracked + ) +' + test_done diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 86195770e97779..de7134af8b5c1a 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1353,12 +1353,31 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep ! -q "fsmonitor_refresh_callback.*FILE-4-A.*pos" "$PWD/file_case_wrong-try2.log" && test_grep ! -q "fsmonitor_refresh_callback.*file-4-a.*pos" "$PWD/file_case_wrong-try2.log" && - # FSM refresh saw nothing, so it will mark all files as valid, - # so they should now have "h" status. + # A late directory event can arrive without repeating the file + # events checked above. Such an event invalidates its entire cone, + # so those entries remain "H" until the next quiet refresh. git -C file_case_wrong ls-files -f >"$PWD/file_case_wrong-lsf2.out" && - test_grep -q "h dir1/dir2/dir3/file-3-a" "$PWD/file_case_wrong-lsf2.out" && - test_grep -q "h dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-lsf2.out" && + if test_grep -E -q \ + "fsmonitor_refresh_callback .dir1(/dir2(/dir3)?)?/?. .*pos " \ + "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null + then + expected_3=H + else + expected_3=h + fi && + test_grep -q "$expected_3 dir1/dir2/dir3/file-3-a" \ + "$PWD/file_case_wrong-lsf2.out" && + if test_grep -E -q \ + "fsmonitor_refresh_callback .dir1(/dir2(/dir4)?)?/?. .*pos " \ + "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null + then + expected_4=H + else + expected_4=h + fi && + test_grep -q "$expected_4 dir1/dir2/dir4/FILE-4-A" \ + "$PWD/file_case_wrong-lsf2.out" && # We now have files with clean content, but with case-incorrect diff --git a/unpack-trees.c b/unpack-trees.c index 154d6d40a15934..44d3567c83844b 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -2241,7 +2241,8 @@ static int verify_uptodate_1(const struct cache_entry *ce, if (!lstat(ce->name, &st)) { int flags = CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE; - unsigned changed = ie_match_stat(o->src_index, ce, &st, flags); + unsigned changed = ie_match_stat_with_content_check( + o->src_index, ce, &st, flags); if (submodule_from_ce(ce)) { int r = check_submodule_move_head(ce, @@ -2407,7 +2408,9 @@ static int icase_exists(struct unpack_trees_options *o, const char *name, int le const struct cache_entry *src; src = index_file_exists(o->src_index, name, len, 1); - return src && !ie_match_stat(o->src_index, src, st, CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE); + return src && !ie_match_stat_with_content_check( + o->src_index, src, st, + CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_SKIP_WORKTREE); } enum absent_checking_type { @@ -3038,7 +3041,10 @@ int oneway_merge(const struct cache_entry * const *src, !(old->ce_flags & CE_FSMONITOR_VALID)) { struct stat st; if (lstat(old->name, &st) || - ie_match_stat(o->src_index, old, &st, CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE)) + ie_match_stat_with_content_check( + o->src_index, old, &st, + CE_MATCH_IGNORE_VALID | + CE_MATCH_IGNORE_SKIP_WORKTREE)) update |= CE_UPDATE; } if (o->update && S_ISGITLINK(old->ce_mode) && From 1a19c1ce5d8988663cddc0bb2a90ce66bf862e37 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:13:45 -0700 Subject: [PATCH 134/403] status: hash tracked files through anchored descriptors Size and modification time cannot establish that worktree content matches an indexed blob. A replacement can preserve those values, and reopening a pathname after hashing can reach a different object. Open a regular file relative to its pinned parent and compare the pathname observation with the held descriptor before hashing. Stream the Git blob header and exact observed bytes using the repository hash algorithm, reject short reads and concurrent appends, then repeat the descriptor and pathname identity checks and reopen the final component. Record a matching object as raw-clean only after every check succeeds. A multiply-linked clean file is not persistable, because an unobserved alias can later change its contents. Structural errors, replacements, unsupported anchored opens, and hash mismatches remain explicit results. Register the file verifier with both Make and Meson. Its 256 KiB hash buffer is supplied by its caller; this patch does not yet classify conversion attributes, run a worker, or apply an index update. Signed-off-by: Taylor Blau --- Makefile | 1 + meson.build | 1 + semantic-verify-file.c | 210 +++++++++++++++++++++++++++++++++++++ semantic-verify-internal.h | 24 +++++ semantic-verify.h | 15 +++ 5 files changed, 251 insertions(+) create mode 100644 semantic-verify-file.c create mode 100644 semantic-verify.h diff --git a/Makefile b/Makefile index afb57d02604aec..b7ee8bcb8e9379 100644 --- a/Makefile +++ b/Makefile @@ -1317,6 +1317,7 @@ LIB_OBJS += resolve-undo.o LIB_OBJS += revision.o LIB_OBJS += run-command.o LIB_OBJS += send-pack.o +LIB_OBJS += semantic-verify-file.o LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o LIB_OBJS += sequencer.o diff --git a/meson.build b/meson.build index 35c693e1f1503e..194f2f11456e93 100644 --- a/meson.build +++ b/meson.build @@ -523,6 +523,7 @@ libgit_sources = [ 'run-command.c', 'send-pack.c', 'sequencer.c', + 'semantic-verify-file.c', 'semantic-verify-path.c', 'semantic-verify-root.c', 'serve.c', diff --git a/semantic-verify-file.c b/semantic-verify-file.c new file mode 100644 index 00000000000000..5a06809060e58d --- /dev/null +++ b/semantic-verify-file.c @@ -0,0 +1,210 @@ +#include "git-compat-util.h" +#include "environment.h" +#include "object-file.h" +#include "path-namespace.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static int mode_matches_ce(struct repository *repo, + const struct cache_entry *ce, + const struct stat *st) +{ + if (!S_ISREG(st->st_mode)) + return 0; + if (repo_trust_executable_bit(repo) && + ((ce->ce_mode ^ st->st_mode) & 0100)) + return 0; + return 1; +} + +static int hash_raw_blob(int fd, size_t size, + const struct git_hash_algo *algo, + struct object_id *oid, void *buffer, + size_t *bytes_hashed) +{ + struct git_hash_ctx ctx; + char header[MAX_HEADER_LEN]; + int header_len; + size_t remaining = size; + + header_len = format_object_header(header, sizeof(header), OBJ_BLOB, size); + git_hash_init(&ctx, algo); + git_hash_update(&ctx, header, header_len); + + while (remaining) { + size_t want = remaining < SEMANTIC_VERIFY_HASH_BUFFER_SIZE ? + remaining : SEMANTIC_VERIFY_HASH_BUFFER_SIZE; + ssize_t nr = xread(fd, buffer, want); + + if (nr < 0) + return -1; + if (!nr) { + errno = EIO; + return -1; + } + git_hash_update(&ctx, buffer, nr); + remaining -= nr; + *bytes_hashed += nr; + } + + /* Do not silently omit an append which raced with the declared size. */ + { + char extra; + ssize_t nr = xread(fd, &extra, 1); + + if (nr < 0) + return -1; + if (nr) { + errno = EAGAIN; + return -1; + } + } + + git_hash_final_oid(oid, &ctx); + return 0; +} + +static unsigned int classify_resolve_error(int error) +{ + if (error == ENOENT) + return SEMANTIC_VERIFY_RAW_MODIFIED; + if (error == ELOOP || error == ENOTDIR || error == EXDEV || + error == EINVAL) + return SEMANTIC_VERIFY_STRUCTURAL; + return SEMANTIC_VERIFY_ERROR; +} +#endif + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +void semantic_verify_file_at(int parent_fd, const char *basename, + const struct stat *observed, + dev_t root_dev, + const struct cache_entry *ce, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result) +{ + struct stat path_before = *observed, fd_before, fd_after, path_after; + struct object_id oid; + int fd = -1; + int saved_errno; + + memset(result, 0, sizeof(*result)); + if (!mode_matches_ce(repo, ce, &path_before)) { + result->kind = SEMANTIC_VERIFY_RAW_MODIFIED; + return; + } + if (path_before.st_size < 0 || + (uintmax_t)path_before.st_size > (uintmax_t)SIZE_MAX) { + result->kind = SEMANTIC_VERIFY_SENSITIVE; + return; + } + + fd = semantic_verify_openat(parent_fd, basename, + O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0) { + result->error = errno; + result->kind = errno == ENOENT || errno == ENOTDIR || + errno == ELOOP ? + SEMANTIC_VERIFY_UNSTABLE : SEMANTIC_VERIFY_ERROR; + return; + } + if (fstat(fd, &fd_before)) + goto unstable; + if (fd_before.st_dev != root_dev || + !path_namespace_stat_equal(&path_before, &fd_before)) { + errno = EAGAIN; + goto unstable; + } + if (hash_raw_blob(fd, (size_t)fd_before.st_size, repo->hash_algo, &oid, + buffer, &result->bytes_hashed)) + goto unstable; + if (fstat(fd, &fd_after)) + goto unstable; + if (fstatat(parent_fd, basename, &path_after, AT_SYMLINK_NOFOLLOW)) + goto unstable; + if (!path_namespace_stat_equal(&fd_before, &fd_after) || + !path_namespace_stat_equal(&fd_after, &path_after)) { + errno = EAGAIN; + goto unstable; + } + if (path_namespace_reopen_component( + parent_fd, basename, O_RDONLY | O_NONBLOCK | O_NOFOLLOW, + semantic_verify_openat, &fd_after)) + goto unstable; + close(fd); + + if (!oideq(&oid, &ce->oid)) { + result->kind = SEMANTIC_VERIFY_RAW_MODIFIED; + return; + } + result->kind = SEMANTIC_VERIFY_RAW_CLEAN; + result->persistable = fd_after.st_nlink == 1; + fill_stat_data(&result->stat_data, &fd_after); + return; + +unstable: + saved_errno = errno; + close(fd); + result->kind = SEMANTIC_VERIFY_UNSTABLE; + result->error = saved_errno; +} + +void semantic_verify_file(struct semantic_verify_root *root, + struct semantic_verify_path *path, + const struct cache_entry *ce, size_t cache_pos, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result) +{ + struct stat path_before; + const char *basename; + int parent_fd; + + memset(result, 0, sizeof(*result)); + if (semantic_verify_resolve_parent(path, ce->name, cache_pos, + &parent_fd, &basename)) { + result->error = errno; + result->kind = classify_resolve_error(errno); + return; + } + if (fstatat(parent_fd, basename, &path_before, AT_SYMLINK_NOFOLLOW)) { + result->error = errno; + result->kind = errno == ENOENT || errno == ENOTDIR ? + SEMANTIC_VERIFY_RAW_MODIFIED : SEMANTIC_VERIFY_ERROR; + return; + } + semantic_verify_file_at(parent_fd, basename, &path_before, + root->stat.st_dev, ce, repo, buffer, result); +} +#else +static void semantic_verify_file_unavailable( + struct semantic_verify_file_result *result) +{ + memset(result, 0, sizeof(*result)); + result->kind = SEMANTIC_VERIFY_ERROR; + result->error = ENOSYS; +} + +void semantic_verify_file_at( + int parent_fd UNUSED, const char *basename UNUSED, + const struct stat *observed UNUSED, + dev_t root_dev UNUSED, + const struct cache_entry *ce UNUSED, + struct repository *repo UNUSED, void *buffer UNUSED, + struct semantic_verify_file_result *result) +{ + semantic_verify_file_unavailable(result); +} + +void semantic_verify_file( + struct semantic_verify_root *root UNUSED, + struct semantic_verify_path *path UNUSED, + const struct cache_entry *ce UNUSED, size_t cache_pos UNUSED, + struct repository *repo UNUSED, void *buffer UNUSED, + struct semantic_verify_file_result *result) +{ + semantic_verify_file_unavailable(result); +} +#endif diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index 1f881af44fba6f..a1451feb0e0e30 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -27,8 +27,12 @@ #endif struct repository; +struct cache_entry; +struct git_hash_algo; struct semantic_verify_path; +#define SEMANTIC_VERIFY_HASH_BUFFER_SIZE (256 * 1024) + struct semantic_verify_root { int fd; char *path; @@ -50,4 +54,24 @@ void semantic_verify_path_free(struct semantic_verify_path *path, unsigned int *namespace_unstable, size_t *namespace_unstable_from); +struct semantic_verify_file_result { + struct stat_data stat_data; + size_t bytes_hashed; + int error; + unsigned int kind; + unsigned int persistable; +}; + +void semantic_verify_file(struct semantic_verify_root *root, + struct semantic_verify_path *path, + const struct cache_entry *ce, size_t cache_pos, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result); +void semantic_verify_file_at(int parent_fd, const char *basename, + const struct stat *observed, + dev_t root_dev, + const struct cache_entry *ce, + struct repository *repo, void *buffer, + struct semantic_verify_file_result *result); + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify.h b/semantic-verify.h new file mode 100644 index 00000000000000..17b13de3765c53 --- /dev/null +++ b/semantic-verify.h @@ -0,0 +1,15 @@ +#ifndef SEMANTIC_VERIFY_H +#define SEMANTIC_VERIFY_H + +enum semantic_verify_kind { + SEMANTIC_VERIFY_UNCHECKED = 0, + SEMANTIC_VERIFY_SKIPPED, + SEMANTIC_VERIFY_RAW_CLEAN, + SEMANTIC_VERIFY_RAW_MODIFIED, + SEMANTIC_VERIFY_SENSITIVE, + SEMANTIC_VERIFY_STRUCTURAL, + SEMANTIC_VERIFY_UNSTABLE, + SEMANTIC_VERIFY_ERROR, +}; + +#endif /* SEMANTIC_VERIFY_H */ From 63597e21a185e1187618d12c1a3a04efee616206 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 11:30:07 -0700 Subject: [PATCH 135/403] fsmonitor: support provider-wide invalidation A filesystem-monitor provider can know that its event history is incomplete without being able to identify every affected path. Treating such a response as an ordinary path leaves tracked entries, cached attributes, and untracked-cache state falsely valid. Reserve // as a provider-only global invalidation record. It cannot collide with a worktree-relative path. When the client receives it, discard cached attribute stacks and untracked-cache state, invalidate every tracked entry, and mark the fsmonitor extension changed. Recognize the existing trivial response only when a complete record consists of a single slash and NUL, newline, or carriage-return terminators. This prevents the new double-slash record from being discarded as a trivial response while preserving existing hook forms. Add a hook regression in t/t7519-status-fsmonitor.sh that changes a tracked file, restores its timestamp, emits the global marker, and requires status to report the change. Global invalidation intentionally scans the tracked index. Signed-off-by: Taylor Blau --- attr.c | 5 +++++ attr.h | 3 +++ dir.c | 9 +++++++++ dir.h | 1 + fsmonitor-ll.h | 3 +++ fsmonitor.c | 39 ++++++++++++++++++++++++++++++++----- t/t7519-status-fsmonitor.sh | 33 +++++++++++++++++++++++++++++++ 7 files changed, 88 insertions(+), 5 deletions(-) diff --git a/attr.c b/attr.c index 0e63f1b6de8f53..87808ba3755d04 100644 --- a/attr.c +++ b/attr.c @@ -536,6 +536,11 @@ static void drop_all_attr_stacks(void) vector_unlock(); } +void git_attr_invalidate_all(void) +{ + drop_all_attr_stacks(); +} + struct attr_check *attr_check_alloc(void) { struct attr_check *c = xcalloc(1, sizeof(struct attr_check)); diff --git a/attr.h b/attr.h index a04a5210921e22..cca94379362f10 100644 --- a/attr.h +++ b/attr.h @@ -227,6 +227,9 @@ enum git_attr_direction { }; void git_attr_set_direction(enum git_attr_direction new_direction); +/* Discard cached attributes after a provider-wide invalidation. */ +void git_attr_invalidate_all(void); + void attr_start(void); /* Return the system gitattributes file. */ diff --git a/dir.c b/dir.c index 95d8a1cce90f77..ad8f43f59536f8 100644 --- a/dir.c +++ b/dir.c @@ -1112,6 +1112,15 @@ static void invalidate_gitignore(struct untracked_cache *uc, do_invalidate_gitignore(dir); } +void untracked_cache_invalidate_all(struct index_state *istate) +{ + if (!istate->untracked || !istate->untracked->root) + return; + invalidate_gitignore(istate->untracked, istate->untracked->root); + istate->untracked->use_fsmonitor = 0; + istate->cache_changed |= UNTRACKED_CHANGED; +} + static void invalidate_directory(struct untracked_cache *uc, struct untracked_cache_dir *dir) { diff --git a/dir.h b/dir.h index 83e0f648a81f36..815225ee147e01 100644 --- a/dir.h +++ b/dir.h @@ -597,6 +597,7 @@ int cmp_dir_entry(const void *p1, const void *p2); int check_dir_entry_contains(const struct dir_entry *out, const struct dir_entry *in); void untracked_cache_invalidate_path(struct index_state *, const char *, int safe_path); +void untracked_cache_invalidate_all(struct index_state *); /* * Invalidate the untracked-cache for this path, but first strip * off a trailing slash, if present. diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 0504ca07d62fa1..a409b15e68bc51 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -4,6 +4,9 @@ struct index_state; struct strbuf; +/* A provider-only marker; worktree-relative paths cannot begin with '/'. */ +#define FSMONITOR_PATH_GLOBAL_INVALIDATE "//" + extern struct trace_key trace_fsmonitor; /* diff --git a/fsmonitor.c b/fsmonitor.c index 175377982d6ec9..6c119b17bd391e 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -2,6 +2,7 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "git-compat-util.h" +#include "attr.h" #include "config.h" #include "dir.h" #include "environment.h" @@ -436,12 +437,26 @@ static size_t handle_path_with_trailing_slash( static void fsmonitor_refresh_callback(struct index_state *istate, char *name) { int len = strlen(name); - int pos = index_name_pos(istate, name, len); + int pos; size_t nr_in_cone; trace_printf_key(&trace_fsmonitor, "fsmonitor_refresh_callback '%s' (pos %d)", - name, pos); + name, !strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE) ? + -1 : index_name_pos(istate, name, len)); + if (!strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE)) { + unsigned int i; + + git_attr_invalidate_all(); + untracked_cache_invalidate_all(istate); + for (i = 0; i < istate->cache_nr; i++) + fsmonitor_invalidate_cache_entry(istate->cache[i]); + istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", istate->repo, + "apply/global-invalidation", 1); + return; + } + pos = index_name_pos(istate, name, len); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); @@ -504,6 +519,19 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) */ static int fsmonitor_force_update_threshold = 100; +static int is_trivial_response_at(const struct strbuf *result, size_t offset) +{ + size_t i; + + if (offset >= result->len || result->buf[offset] != '/') + return 0; + for (i = offset + 1; i < result->len; i++) + if (result->buf[i] != '\0' && result->buf[i] != '\n' && + result->buf[i] != '\r') + return 0; + return 1; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -552,7 +580,7 @@ void refresh_fsmonitor(struct index_state *istate) buf = query_result.buf; strbuf_addstr(&last_update_token, buf); bol = last_update_token.len + 1; - is_trivial = query_result.buf[bol] == '/'; + is_trivial = is_trivial_response_at(&query_result, bol); if (is_trivial) trace2_data_intmax("fsm_client", NULL, "query/trivial-response", 1); @@ -613,7 +641,8 @@ void refresh_fsmonitor(struct index_state *istate) query_success = 0; } else { bol = last_update_token.len + 1; - is_trivial = query_result.buf[bol] == '/'; + is_trivial = is_trivial_response_at( + &query_result, bol); } } else if (hook_version < 0) { hook_version = HOOK_INTERFACE_VERSION1; @@ -627,7 +656,7 @@ void refresh_fsmonitor(struct index_state *istate) r, HOOK_INTERFACE_VERSION1, istate->fsmonitor_last_update, &query_result); if (query_success) - is_trivial = query_result.buf[0] == '/'; + is_trivial = is_trivial_response_at(&query_result, 0); } if (is_trivial) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 1160612ea82177..a0a20aa80e4a8f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -619,4 +619,37 @@ test_expect_success 'verified reported paths restore poisoned stat data' ' ) ' +test_expect_success 'provider global marker invalidates every tracked entry' ' + test_create_repo global-invalidate && + ( + cd global-invalidate && + printf "aaaa\n" >tracked && + git add tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + test-tool chmtime =-60 tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked) && + test_hook --setup fsmonitor-test <<-\EOF && + if test -f .git/global + then + printf "token1\0//\0" + else + printf "token0\0" + fi + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime tracked && + > .git/global && + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* tracked$" .git/actual + ) +' + test_done From 7b294f9bcc4933e4f1077ba55d2d7deebabd4880 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:45:25 -0700 Subject: [PATCH 136/403] convert: support independent conversion attribute checks convert_attrs() evaluates conversion attributes through a single process-global attr_check. Sharing that mutable check between workers would let concurrent path evaluations overwrite each other's results. Separate singleton initialization from attribute evaluation. Provide an allocator for the same six conversion attributes and an evaluator that accepts a caller-owned check. Keep convert_attrs() on its existing initialized singleton, so ordinary filters, encoding, ident expansion, and line-ending decisions retain their previous behavior. Each concurrent caller must provide a distinct six-attribute check while global conversion and attribute state remains unchanged. This patch does not introduce the raw-safe predicate, prepare conversion state for workers, or claim that existing conversion tests were run at this intermediate commit. Signed-off-by: Taylor Blau --- convert.c | 45 +++++++++++++++++++++++++++++++++++++++------ convert.h | 19 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/convert.c b/convert.c index 036506842c3d41..9f44eba7d16439 100644 --- a/convert.c +++ b/convert.c @@ -1318,11 +1318,8 @@ static int git_path_check_ident(struct attr_check_item *check) static struct attr_check *check; -void convert_attrs(struct index_state *istate, - struct conv_attrs *ca, const char *path) +static void convert_attrs_init(void) { - struct attr_check_item *ccheck = NULL; - if (!check) { check = attr_check_initl("crlf", "ident", "filter", "eol", "text", "working-tree-encoding", @@ -1330,9 +1327,26 @@ void convert_attrs(struct index_state *istate, user_convert_tail = &user_convert; repo_config(the_repository, read_convert_config, NULL); } +} - git_check_attr(istate, path, check); - ccheck = check->items; +struct attr_check *convert_attrs_check_alloc(void) +{ + return attr_check_initl("crlf", "ident", "filter", + "eol", "text", "working-tree-encoding", + NULL); +} + +void convert_attrs_with_check(struct index_state *istate, + struct conv_attrs *ca, const char *path, + struct attr_check *attr_check) +{ + struct attr_check_item *ccheck; + + if (!attr_check || attr_check->nr != 6) + BUG("invalid per-thread conversion attribute check"); + + git_check_attr(istate, path, attr_check); + ccheck = attr_check->items; ca->crlf_action = git_path_check_crlf(ccheck + 4); if (ca->crlf_action == CRLF_UNDEFINED) ca->crlf_action = git_path_check_crlf(ccheck + 0); @@ -1363,6 +1377,25 @@ void convert_attrs(struct index_state *istate, ca->crlf_action = CRLF_AUTO_INPUT; } +void convert_attrs(struct index_state *istate, + struct conv_attrs *ca, const char *path) +{ + convert_attrs_init(); + convert_attrs_with_check(istate, ca, path, check); +} + +int convert_attrs_has_clean_filter(const struct conv_attrs *ca) +{ + return ca->drv && + (ca->drv->clean || ca->drv->process || ca->drv->required); +} + +int convert_attrs_are_raw_safe(const struct conv_attrs *ca) +{ + return !ca->drv && !ca->working_tree_encoding && !ca->ident && + ca->crlf_action == CRLF_BINARY; +} + void reset_parsed_attributes(void) { struct convert_driver *drv, *next; diff --git a/convert.h b/convert.h index 0a6e4086b8f932..b855919fa0bd3a 100644 --- a/convert.h +++ b/convert.h @@ -8,6 +8,7 @@ #include "string-list.h" struct index_state; +struct attr_check; struct strbuf; #define CONV_EOL_RNDTRP_DIE (1<<0) /* Die if CRLF to LF to CRLF is different */ @@ -91,6 +92,24 @@ struct conv_attrs { void convert_attrs(struct index_state *istate, struct conv_attrs *ca, const char *path); +/* Allocate the exact six-attribute check used by convert_attrs(). */ +struct attr_check *convert_attrs_check_alloc(void); + +/* + * Thread-friendly variant. Each concurrent caller must supply a distinct + * check allocated by convert_attrs_check_alloc(), and conversion/attribute + * global state must remain immutable until all callers have finished. + */ +void convert_attrs_with_check(struct index_state *istate, + struct conv_attrs *ca, const char *path, + struct attr_check *check); + +/* True when the selected driver can affect conversion into the index. */ +int convert_attrs_has_clean_filter(const struct conv_attrs *ca); + +/* True only when hashing the worktree bytes verbatim is exact. */ +int convert_attrs_are_raw_safe(const struct conv_attrs *ca); + extern enum eol core_eol; extern char *check_roundtrip_encoding; const char *get_cached_convert_stats_ascii(struct index_state *istate, From e9cf3b556fd31e006d8f247d63fb92134901a442 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:41:23 -0700 Subject: [PATCH 137/403] fsmonitor: keep multiply-linked files invalid A pathname monitor cannot establish that every name for a multiply-linked regular file lies inside its watch cone. Persisting CE_FSMONITOR_VALID after checking the tracked name can therefore hide a later write through an unmonitored hardlink. Use fsmonitor_stat_can_be_valid() to exclude regular files with more than one link from persistent fsmonitor validity when the platform reports real link counts. Apply that decision where index refresh, threaded preload, and diff-files first consume an actual stat. Preserve CE_UPTODATE for the current process and retain existing persistent validity for single-link and nonregular entries. Windows and Cygwin synthesize their link counts, so preserve their existing fsmonitor behavior without claiming the hardlink guarantee there. Add a hardlink regression in t/t7519-status-fsmonitor.sh on platforms with trustworthy stat metadata. It keeps a tracked hardlink outside the fsmonitor-valid bitmap and checks that a write through an alias outside the worktree appears in status. The deliberate cost is another stat in a subsequent process. Signed-off-by: Taylor Blau --- diff-lib.c | 7 ++++++- fsmonitor.h | 13 +++++++++++++ preload-index.c | 3 ++- read-cache.c | 6 ++++-- t/t7519-status-fsmonitor.sh | 31 +++++++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/diff-lib.c b/diff-lib.c index 748f9ec3f3e7e7..33b4233738cac3 100644 --- a/diff-lib.c +++ b/diff-lib.c @@ -130,6 +130,7 @@ void run_diff_files(struct rev_info *revs, unsigned int option) entries = istate->cache_nr; for (i = 0; i < entries; i++) { unsigned int oldmode, newmode; + int fsmonitor_valid = 0; struct cache_entry *ce = istate->cache[i]; int changed; unsigned dirty_submodule = 0; @@ -249,6 +250,8 @@ void run_diff_files(struct rev_info *revs, unsigned int option) if (ce->ce_flags & (CE_VALID | CE_FSMONITOR_VALID)) { changed = 0; newmode = ce->ce_mode; + fsmonitor_valid = + !!(ce->ce_flags & CE_FSMONITOR_VALID); } else { struct stat st; @@ -274,11 +277,13 @@ void run_diff_files(struct rev_info *revs, unsigned int option) changed = match_stat_with_submodule(&revs->diffopt, ce, &st, ce_option, &dirty_submodule); newmode = ce_mode_from_stat(revs->repo, ce, st.st_mode); + fsmonitor_valid = fsmonitor_stat_can_be_valid(&st); } if (!changed && !dirty_submodule) { ce_mark_uptodate(ce); - mark_fsmonitor_valid(istate, ce); + if (fsmonitor_valid) + mark_fsmonitor_valid(istate, ce); if (revs->diffopt.flags.find_copies_harder) diff_same(&revs->diffopt, newmode, &ce->oid, ce->name); diff --git a/fsmonitor.h b/fsmonitor.h index 4027ca83f2b8cd..47ce78de61c508 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -15,6 +15,19 @@ */ void fsmonitor_invalidate_cache_entry(struct cache_entry *ce); +/* + * A pathname monitor cannot prove that every name for a multiply-linked + * inode is inside its watch cone. When the platform reports real link + * counts, keep such regular files out of the persistent valid bitmap so + * that every new process checks their stat data. Platforms that synthesize + * link counts retain their existing fsmonitor behavior. The in-process + * CE_UPTODATE bit is still safe after the caller's lstat(). + */ +static inline int fsmonitor_stat_can_be_valid(const struct stat *st) +{ + return !S_ISREG(st->st_mode) || st->st_nlink <= 1; +} + /* * Check if refresh_fsmonitor has been called at least once. * refresh_fsmonitor is idempotent. Returns true if fsmonitor is diff --git a/preload-index.c b/preload-index.c index b222821b448526..6c675339285257 100644 --- a/preload-index.c +++ b/preload-index.c @@ -90,7 +90,8 @@ static void *preload_thread(void *_data) if (ie_match_stat(index, ce, &st, CE_MATCH_RACY_IS_DIRTY|CE_MATCH_IGNORE_FSMONITOR)) continue; ce_mark_uptodate(ce); - mark_fsmonitor_valid(index, ce); + if (fsmonitor_stat_can_be_valid(&st)) + mark_fsmonitor_valid(index, ce); } while (--nr > 0); if (p->progress) { struct progress_data *pd = p->progress; diff --git a/read-cache.c b/read-cache.c index fe989ce636003b..b853d79c72aa5f 100644 --- a/read-cache.c +++ b/read-cache.c @@ -199,7 +199,8 @@ void fill_stat_cache_info(struct index_state *istate, struct cache_entry *ce, st if (S_ISREG(st->st_mode)) { ce_mark_uptodate(ce); - mark_fsmonitor_valid(istate, ce); + if (fsmonitor_stat_can_be_valid(st)) + mark_fsmonitor_valid(istate, ce); } } @@ -1441,7 +1442,8 @@ static struct cache_entry *refresh_cache_ent(struct index_state *istate, */ if (!S_ISGITLINK(ce->ce_mode)) { ce_mark_uptodate(ce); - mark_fsmonitor_valid(istate, ce); + if (fsmonitor_stat_can_be_valid(&st)) + mark_fsmonitor_valid(istate, ce); } return ce; } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index a0a20aa80e4a8f..fb2fadc53d5986 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -55,6 +55,11 @@ test_lazy_prereq UNTRACKED_CACHE ' test $ret -ne 1 ' +test_lazy_prereq HARDLINKS ' + : >hardlink-a && + ln hardlink-a hardlink-b +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && @@ -652,4 +657,30 @@ test_expect_success 'provider global marker invalidates every tracked entry' ' ) ' +test_expect_success HARDLINKS,!MINGW,!CYGWIN \ + 'multiply-linked files stay fsmonitor-invalid' ' + test_when_finished "rm -f hardlink-alias" && + test_create_repo hardlink-validity && + ( + cd hardlink-validity && + echo content >tracked && + git add tracked && + git commit -m base && + ln tracked ../hardlink-alias && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + git ls-files -f >.git/flags && + test_grep "^H tracked$" .git/flags && + echo changed >>../hardlink-alias && + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* tracked$" .git/actual + ) +' + test_done From 3bf34fd0865dec55842f92746a5983e4013c382a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:15:13 -0700 Subject: [PATCH 138/403] status: classify candidates for semantic verification Comparing raw worktree bytes with an indexed object is valid only when the indexed entry is an ordinary file and conversion cannot alter its canonical content. Index promises, sparse entries, staged conflicts, intent-to-add entries, and active conversion cannot share that proof. Introduce convert_attrs_is_raw_safe() and require a binary conversion action with no filter, working-tree encoding, or ident expansion. Classify skip-worktree and assumed-valid entries as skipped; classify conflicts, intent-to-add entries, and sparse directories as structural; leave converted and nonregular files to the ordinary refresh path. Run eligible entries over one contiguous index range using an independent attribute check, a private hash buffer, and a pinned parent resolver. Collect results and replacement stat data without modifying the index. If a cached ancestor changes, downgrade affected clean results to unstable. Register the worker in both Make and Meson. This patch introduces the raw-safe predicate and internal range worker; it does not yet expose a complete proof, add the test helper, or start verifier threads. Signed-off-by: Taylor Blau --- Makefile | 1 + convert.c | 9 ++++ convert.h | 4 ++ meson.build | 1 + semantic-verify-file.c | 38 +++++++++++++ semantic-verify-internal.h | 37 +++++++++++++ semantic-verify-worker.c | 108 +++++++++++++++++++++++++++++++++++++ semantic-verify.h | 5 ++ 8 files changed, 203 insertions(+) create mode 100644 semantic-verify-worker.c diff --git a/Makefile b/Makefile index b7ee8bcb8e9379..9e159a28fb6959 100644 --- a/Makefile +++ b/Makefile @@ -1320,6 +1320,7 @@ LIB_OBJS += send-pack.o LIB_OBJS += semantic-verify-file.o LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o +LIB_OBJS += semantic-verify-worker.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o LIB_OBJS += server-info.o diff --git a/convert.c b/convert.c index 9f44eba7d16439..c0ec8781ab2a76 100644 --- a/convert.c +++ b/convert.c @@ -1396,6 +1396,15 @@ int convert_attrs_are_raw_safe(const struct conv_attrs *ca) ca->crlf_action == CRLF_BINARY; } +int convert_attrs_is_raw_safe(struct index_state *istate, const char *path, + struct attr_check *attr_check) +{ + struct conv_attrs ca; + + convert_attrs_with_check(istate, &ca, path, attr_check); + return convert_attrs_are_raw_safe(&ca); +} + void reset_parsed_attributes(void) { struct convert_driver *drv, *next; diff --git a/convert.h b/convert.h index b855919fa0bd3a..017f5966d5d260 100644 --- a/convert.h +++ b/convert.h @@ -110,6 +110,10 @@ int convert_attrs_has_clean_filter(const struct conv_attrs *ca); /* True only when hashing the worktree bytes verbatim is exact. */ int convert_attrs_are_raw_safe(const struct conv_attrs *ca); +/* True only when hashing the worktree bytes verbatim is exact. */ +int convert_attrs_is_raw_safe(struct index_state *istate, const char *path, + struct attr_check *check); + extern enum eol core_eol; extern char *check_roundtrip_encoding; const char *get_cached_convert_stats_ascii(struct index_state *istate, diff --git a/meson.build b/meson.build index 194f2f11456e93..91fde4044ad768 100644 --- a/meson.build +++ b/meson.build @@ -526,6 +526,7 @@ libgit_sources = [ 'semantic-verify-file.c', 'semantic-verify-path.c', 'semantic-verify-root.c', + 'semantic-verify-worker.c', 'serve.c', 'server-info.c', 'setup.c', diff --git a/semantic-verify-file.c b/semantic-verify-file.c index 5a06809060e58d..811b9fc43355fc 100644 --- a/semantic-verify-file.c +++ b/semantic-verify-file.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "convert.h" #include "environment.h" #include "object-file.h" #include "path-namespace.h" @@ -78,6 +79,43 @@ static unsigned int classify_resolve_error(int error) } #endif +int semantic_verify_classify_entry(struct index_state *istate, + const struct cache_entry *ce, + struct attr_check *check, + int validate_filter_scope, + struct semantic_verify_file_result *result) +{ + struct conv_attrs ca; + int attrs_resolved = 0; + + memset(result, 0, sizeof(*result)); + if (validate_filter_scope) { + convert_attrs_with_check(istate, &ca, ce->name, check); + attrs_resolved = 1; + result->active_filter = convert_attrs_has_clean_filter(&ca); + } + if (ce_skip_worktree(ce) || (ce->ce_flags & CE_VALID)) { + result->kind = SEMANTIC_VERIFY_SKIPPED; + return 0; + } + if (ce_stage(ce) || ce_intent_to_add(ce) || + S_ISSPARSEDIR(ce->ce_mode)) { + result->kind = SEMANTIC_VERIFY_STRUCTURAL; + return 0; + } + if (!S_ISREG(ce->ce_mode)) { + result->kind = SEMANTIC_VERIFY_SENSITIVE; + return 0; + } + if (!attrs_resolved) + convert_attrs_with_check(istate, &ca, ce->name, check); + if (!convert_attrs_are_raw_safe(&ca)) { + result->kind = SEMANTIC_VERIFY_SENSITIVE; + return 0; + } + return 1; +} + #if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN void semantic_verify_file_at(int parent_fd, const char *basename, const struct stat *observed, diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index a1451feb0e0e30..16729e60fbddf1 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -26,9 +26,12 @@ #define SEMANTIC_VERIFY_HAS_ANCHORED_OPEN 0 #endif +struct attr_check; struct repository; struct cache_entry; struct git_hash_algo; +struct index_state; +struct semantic_verify_result; struct semantic_verify_path; #define SEMANTIC_VERIFY_HASH_BUFFER_SIZE (256 * 1024) @@ -60,8 +63,14 @@ struct semantic_verify_file_result { int error; unsigned int kind; unsigned int persistable; + unsigned int active_filter; }; +int semantic_verify_classify_entry(struct index_state *istate, + const struct cache_entry *ce, + struct attr_check *check, + int validate_filter_scope, + struct semantic_verify_file_result *result); void semantic_verify_file(struct semantic_verify_root *root, struct semantic_verify_path *path, const struct cache_entry *ce, size_t cache_pos, @@ -74,4 +83,32 @@ void semantic_verify_file_at(int parent_fd, const char *basename, struct repository *repo, void *buffer, struct semantic_verify_file_result *result); +struct semantic_verify_stat_update { + uint32_t cache_pos; + struct stat_data stat_data; +}; + +struct semantic_verify_worker { + struct index_state *istate; + struct semantic_verify_root *root; + struct semantic_verify_result *results; + size_t start; + size_t end; + struct semantic_verify_stat_update *updates; + size_t updates_nr; + size_t updates_alloc; + size_t bytes_hashed; + size_t raw_clean; + size_t raw_modified; + size_t sensitive; + size_t structural; + size_t skipped; + size_t unstable; + size_t errors; + size_t hardlinks; + unsigned int namespace_unstable; +}; + +void semantic_verify_worker_run(struct semantic_verify_worker *worker); + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c new file mode 100644 index 00000000000000..5ebd9cd26cc8a0 --- /dev/null +++ b/semantic-verify-worker.c @@ -0,0 +1,108 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "git-compat-util.h" +#include "attr.h" +#include "convert.h" +#include "object.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" + +static void record_stat_update(struct semantic_verify_worker *worker, + uint32_t cache_pos, + const struct stat_data *stat_data) +{ + struct semantic_verify_stat_update *update; + + ALLOC_GROW(worker->updates, worker->updates_nr + 1, + worker->updates_alloc); + update = &worker->updates[worker->updates_nr++]; + update->cache_pos = cache_pos; + memcpy(&update->stat_data, stat_data, sizeof(*stat_data)); +} + +static void count_result(struct semantic_verify_worker *worker, + enum semantic_verify_kind kind) +{ + switch (kind) { + case SEMANTIC_VERIFY_SKIPPED: + worker->skipped++; + break; + case SEMANTIC_VERIFY_RAW_CLEAN: + worker->raw_clean++; + break; + case SEMANTIC_VERIFY_RAW_MODIFIED: + worker->raw_modified++; + break; + case SEMANTIC_VERIFY_SENSITIVE: + worker->sensitive++; + break; + case SEMANTIC_VERIFY_STRUCTURAL: + worker->structural++; + break; + case SEMANTIC_VERIFY_UNSTABLE: + worker->unstable++; + break; + case SEMANTIC_VERIFY_ERROR: + worker->errors++; + break; + case SEMANTIC_VERIFY_UNCHECKED: + BUG("cannot count an unchecked semantic result"); + } +} + +void semantic_verify_worker_run(struct semantic_verify_worker *worker) +{ + struct semantic_verify_path *path = + semantic_verify_path_new(worker->root); + struct attr_check *check = convert_attrs_check_alloc(); + void *buffer = xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); + size_t unstable_from = SIZE_MAX; + + for (size_t i = worker->start; i < worker->end; i++) { + struct cache_entry *ce = worker->istate->cache[i]; + struct semantic_verify_result *result = &worker->results[i]; + struct semantic_verify_file_result file; + + if (!semantic_verify_classify_entry(worker->istate, ce, check, 0, + &file)) { + result->kind = file.kind; + count_result(worker, result->kind); + continue; + } + + semantic_verify_file(worker->root, path, ce, i, + worker->istate->repo, + buffer, &file); + result->kind = file.kind; + result->error = file.error > UINT16_MAX ? EIO : file.error; + worker->bytes_hashed += file.bytes_hashed; + if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) { + if (!file.persistable) + worker->hardlinks++; + if (memcmp(&file.stat_data, &ce->ce_stat_data, + sizeof(file.stat_data))) + record_stat_update(worker, i, &file.stat_data); + } + count_result(worker, result->kind); + } + + semantic_verify_path_free(path, &worker->namespace_unstable, + &unstable_from); + if (worker->namespace_unstable) { + for (size_t i = unstable_from; i < worker->end; i++) { + struct semantic_verify_result *result = &worker->results[i]; + + if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) + continue; + result->kind = SEMANTIC_VERIFY_UNSTABLE; + result->error = EAGAIN; + worker->raw_clean--; + worker->unstable++; + } + } + + free(buffer); + attr_check_free(check); +} diff --git a/semantic-verify.h b/semantic-verify.h index 17b13de3765c53..f6b63a2c2b220b 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -12,4 +12,9 @@ enum semantic_verify_kind { SEMANTIC_VERIFY_ERROR, }; +struct semantic_verify_result { + uint16_t error; + uint8_t kind; +}; + #endif /* SEMANTIC_VERIFY_H */ From 47d224659ce2498c4144dd8ea183ae618e95a501 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:42:25 -0700 Subject: [PATCH 139/403] fsmonitor--daemon: invalidate globally for worktree hardlink events Darwin FSEvents identifies the pathname associated with a hardlink event, not every name referring to the same inode. Invalidating only that pathname can leave another tracked hardlink trusted after its contents change. Classify the event's absolute path before handling its hardlink flags. For worktree events, enqueue the provider-wide marker introduced by S04/P02 so clients content-check the tracked set. Leave gitdir events in the existing cookie and gitdir handling; otherwise reads of hardlinked object files could repeatedly trigger global invalidation. Add a MACOS,HARDLINKS daemon regression that rejects a marker for a gitdir hardlink, then verifies the marker and correct status for a changed worktree hardlink with its timestamp restored. Signed-off-by: Taylor Blau --- compat/fsmonitor/fsm-listen-darwin.c | 25 ++++++++++++++++- t/t7527-builtin-fsmonitor.sh | 40 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index 43c3a915a0edfc..ffd8392262261b 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -138,6 +138,12 @@ static int ef_is_dropped(const FSEventStreamEventFlags ef) ef & kFSEventStreamEventFlagUserDropped); } +static int ef_is_hardlink(const FSEventStreamEventFlags ef) +{ + return ef & (kFSEventStreamEventFlagItemIsHardlink | + kFSEventStreamEventFlagItemIsLastHardlink); +} + /* * If an `xattr` change is the only reason we received this event, * then silently ignore it. Git doesn't care about xattr's. We @@ -208,6 +214,7 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, const char *slash; char *resolved = NULL; struct strbuf tmp = STRBUF_INIT; + enum fsmonitor_path_type path_type; /* * Build a list of all filesystem changes into a private/local @@ -290,7 +297,23 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, continue; } - switch (fsmonitor_classify_path_absolute(state, path_k)) { + path_type = fsmonitor_classify_path_absolute(state, path_k); + if (ef_is_hardlink(event_flags[k]) && + path_type == IS_WORKDIR_PATH) { + /* + * An event for one name does not prove that all names of the + * inode are in this watch cone. Make the client content-check + * the entire tracked set rather than trusting path-local stats. + */ + if (trace_pass_fl(&trace_fsmonitor)) + log_flags_set(path_k, event_flags[k]); + if (!batch) + batch = fsmonitor_batch__new(); + my_add_path(batch, FSMONITOR_PATH_GLOBAL_INVALIDATE); + continue; + } + + switch (path_type) { case IS_INSIDE_DOT_GIT_WITH_COOKIE_PREFIX: case IS_INSIDE_GITDIR_WITH_COOKIE_PREFIX: diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index de7134af8b5c1a..9b3d9305310848 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -53,6 +53,11 @@ test_lazy_prereq FSMONITOR_WORKS ' return $ret ' +test_lazy_prereq HARDLINKS ' + : >hardlink-a && + ln hardlink-a hardlink-b +' + if ! test_have_prereq FSMONITOR_WORKS then skip_all="filesystem does not deliver fsmonitor events (container/overlayfs?)" @@ -1408,4 +1413,39 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep -q " M dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-try3.out" ' +test_expect_success MACOS,HARDLINKS 'hardlink events invalidate all tracked paths' ' + test_when_finished "git -C hardlink-event fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo hardlink-event && + ( + cd hardlink-event && + printf "AAAA\\n" >tracked && + git add tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + git config core.trustctime false && + git config core.checkStat minimal && + cp -p tracked .git/mtime-reference && + start_daemon --tf "$PWD/../hardlink-event.trace" && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + printf "ignore\n" >.git/hardlink-source && + ln .git/hardlink-source .git/hardlink-alias && + printf "still-ignore\n" >.git/hardlink-alias && + test-tool fsmonitor-client query >.git/gitdir-query && + test_grep ! "^event: //$" ../hardlink-event.trace && + ln tracked alias && + printf "BBBB\\n" >alias && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + touch -r .git/mtime-reference alias && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^event: //$" ../hardlink-event.trace && + git fsmonitor--daemon stop + ) +' + test_done From 0d0dee5183f207e5e8e77f366f16dbe5944f8670 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:00 -0700 Subject: [PATCH 140/403] status: assemble and test serial semantic proof candidates The descriptor root, pinned path resolver, file verifier, and range worker cannot demonstrate a complete verification result until one caller coordinates their inputs and exposes the classifications. Introduce semantic_verify_prepare() and assemble one serial proof without changing any cache entry. Initialize conversion and root attribute state on the calling thread, retain per-entry results and stat updates, count each classification, and distinguish persistable single-link files from clean hardlinks. Reject sparse indexes and unavailable anchored opens without marking any entry clean. Add test-tool semantic-verify and register the library, helper, and integration suite in both build systems. The new tests exercise raw, converted, nested, modified, deleted, multiply-linked, structural, and SHA-256 entries, as well as unsupported-platform fallback. This is the first executable consumer of the proof primitives. It does not apply results to the index, create worker threads, or enable semantic verification in a production status command. Signed-off-by: Taylor Blau --- Makefile | 2 + convert.c | 7 ++ convert.h | 6 ++ meson.build | 1 + semantic-verify-internal.h | 20 +++++ semantic-verify-root.c | 13 +++ semantic-verify-worker.c | 5 +- semantic-verify.c | 142 ++++++++++++++++++++++++++++++++ semantic-verify.h | 37 +++++++++ t/helper/meson.build | 1 + t/helper/test-semantic-verify.c | 90 ++++++++++++++++++++ t/helper/test-tool.c | 1 + t/helper/test-tool.h | 1 + t/lib-semantic-verify.sh | 9 ++ t/meson.build | 1 + t/t7531-semantic-verify.sh | 104 +++++++++++++++++++++++ 16 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 semantic-verify.c create mode 100644 t/helper/test-semantic-verify.c create mode 100644 t/lib-semantic-verify.sh create mode 100755 t/t7531-semantic-verify.sh diff --git a/Makefile b/Makefile index 9e159a28fb6959..0c8545ab270abe 100644 --- a/Makefile +++ b/Makefile @@ -866,6 +866,7 @@ TEST_BUILTINS_OBJS += test-repository.o TEST_BUILTINS_OBJS += test-revision-walking.o TEST_BUILTINS_OBJS += test-run-command.o TEST_BUILTINS_OBJS += test-scrap-cache-tree.o +TEST_BUILTINS_OBJS += test-semantic-verify.o TEST_BUILTINS_OBJS += test-serve-v2.o TEST_BUILTINS_OBJS += test-sha1.o TEST_BUILTINS_OBJS += test-sha256.o @@ -1321,6 +1322,7 @@ LIB_OBJS += semantic-verify-file.o LIB_OBJS += semantic-verify-path.o LIB_OBJS += semantic-verify-root.o LIB_OBJS += semantic-verify-worker.o +LIB_OBJS += semantic-verify.o LIB_OBJS += sequencer.o LIB_OBJS += serve.o LIB_OBJS += server-info.o diff --git a/convert.c b/convert.c index c0ec8781ab2a76..f7ca4d780466e7 100644 --- a/convert.c +++ b/convert.c @@ -1336,6 +1336,13 @@ struct attr_check *convert_attrs_check_alloc(void) NULL); } +void convert_attrs_prepare(struct index_state *istate) +{ + convert_attrs_init(); + /* Prime default_attr_source() and the root attribute stack on main. */ + git_check_attr(istate, "", check); +} + void convert_attrs_with_check(struct index_state *istate, struct conv_attrs *ca, const char *path, struct attr_check *attr_check) diff --git a/convert.h b/convert.h index 017f5966d5d260..241cd65c6e02a9 100644 --- a/convert.h +++ b/convert.h @@ -92,6 +92,12 @@ struct conv_attrs { void convert_attrs(struct index_state *istate, struct conv_attrs *ca, const char *path); +/* + * Prepare conversion configuration and the default attribute source on the + * main thread before using per-thread attribute checks below. + */ +void convert_attrs_prepare(struct index_state *istate); + /* Allocate the exact six-attribute check used by convert_attrs(). */ struct attr_check *convert_attrs_check_alloc(void); diff --git a/meson.build b/meson.build index 91fde4044ad768..ff471a3a092f5a 100644 --- a/meson.build +++ b/meson.build @@ -527,6 +527,7 @@ libgit_sources = [ 'semantic-verify-path.c', 'semantic-verify-root.c', 'semantic-verify-worker.c', + 'semantic-verify.c', 'serve.c', 'server-info.c', 'setup.c', diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index 16729e60fbddf1..bf1753bf17a6cc 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -44,6 +44,7 @@ struct semantic_verify_root { int semantic_verify_root_init(struct repository *repo, struct semantic_verify_root **root_out); +int semantic_verify_root_stable(const struct semantic_verify_root *root); void semantic_verify_root_clear(struct semantic_verify_root *root); int semantic_verify_openat(int dirfd, const char *path, int flags); @@ -111,4 +112,23 @@ struct semantic_verify_worker { void semantic_verify_worker_run(struct semantic_verify_worker *worker); +struct semantic_verify_proof { + struct index_state *istate; + struct semantic_verify_root *root; + struct semantic_verify_result *results; + struct semantic_verify_stat_update *stat_updates; + size_t cache_nr; + size_t stat_updates_nr; + size_t bytes_hashed; + size_t raw_clean; + size_t raw_modified; + size_t sensitive; + size_t structural; + size_t skipped; + size_t unstable; + size_t errors; + size_t hardlinks; + unsigned int namespace_unstable; +}; + #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-root.c b/semantic-verify-root.c index e896cbe51f7c94..fbec93ac147607 100644 --- a/semantic-verify-root.c +++ b/semantic-verify-root.c @@ -144,6 +144,19 @@ int semantic_verify_root_init(struct repository *repo UNUSED, } #endif +int semantic_verify_root_stable(const struct semantic_verify_root *root) +{ + struct stat fd_stat, path_stat; + + if (!root || root->fd < 0) + return 0; + if (fstat(root->fd, &fd_stat) || lstat(root->path, &path_stat) || + !S_ISDIR(path_stat.st_mode)) + return 0; + return path_namespace_stat_equal(&root->stat, &fd_stat) && + path_namespace_stat_equal(&fd_stat, &path_stat); +} + void semantic_verify_root_clear(struct semantic_verify_root *root) { if (!root) diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index 5ebd9cd26cc8a0..47e46e8e19b0ee 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -79,7 +79,9 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) result->error = file.error > UINT16_MAX ? EIO : file.error; worker->bytes_hashed += file.bytes_hashed; if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) { - if (!file.persistable) + if (file.persistable) + result->flags |= SEMANTIC_VERIFY_PERSISTABLE; + else worker->hardlinks++; if (memcmp(&file.stat_data, &ce->ce_stat_data, sizeof(file.stat_data))) @@ -97,6 +99,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) continue; result->kind = SEMANTIC_VERIFY_UNSTABLE; + result->flags = 0; result->error = EAGAIN; worker->raw_clean--; worker->unstable++; diff --git a/semantic-verify.c b/semantic-verify.c new file mode 100644 index 00000000000000..d58666ae058d8a --- /dev/null +++ b/semantic-verify.c @@ -0,0 +1,142 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "git-compat-util.h" +#include "convert.h" +#include "object.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" +#include "trace2.h" + +static void combine_worker(struct semantic_verify_proof *proof, + struct semantic_verify_worker *worker) +{ + size_t base = proof->stat_updates_nr; + + if (worker->updates_nr) + COPY_ARRAY(proof->stat_updates + base, worker->updates, + worker->updates_nr); + proof->stat_updates_nr += worker->updates_nr; + proof->bytes_hashed += worker->bytes_hashed; + proof->raw_clean += worker->raw_clean; + proof->raw_modified += worker->raw_modified; + proof->sensitive += worker->sensitive; + proof->structural += worker->structural; + proof->skipped += worker->skipped; + proof->unstable += worker->unstable; + proof->errors += worker->errors; + proof->hardlinks += worker->hardlinks; + proof->namespace_unstable |= worker->namespace_unstable; + free(worker->updates); +} + +int semantic_verify_prepare(struct index_state *istate, + struct semantic_verify_proof **proof_out) +{ + struct semantic_verify_proof *proof; + struct semantic_verify_worker worker = { 0 }; + + if (!istate || !proof_out) + BUG("semantic_verify_prepare requires an index and output"); + + CALLOC_ARRAY(proof, 1); + proof->istate = istate; + proof->cache_nr = istate->cache_nr; + CALLOC_ARRAY(proof->results, proof->cache_nr); + *proof_out = proof; + if (!proof->cache_nr) + return 0; + if (istate->sparse_index != INDEX_EXPANDED) { + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_STRUCTURAL; + proof->structural++; + } + return 0; + } + if (semantic_verify_root_init(istate->repo, &proof->root)) { + int saved_errno = errno; + + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_ERROR; + proof->results[i].error = saved_errno > UINT16_MAX ? + EIO : saved_errno; + } + proof->errors = proof->cache_nr; + return -1; + } + + /* Initialize conversion config and default attribute state serially. */ + convert_attrs_prepare(istate); + trace2_region_enter("semantic_verify", "prepare", istate->repo); + trace2_data_intmax("semantic_verify", istate->repo, "threads", 1); + trace2_data_intmax("semantic_verify", istate->repo, + "result-bytes", sizeof(struct semantic_verify_result)); + + worker.istate = istate; + worker.root = proof->root; + worker.results = proof->results; + worker.end = proof->cache_nr; + semantic_verify_worker_run(&worker); + ALLOC_ARRAY(proof->stat_updates, worker.updates_nr); + combine_worker(proof, &worker); + + trace2_data_intmax("semantic_verify", istate->repo, + "raw-clean", proof->raw_clean); + trace2_data_intmax("semantic_verify", istate->repo, + "raw-modified", proof->raw_modified); + trace2_data_intmax("semantic_verify", istate->repo, + "sensitive", proof->sensitive); + trace2_data_intmax("semantic_verify", istate->repo, + "structural", proof->structural); + trace2_data_intmax("semantic_verify", istate->repo, + "unstable", proof->unstable); + trace2_data_intmax("semantic_verify", istate->repo, + "errors", proof->errors); + trace2_data_intmax("semantic_verify", istate->repo, + "bytes-hashed", proof->bytes_hashed); + trace2_region_leave("semantic_verify", "prepare", istate->repo); + return 0; +} + +int semantic_verify_root_is_stable(const struct semantic_verify_proof *proof) +{ + return proof && semantic_verify_root_stable(proof->root); +} + +void semantic_verify_get_stats(const struct semantic_verify_proof *proof, + struct semantic_verify_stats *stats) +{ + if (!proof || !stats) + BUG("semantic_verify_get_stats requires proof and output"); + stats->cache_nr = proof->cache_nr; + stats->stat_updates_nr = proof->stat_updates_nr; + stats->bytes_hashed = proof->bytes_hashed; + stats->raw_clean = proof->raw_clean; + stats->raw_modified = proof->raw_modified; + stats->sensitive = proof->sensitive; + stats->structural = proof->structural; + stats->skipped = proof->skipped; + stats->unstable = proof->unstable; + stats->errors = proof->errors; + stats->hardlinks = proof->hardlinks; + stats->namespace_unstable = proof->namespace_unstable; +} + +const struct semantic_verify_result *semantic_verify_result_at( + const struct semantic_verify_proof *proof, size_t cache_pos) +{ + if (!proof || cache_pos >= proof->cache_nr) + BUG("semantic verifier result position out of range"); + return &proof->results[cache_pos]; +} + +void semantic_verify_proof_clear(struct semantic_verify_proof *proof) +{ + if (!proof) + return; + semantic_verify_root_clear(proof->root); + free(proof->stat_updates); + free(proof->results); + free(proof); +} diff --git a/semantic-verify.h b/semantic-verify.h index f6b63a2c2b220b..798dad30ae5ae4 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -1,6 +1,9 @@ #ifndef SEMANTIC_VERIFY_H #define SEMANTIC_VERIFY_H +struct index_state; +struct semantic_verify_proof; + enum semantic_verify_kind { SEMANTIC_VERIFY_UNCHECKED = 0, SEMANTIC_VERIFY_SKIPPED, @@ -12,9 +15,43 @@ enum semantic_verify_kind { SEMANTIC_VERIFY_ERROR, }; +enum semantic_verify_result_flags { + /* The clean result may receive persistent fsmonitor validity. */ + SEMANTIC_VERIFY_PERSISTABLE = (1u << 0), +}; + struct semantic_verify_result { uint16_t error; uint8_t kind; + uint8_t flags; }; +struct semantic_verify_stats { + size_t cache_nr; + size_t stat_updates_nr; + size_t bytes_hashed; + size_t raw_clean; + size_t raw_modified; + size_t sensitive; + size_t structural; + size_t skipped; + size_t unstable; + size_t errors; + size_t hardlinks; + unsigned int namespace_unstable; +}; + +/* Build a proof candidate without changing the index. */ +int semantic_verify_prepare(struct index_state *istate, + struct semantic_verify_proof **proof_out); +int semantic_verify_root_is_stable( + const struct semantic_verify_proof *proof); +void semantic_verify_proof_clear(struct semantic_verify_proof *proof); + +/* Introspection used by the semantic verifier test helper. */ +void semantic_verify_get_stats(const struct semantic_verify_proof *proof, + struct semantic_verify_stats *stats); +const struct semantic_verify_result *semantic_verify_result_at( + const struct semantic_verify_proof *proof, size_t cache_pos); + #endif /* SEMANTIC_VERIFY_H */ diff --git a/t/helper/meson.build b/t/helper/meson.build index 3235f10ab8aae1..7c97bfb1e6ec51 100644 --- a/t/helper/meson.build +++ b/t/helper/meson.build @@ -59,6 +59,7 @@ test_tool_sources = [ 'test-rot13-filter.c', 'test-run-command.c', 'test-scrap-cache-tree.c', + 'test-semantic-verify.c', 'test-serve-v2.c', 'test-sha1.c', 'test-sha256.c', diff --git a/t/helper/test-semantic-verify.c b/t/helper/test-semantic-verify.c new file mode 100644 index 00000000000000..bb1cffe99201a1 --- /dev/null +++ b/t/helper/test-semantic-verify.c @@ -0,0 +1,90 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "test-tool.h" +#include "config.h" +#include "parse-options.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "setup.h" + +static const char *kind_name(enum semantic_verify_kind kind) +{ + switch (kind) { + case SEMANTIC_VERIFY_UNCHECKED: + return "unchecked"; + case SEMANTIC_VERIFY_SKIPPED: + return "skipped"; + case SEMANTIC_VERIFY_RAW_CLEAN: + return "raw-clean"; + case SEMANTIC_VERIFY_RAW_MODIFIED: + return "raw-modified"; + case SEMANTIC_VERIFY_SENSITIVE: + return "sensitive"; + case SEMANTIC_VERIFY_STRUCTURAL: + return "structural"; + case SEMANTIC_VERIFY_UNSTABLE: + return "unstable"; + case SEMANTIC_VERIFY_ERROR: + return "error"; + } + BUG("unknown semantic verification kind"); +} + +int cmd__semantic_verify(int argc, const char **argv) +{ + struct semantic_verify_proof *proof = NULL; + struct semantic_verify_stats stats; + int show_results = 0; + int ret; + const char * const usage[] = { + "test-tool semantic-verify []", + NULL + }; + struct option opts[] = { + OPT_BOOL(0, "show-results", &show_results, + "show one result per cache entry"), + OPT_END() + }; + + argc = parse_options(argc, argv, NULL, opts, usage, 0); + if (argc) + usage_with_options(usage, opts); + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + prepare_repo_settings(the_repository); + the_repository->settings.command_requires_full_index = 0; + if (repo_read_index(the_repository) < 0) + die("unable to read index"); + ret = semantic_verify_prepare(the_repository->index, &proof); + semantic_verify_get_stats(proof, &stats); + if (show_results) { + for (size_t i = 0; i < stats.cache_nr; i++) { + const struct semantic_verify_result *result = + semantic_verify_result_at(proof, i); + + printf("%s %s persist=%d error=%u\n", + the_repository->index->cache[i]->name, + kind_name(result->kind), + !!(result->flags & SEMANTIC_VERIFY_PERSISTABLE), + result->error); + } + } + printf("entries=%"PRIuMAX" clean=%"PRIuMAX + " modified=%"PRIuMAX" sensitive=%"PRIuMAX + " structural=%"PRIuMAX" unstable=%"PRIuMAX + " errors=%"PRIuMAX" hardlinks=%"PRIuMAX + " bytes=%"PRIuMAX" stat_updates=%"PRIuMAX + " root_stable=%d namespace_stable=%d\n", + (uintmax_t)stats.cache_nr, (uintmax_t)stats.raw_clean, + (uintmax_t)stats.raw_modified, (uintmax_t)stats.sensitive, + (uintmax_t)stats.structural, (uintmax_t)stats.unstable, + (uintmax_t)stats.errors, (uintmax_t)stats.hardlinks, + (uintmax_t)stats.bytes_hashed, + (uintmax_t)stats.stat_updates_nr, + semantic_verify_root_is_stable(proof), + !stats.namespace_unstable); + semantic_verify_proof_clear(proof); + return !!ret; +} diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c index b71a22b43bbc9e..5ccf3864beb235 100644 --- a/t/helper/test-tool.c +++ b/t/helper/test-tool.c @@ -70,6 +70,7 @@ static struct test_cmd cmds[] = { { "revision-walking", cmd__revision_walking }, { "run-command", cmd__run_command }, { "scrap-cache-tree", cmd__scrap_cache_tree }, + { "semantic-verify", cmd__semantic_verify }, { "serve-v2", cmd__serve_v2 }, { "sha1", cmd__sha1 }, { "sha1-is-sha1dc", cmd__sha1_is_sha1dc }, diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h index f2885b33d58aa8..d1044198247ae4 100644 --- a/t/helper/test-tool.h +++ b/t/helper/test-tool.h @@ -63,6 +63,7 @@ int cmd__repository(int argc, const char **argv); int cmd__revision_walking(int argc, const char **argv); int cmd__run_command(int argc, const char **argv); int cmd__scrap_cache_tree(int argc, const char **argv); +int cmd__semantic_verify(int argc, const char **argv); int cmd__serve_v2(int argc, const char **argv); int cmd__sha1(int argc, const char **argv); int cmd__sha1_is_sha1dc(int argc, const char **argv); diff --git a/t/lib-semantic-verify.sh b/t/lib-semantic-verify.sh new file mode 100644 index 00000000000000..b46fd064711daf --- /dev/null +++ b/t/lib-semantic-verify.sh @@ -0,0 +1,9 @@ +test_lazy_prereq SEMANTIC_VERIFY_ANCHORED_OPEN ' + test_create_repo semantic-anchored-open-probe && + test_commit -C semantic-anchored-open-probe base tracked && + ( + cd semantic-anchored-open-probe && + test-tool semantic-verify --show-results >actual && + test_grep "^tracked raw-clean " actual + ) +' diff --git a/t/meson.build b/t/meson.build index 6d4c7c4bed8fdf..4ecf8343a2f935 100644 --- a/t/meson.build +++ b/t/meson.build @@ -945,6 +945,7 @@ integration_tests = [ 't7526-commit-pathspec-file.sh', 't7527-builtin-fsmonitor.sh', 't7528-signed-commit-ssh.sh', + 't7531-semantic-verify.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh new file mode 100755 index 00000000000000..f391b86f5005aa --- /dev/null +++ b/t/t7531-semantic-verify.sh @@ -0,0 +1,104 @@ +#!/bin/sh + +test_description='descriptor-anchored semantic verification' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh + +verify_repo () { + repo=$1 && + shift && + ( + cd "$repo" && + test-tool semantic-verify "$@" + ) +} + +test_expect_success !SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'unsupported platforms decline semantic verification' ' + test_create_repo anchored-open-unsupported && + test_commit -C anchored-open-unsupported base tracked && + ( + cd anchored-open-unsupported && + test_must_fail test-tool semantic-verify --show-results + ) >actual && + test_grep "^tracked error persist=0 error=[1-9][0-9]*$" actual && + test_grep "^entries=1 clean=0 modified=0 sensitive=0 structural=0 " \ + actual && + test_grep " unstable=0 errors=1 hardlinks=0 bytes=0 " actual && + test_grep " stat_updates=0 root_stable=0 namespace_stable=1" \ + actual +' + +test_lazy_prereq HARDLINKS ' + rm -f hardlink-source hardlink-alias && + : >hardlink-source && + ln hardlink-source hardlink-alias +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'classifies raw, converted, nested, and modified files' ' + test_create_repo classify && + mkdir -p classify/a/b && + test_write_lines "converted text" >classify/.gitattributes && + for path in raw converted modified deleted a/b/nested + do + test_write_lines original >"classify/$path" || return 1 + done && + test-tool chmtime -120 classify/raw classify/converted \ + classify/modified classify/deleted classify/a/b/nested && + git -C classify add . && + git -C classify commit -m base && + cp -p classify/modified classify/mtime-reference && + test_write_lines replaced >classify/modified && + touch -r classify/mtime-reference classify/modified && + rm classify/deleted classify/mtime-reference && + + verify_repo classify --show-results >actual && + test_grep "^.gitattributes raw-clean persist=1" actual && + test_grep "^raw raw-clean persist=1" actual && + test_grep "^a/b/nested raw-clean persist=1" actual && + test_grep "^converted sensitive" actual && + test_grep "^modified raw-modified" actual && + test_grep "^deleted raw-modified" actual && + test_grep "entries=6 clean=3 modified=2 sensitive=1" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN,HARDLINKS \ + 'clean hardlinks are not persistable' ' + test_create_repo hardlink && + test_write_lines content >hardlink/tracked && + git -C hardlink add tracked && + git -C hardlink commit -m base && + ln hardlink/tracked hardlink/alias && + + verify_repo hardlink --show-results >actual && + test_grep "^tracked raw-clean persist=0" actual && + test_grep "hardlinks=1" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'classifies structural index state' ' + test_create_repo structural && + test_write_lines tracked >structural/tracked && + git -C structural add tracked && + git -C structural commit -m base && + test_write_lines intent >structural/intent && + git -C structural add -N intent && + + verify_repo structural --show-results >actual && + test_grep "^intent structural" actual && + test_grep "structural=1" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'raw hashing uses the repository object format' ' + git init --object-format=sha256 sha256 && + test_write_lines sha256 >sha256/tracked && + git -C sha256 add tracked && + git -C sha256 commit -m base && + verify_repo sha256 --show-results >actual && + test_grep "^tracked raw-clean persist=1" actual +' + +test_done From 84df9971fe5be313fea77bc06734f40d62f5adab Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:28 -0500 Subject: [PATCH 141/403] fsmonitor: invalidate conversion state for attribute-file events Changing a .gitattributes file can change how tracked content is converted without changing the tracked file's stat data. Invalidating the attribute-file path alone therefore leaves cached conversion state and affected fsmonitor-valid tracked entries falsely reusable. Recognize an exact .gitattributes basename in the refresh callback. Discard cached attribute stacks globally and strongly invalidate only tracked entries beneath that file's parent directory. A root attribute file invalidates all tracked entries; tracked entries in sibling directories remain valid after a nested attribute-file event. Mark the fsmonitor extension changed only when an entry is invalidated. Add Clar unit coverage for unrelated paths, nested-directory scope, root-directory scope, cleared validity, zeroed stat data, and the content-check marker. Register u-fsmonitor-attributes in both Makefile and t/meson.build so the suite is included in both build systems. Signed-off-by: Taylor Blau --- Makefile | 1 + fsmonitor-ll.h | 2 + fsmonitor.c | 34 +++++++++++++ t/meson.build | 1 + t/unit-tests/u-fsmonitor-attributes.c | 72 +++++++++++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 t/unit-tests/u-fsmonitor-attributes.c diff --git a/Makefile b/Makefile index fac3e8879c8377..91090f730b14ae 100644 --- a/Makefile +++ b/Makefile @@ -1538,6 +1538,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate +CLAR_TEST_SUITES += u-fsmonitor-attributes CLAR_TEST_SUITES += u-hash CLAR_TEST_SUITES += u-hashmap CLAR_TEST_SUITES += u-list-objects-filter-options diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index a409b15e68bc51..7f78ad21c8d0b0 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -47,6 +47,8 @@ void tweak_fsmonitor(struct index_state *istate); */ void refresh_fsmonitor(struct index_state *istate); +int fsmonitor_invalidate_attributes_path(struct index_state *istate, + const char *name); /* * Does the received result contain the "trivial" response? */ diff --git a/fsmonitor.c b/fsmonitor.c index 6c119b17bd391e..2fd070b1d5b22a 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -209,6 +209,39 @@ void fsmonitor_invalidate_cache_entry(struct cache_entry *ce) static size_t handle_path_with_trailing_slash( struct index_state *istate, const char *name, int pos); +int fsmonitor_invalidate_attributes_path(struct index_state *istate, + const char *name) +{ + size_t len = strlen(name), base, attr_len = strlen(GITATTRIBUTES_FILE); + size_t invalidated = 0; + unsigned int i; + + while (len && is_dir_sep(name[len - 1])) + len--; + base = len; + while (base && !is_dir_sep(name[base - 1])) + base--; + if (len - base != attr_len || + fspathncmp(name + base, GITATTRIBUTES_FILE, attr_len)) + return 0; + + git_attr_invalidate_all(); + for (i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + + if (base && (ce->ce_namelen < base || + fspathncmp(ce->name, name, base))) + continue; + fsmonitor_invalidate_cache_entry(ce); + invalidated++; + } + if (invalidated) + istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/attributes-scope", base); + return invalidated > 0; +} + /* * Use the name-hash to do a case-insensitive cache-entry lookup with * the pathname and invalidate the cache-entry. @@ -457,6 +490,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) return; } pos = index_name_pos(istate, name, len); + fsmonitor_invalidate_attributes_path(istate, name); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); diff --git a/t/meson.build b/t/meson.build index a25f37d2f5ae7d..c9b35c051949ff 100644 --- a/t/meson.build +++ b/t/meson.build @@ -2,6 +2,7 @@ clar_test_suites = [ 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', + 'unit-tests/u-fsmonitor-attributes.c', 'unit-tests/u-hash.c', 'unit-tests/u-hashmap.c', 'unit-tests/u-list-objects-filter-options.c', diff --git a/t/unit-tests/u-fsmonitor-attributes.c b/t/unit-tests/u-fsmonitor-attributes.c new file mode 100644 index 00000000000000..5a2b7a25f137b3 --- /dev/null +++ b/t/unit-tests/u-fsmonitor-attributes.c @@ -0,0 +1,72 @@ +#include "unit-test.h" +#include "fsmonitor-ll.h" +#include "read-cache-ll.h" +#include "repository.h" + +static void add_entry(struct index_state *istate, size_t pos, + const char *path) +{ + size_t len = strlen(path); + struct cache_entry *ce = make_empty_cache_entry(istate, len); + + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = len; + ce->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE; + memset(&ce->ce_stat_data, 1, sizeof(ce->ce_stat_data)); + memcpy(ce->name, path, len + 1); + istate->cache[pos] = ce; +} + +static int stat_data_is_zero(const struct cache_entry *ce) +{ + struct stat_data zero = { 0 }; + + return !memcmp(&ce->ce_stat_data, &zero, sizeof(zero)); +} + +void test_fsmonitor_attributes__invalidates_only_the_affected_scope(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 3); + istate.cache_alloc = istate.cache_nr = 3; + add_entry(&istate, 0, "a/file"); + add_entry(&istate, 1, "a/sub/file"); + add_entry(&istate, 2, "b/file"); + + cl_assert(!fsmonitor_invalidate_attributes_path( + &istate, "a/not-attributes")); + cl_assert(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, "a/.gitattributes")); + for (size_t i = 0; i < 2; i++) { + cl_assert(!(istate.cache[i]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(!(istate.cache[i]->ce_flags & CE_UPTODATE)); + cl_assert(istate.cache[i]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(stat_data_is_zero(istate.cache[i])); + } + cl_assert(istate.cache[2]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(istate.cache[2]->ce_flags & CE_UPTODATE); + cl_assert(!stat_data_is_zero(istate.cache[2])); + cl_assert(istate.cache_changed & FSMONITOR_CHANGED); + release_index(&istate); +} + +void test_fsmonitor_attributes__root_source_invalidates_every_entry(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_entry(&istate, 0, "a/file"); + add_entry(&istate, 1, "b/file"); + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, ".gitattributes")); + for (size_t i = 0; i < istate.cache_nr; i++) { + cl_assert(!(istate.cache[i]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[i]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + } + release_index(&istate); +} From cd3f39f24421a89a66020a648029baf894f879eb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:40 -0700 Subject: [PATCH 142/403] status: apply complete semantic proofs atomically An index entry, worktree root, or recorded verification result can become invalid between proof preparation and index mutation. Applying early results before checking later entries would leave part of the index incorrectly trusted. Snapshot each cache entry's identity and retain an eight-byte result with an explicitly indexed optional stat update. Before modifying any entry, recheck the root, reject namespaces marked unstable during verification, validate every entry and result, and require a complete one-to-one mapping for staged stat updates. Parent directories are reopened during verification, not again during proof application. Apply only verified, persistable clean entries. Invalidate matching hardlinks and detected content changes so the ordinary refresh tail cannot accidentally accept their existing stat data. Leave converted and skipped entries to their established handling. Extend the test helper and semantic-verification suite to cover successful application, nonpersistable hardlinks, structural rejection, and replacement of an index entry after proof preparation. These tests exercise the explicit proof API; they neither replace a parent after preparation nor introduce a production status caller. Signed-off-by: Taylor Blau --- semantic-verify-internal.h | 13 +++ semantic-verify.c | 137 ++++++++++++++++++++++++++++++++ semantic-verify.h | 7 ++ t/helper/test-semantic-verify.c | 47 ++++++++++- t/t7531-semantic-verify.sh | 45 ++++++++--- 5 files changed, 234 insertions(+), 15 deletions(-) diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index bf1753bf17a6cc..f090787dd1d079 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -1,6 +1,7 @@ #ifndef SEMANTIC_VERIFY_INTERNAL_H #define SEMANTIC_VERIFY_INTERNAL_H +#include "hash.h" #include "statinfo.h" #ifdef __linux__ @@ -89,6 +90,15 @@ struct semantic_verify_stat_update { struct stat_data stat_data; }; +struct semantic_verify_entry_identity { + const struct cache_entry *entry; + struct object_id oid; + struct stat_data stat_data; + char *name; + unsigned int mode; + unsigned int flags; +}; + struct semantic_verify_worker { struct index_state *istate; struct semantic_verify_root *root; @@ -107,6 +117,7 @@ struct semantic_verify_worker { size_t unstable; size_t errors; size_t hardlinks; + size_t active_filters; unsigned int namespace_unstable; }; @@ -116,6 +127,7 @@ struct semantic_verify_proof { struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; + struct semantic_verify_entry_identity *entry_identities; struct semantic_verify_stat_update *stat_updates; size_t cache_nr; size_t stat_updates_nr; @@ -128,6 +140,7 @@ struct semantic_verify_proof { size_t unstable; size_t errors; size_t hardlinks; + size_t active_filters; unsigned int namespace_unstable; }; diff --git a/semantic-verify.c b/semantic-verify.c index d58666ae058d8a..e932addc6c1b54 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -2,6 +2,7 @@ #include "git-compat-util.h" #include "convert.h" +#include "fsmonitor.h" #include "object.h" #include "read-cache-ll.h" #include "repository.h" @@ -9,6 +10,9 @@ #include "semantic-verify-internal.h" #include "trace2.h" +#define SEMANTIC_VERIFY_ENTRY_FLAGS \ + (CE_VALID | CE_STAGEMASK | CE_INTENT_TO_ADD | CE_SKIP_WORKTREE | \ + CE_UPTODATE | CE_FSMONITOR_VALID | CE_CONTENT_CHECK_REQUIRED) static void combine_worker(struct semantic_verify_proof *proof, struct semantic_verify_worker *worker) { @@ -17,6 +21,11 @@ static void combine_worker(struct semantic_verify_proof *proof, if (worker->updates_nr) COPY_ARRAY(proof->stat_updates + base, worker->updates, worker->updates_nr); + for (size_t i = 0; i < worker->updates_nr; i++) { + uint32_t cache_pos = worker->updates[i].cache_pos; + + proof->results[cache_pos].stat_update_index = base + i; + } proof->stat_updates_nr += worker->updates_nr; proof->bytes_hashed += worker->bytes_hashed; proof->raw_clean += worker->raw_clean; @@ -39,11 +48,28 @@ int semantic_verify_prepare(struct index_state *istate, if (!istate || !proof_out) BUG("semantic_verify_prepare requires an index and output"); + if (sizeof(struct semantic_verify_result) != 8) + BUG("semantic verify result unexpectedly grew to %"PRIuMAX" bytes", + (uintmax_t)sizeof(struct semantic_verify_result)); CALLOC_ARRAY(proof, 1); proof->istate = istate; proof->cache_nr = istate->cache_nr; CALLOC_ARRAY(proof->results, proof->cache_nr); + CALLOC_ARRAY(proof->entry_identities, proof->cache_nr); + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + struct semantic_verify_entry_identity *identity = + &proof->entry_identities[i]; + + proof->results[i].stat_update_index = UINT32_MAX; + identity->entry = ce; + oidcpy(&identity->oid, &ce->oid); + identity->stat_data = ce->ce_stat_data; + identity->name = xstrdup(ce->name); + identity->mode = ce->ce_mode; + identity->flags = ce->ce_flags & SEMANTIC_VERIFY_ENTRY_FLAGS; + } *proof_out = proof; if (!proof->cache_nr) return 0; @@ -131,11 +157,122 @@ const struct semantic_verify_result *semantic_verify_result_at( return &proof->results[cache_pos]; } +int semantic_verify_apply_after_closure( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + int applied = 0; + int poisoned = 0; + size_t validated_updates = 0; + + if (!istate || !proof || proof->istate != istate || + proof->cache_nr != istate->cache_nr || + proof->namespace_unstable || + !semantic_verify_root_is_stable(proof)) + return -1; + if (proof->active_filters) { + trace2_data_intmax("semantic_verify", istate->repo, + "filter-scope-rejected", 1); + return -1; + } + + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct semantic_verify_entry_identity *identity = + &proof->entry_identities[i]; + const struct cache_entry *ce = istate->cache[i]; + + if (ce != identity->entry || + !oideq(&ce->oid, &identity->oid) || + memcmp(&ce->ce_stat_data, &identity->stat_data, + sizeof(ce->ce_stat_data)) || + strcmp(ce->name, identity->name) || + ce->ce_mode != identity->mode || + (ce->ce_flags & SEMANTIC_VERIFY_ENTRY_FLAGS) != + identity->flags) + return -1; + } + + /* Validate the complete proof before changing any cache entry. */ + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct semantic_verify_result *result = &proof->results[i]; + + if (result->kind > SEMANTIC_VERIFY_ERROR || + (result->flags & ~(SEMANTIC_VERIFY_PERSISTABLE | + SEMANTIC_VERIFY_ACTIVE_FILTER))) + return -1; + if (result->kind == SEMANTIC_VERIFY_UNCHECKED || + result->kind == SEMANTIC_VERIFY_STRUCTURAL || + result->kind == SEMANTIC_VERIFY_UNSTABLE || + result->kind == SEMANTIC_VERIFY_ERROR) + return -1; + if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) { + if (result->flags || + result->stat_update_index != UINT32_MAX) + return -1; + continue; + } + if (result->stat_update_index != UINT32_MAX) { + const struct semantic_verify_stat_update *update; + + if (result->stat_update_index >= proof->stat_updates_nr) + return -1; + update = &proof->stat_updates[result->stat_update_index]; + if (update->cache_pos != i) + return -1; + validated_updates++; + } + } + if (validated_updates != proof->stat_updates_nr) + return -1; + + for (size_t i = 0; i < proof->cache_nr; i++) { + const struct semantic_verify_result *result = &proof->results[i]; + struct cache_entry *ce = istate->cache[i]; + + /* Force the ordinary refresh tail to preserve mismatches. */ + if (result->kind == SEMANTIC_VERIFY_RAW_MODIFIED || + (result->kind == SEMANTIC_VERIFY_RAW_CLEAN && + !(result->flags & SEMANTIC_VERIFY_PERSISTABLE))) { + fsmonitor_invalidate_cache_entry(ce); + mark_fsmonitor_invalid(istate, ce); + ce->ce_flags |= CE_UPDATE_IN_BASE; + istate->cache_changed |= CE_ENTRY_CHANGED; + poisoned++; + if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) + applied++; + continue; + } + if (result->kind != SEMANTIC_VERIFY_RAW_CLEAN) + continue; + if (result->stat_update_index != UINT32_MAX) { + const struct semantic_verify_stat_update *update = + &proof->stat_updates[result->stat_update_index]; + + memcpy(&ce->ce_stat_data, &update->stat_data, + sizeof(ce->ce_stat_data)); + ce->ce_flags |= CE_UPDATE_IN_BASE; + istate->cache_changed |= CE_ENTRY_CHANGED; + } + ce_mark_uptodate(ce); + if (result->flags & SEMANTIC_VERIFY_PERSISTABLE) + mark_fsmonitor_valid(istate, ce); + applied++; + } + trace2_data_intmax("semantic_verify", istate->repo, + "applied", applied); + trace2_data_intmax("semantic_verify", istate->repo, + "poisoned-for-tail", poisoned); + return applied; +} + void semantic_verify_proof_clear(struct semantic_verify_proof *proof) { if (!proof) return; semantic_verify_root_clear(proof->root); + for (size_t i = 0; i < proof->cache_nr; i++) + free(proof->entry_identities[i].name); + free(proof->entry_identities); free(proof->stat_updates); free(proof->results); free(proof); diff --git a/semantic-verify.h b/semantic-verify.h index 798dad30ae5ae4..dc2f02fffdacf6 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -18,9 +18,13 @@ enum semantic_verify_kind { enum semantic_verify_result_flags { /* The clean result may receive persistent fsmonitor validity. */ SEMANTIC_VERIFY_PERSISTABLE = (1u << 0), + /* The selected driver can affect conversion into the index. */ + SEMANTIC_VERIFY_ACTIVE_FILTER = (1u << 1), }; +/* Exactly eight bytes per cache entry. */ struct semantic_verify_result { + uint32_t stat_update_index; uint16_t error; uint8_t kind; uint8_t flags; @@ -44,6 +48,9 @@ struct semantic_verify_stats { /* Build a proof candidate without changing the index. */ int semantic_verify_prepare(struct index_state *istate, struct semantic_verify_proof **proof_out); +int semantic_verify_apply_after_closure( + struct index_state *istate, + const struct semantic_verify_proof *proof); int semantic_verify_root_is_stable( const struct semantic_verify_proof *proof); void semantic_verify_proof_clear(struct semantic_verify_proof *proof); diff --git a/t/helper/test-semantic-verify.c b/t/helper/test-semantic-verify.c index bb1cffe99201a1..b0048dea791490 100644 --- a/t/helper/test-semantic-verify.c +++ b/t/helper/test-semantic-verify.c @@ -36,7 +36,12 @@ int cmd__semantic_verify(int argc, const char **argv) struct semantic_verify_proof *proof = NULL; struct semantic_verify_stats stats; int show_results = 0; + int apply = 0; + int applied = -2; + int before_uptodate = 0, after_uptodate = 0; + int before_valid = 0, after_valid = 0; int ret; + const char *replace_after_prepare = NULL; const char * const usage[] = { "test-tool semantic-verify []", NULL @@ -44,6 +49,9 @@ int cmd__semantic_verify(int argc, const char **argv) struct option opts[] = { OPT_BOOL(0, "show-results", &show_results, "show one result per cache entry"), + OPT_BOOL(0, "apply", &apply, "apply the completed proof"), + OPT_STRING(0, "replace-after-prepare", &replace_after_prepare, + "path", "replace an entry after preparing the proof"), OPT_END() }; @@ -59,6 +67,29 @@ int cmd__semantic_verify(int argc, const char **argv) die("unable to read index"); ret = semantic_verify_prepare(the_repository->index, &proof); semantic_verify_get_stats(proof, &stats); + if (replace_after_prepare) { + struct index_state *istate = the_repository->index; + struct cache_entry *replacement; + int pos = index_name_pos(istate, replace_after_prepare, + strlen(replace_after_prepare)); + + if (pos < 0) + die("%s not in index", replace_after_prepare); + replacement = dup_cache_entry(istate->cache[pos], istate); + replacement->oid.hash[0] ^= 1; + replacement->ce_flags &= + ~(CE_UPTODATE | CE_FSMONITOR_VALID); + if (add_index_entry(istate, replacement, + ADD_CACHE_OK_TO_REPLACE | + ADD_CACHE_KEEP_CACHE_TREE)) + die("unable to replace %s", replace_after_prepare); + } + for (size_t i = 0; i < stats.cache_nr; i++) { + struct cache_entry *ce = the_repository->index->cache[i]; + + before_uptodate += !!ce_uptodate(ce); + before_valid += !!(ce->ce_flags & CE_FSMONITOR_VALID); + } if (show_results) { for (size_t i = 0; i < stats.cache_nr; i++) { const struct semantic_verify_result *result = @@ -71,12 +102,23 @@ int cmd__semantic_verify(int argc, const char **argv) result->error); } } + if (apply) + applied = semantic_verify_apply_after_closure( + the_repository->index, proof); + for (size_t i = 0; i < stats.cache_nr; i++) { + struct cache_entry *ce = the_repository->index->cache[i]; + + after_uptodate += !!ce_uptodate(ce); + after_valid += !!(ce->ce_flags & CE_FSMONITOR_VALID); + } printf("entries=%"PRIuMAX" clean=%"PRIuMAX " modified=%"PRIuMAX" sensitive=%"PRIuMAX " structural=%"PRIuMAX" unstable=%"PRIuMAX " errors=%"PRIuMAX" hardlinks=%"PRIuMAX " bytes=%"PRIuMAX" stat_updates=%"PRIuMAX - " root_stable=%d namespace_stable=%d\n", + " root_stable=%d namespace_stable=%d applied=%d" + " before_uptodate=%d before_valid=%d" + " after_uptodate=%d after_valid=%d\n", (uintmax_t)stats.cache_nr, (uintmax_t)stats.raw_clean, (uintmax_t)stats.raw_modified, (uintmax_t)stats.sensitive, (uintmax_t)stats.structural, (uintmax_t)stats.unstable, @@ -84,7 +126,8 @@ int cmd__semantic_verify(int argc, const char **argv) (uintmax_t)stats.bytes_hashed, (uintmax_t)stats.stat_updates_nr, semantic_verify_root_is_stable(proof), - !stats.namespace_unstable); + !stats.namespace_unstable, applied, + before_uptodate, before_valid, after_uptodate, after_valid); semantic_verify_proof_clear(proof); return !!ret; } diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh index f391b86f5005aa..828a1aaa9595be 100755 --- a/t/t7531-semantic-verify.sh +++ b/t/t7531-semantic-verify.sh @@ -54,41 +54,60 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ touch -r classify/mtime-reference classify/modified && rm classify/deleted classify/mtime-reference && - verify_repo classify --show-results >actual && + verify_repo classify --show-results --apply >actual && test_grep "^.gitattributes raw-clean persist=1" actual && test_grep "^raw raw-clean persist=1" actual && test_grep "^a/b/nested raw-clean persist=1" actual && test_grep "^converted sensitive" actual && test_grep "^modified raw-modified" actual && test_grep "^deleted raw-modified" actual && - test_grep "entries=6 clean=3 modified=2 sensitive=1" actual + test_grep "entries=6 clean=3 modified=2 sensitive=1" actual && + test_grep "applied=3 .* after_uptodate=3" actual ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN,HARDLINKS \ - 'clean hardlinks are not persistable' ' + 'clean hardlinks require the ordinary tail' ' test_create_repo hardlink && test_write_lines content >hardlink/tracked && git -C hardlink add tracked && git -C hardlink commit -m base && ln hardlink/tracked hardlink/alias && - verify_repo hardlink --show-results >actual && + verify_repo hardlink --show-results --apply >actual && test_grep "^tracked raw-clean persist=0" actual && - test_grep "hardlinks=1" actual + test_grep "hardlinks=1" actual && + test_grep "applied=1 .* after_uptodate=0 after_valid=0" actual ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'classifies structural index state' ' + 'structural index state rejects the whole proof' ' test_create_repo structural && - test_write_lines tracked >structural/tracked && - git -C structural add tracked && + test_write_lines tracked >structural/a-tracked && + git -C structural add a-tracked && git -C structural commit -m base && - test_write_lines intent >structural/intent && - git -C structural add -N intent && + test_write_lines intent >structural/z-intent && + git -C structural add -N z-intent && - verify_repo structural --show-results >actual && - test_grep "^intent structural" actual && - test_grep "structural=1" actual + verify_repo structural --show-results --apply >actual && + test_grep "^a-tracked raw-clean persist=1" actual && + test_grep "^z-intent structural" actual && + test_grep "applied=-1 .* after_uptodate=0" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'replaced index entries reject the whole proof' ' + test_create_repo replaced-entry && + test_write_lines tracked >replaced-entry/a-tracked && + test_write_lines replaced >replaced-entry/z-replaced && + git -C replaced-entry add . && + git -C replaced-entry commit -m base && + + verify_repo replaced-entry --show-results --apply \ + --replace-after-prepare=z-replaced >actual && + test_grep "^a-tracked raw-clean persist=1" actual && + test_grep "^z-replaced raw-clean persist=1" actual && + test_grep "applied=-1 before_uptodate=0 before_valid=0 " actual && + test_grep "after_uptodate=0 after_valid=0" actual ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ From 7933949f9f4b27fe1ad05f59c7b6febe569ea837 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:58 -0500 Subject: [PATCH 143/403] fsmonitor: invalidate attributes for matched directory summaries A provider can report a directory move or modification without naming a changed .gitattributes file beneath it. Existing directory handling invalidates tracked entries in the reported cone but can leave cached attribute stacks describing the old conversion rules. Discard cached attribute stacks only after directory handling matches at least one tracked index entry. Record semantic/attributes-cone with the number of matched entries. An unmatched directory keeps its existing case-correction and untracked-path fallback without speculatively flushing attribute state. Extend t/helper/test-read-cache.c to cache an old attribute, process a directory event, and require the new attribute value. Add hook regressions in t/t7519-status-fsmonitor.sh for both an indexed cone and an unmatched directory, including their distinct Trace2 behavior. Signed-off-by: Taylor Blau --- fsmonitor.c | 10 ++++++ t/helper/test-read-cache.c | 45 ++++++++++++++++++++++++ t/t7519-status-fsmonitor.sh | 69 +++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/fsmonitor.c b/fsmonitor.c index 2fd070b1d5b22a..df716a26b85499 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -464,6 +464,16 @@ static size_t handle_path_with_trailing_slash( nr_in_cone++; } + if (nr_in_cone) { + /* + * A matched directory event may stand in for a nested + * attribute-file change. + */ + git_attr_invalidate_all(); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/attributes-cone", nr_in_cone); + } + return nr_in_cone; } diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index f5dae8ecfcc485..c7631a204c8b2a 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -1,9 +1,11 @@ #define USE_THE_REPOSITORY_VARIABLE #include "test-tool.h" +#include "attr.h" #include "config.h" #include "environment.h" #include "fsmonitor.h" +#include "fsmonitor-ll.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" @@ -41,6 +43,45 @@ static int test_fsmonitor_content_recovery(const char *path) return 0; } +static int test_fsmonitor_directory_attributes(void) +{ + struct attr_check *check; + int ret = 1; + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + + check = attr_check_initl("marker", NULL); + git_check_attr(the_repository->index, "tracked-dir/tracked", check); + if (!check->items[0].value || + strcmp(check->items[0].value, "old")) { + error("initial attribute value was not cached"); + goto done; + } + + write_file("tracked-dir/.gitattributes", "tracked marker=new\n"); + /* + * repo_read_index() consumed the normal refresh. Re-arm it after + * caching the pre-event attribute value. + */ + the_repository->index->fsmonitor_has_run_once = 0; + refresh_fsmonitor(the_repository->index); + git_check_attr(the_repository->index, "tracked-dir/tracked", check); + if (!check->items[0].value || + strcmp(check->items[0].value, "new")) { + error("directory event did not invalidate cached attributes"); + goto done; + } + ret = 0; + +done: + attr_check_free(check); + discard_index(the_repository->index); + return ret; +} + int cmd__read_cache(int argc, const char **argv) { int i, cnt = 1; @@ -49,6 +90,10 @@ int cmd__read_cache(int argc, const char **argv) if (argc == 3 && !strcmp(argv[1], "--test-fsmonitor-content-recovery")) return test_fsmonitor_content_recovery(argv[2]); + if (argc == 2 && + !strcmp(argv[1], "--test-fsmonitor-directory-attributes")) + return test_fsmonitor_directory_attributes(); + if (argc > 1 && skip_prefix(argv[1], "--print-and-refresh=", &name)) { argc--; argv++; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index fb2fadc53d5986..691148ae677113 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -657,6 +657,75 @@ test_expect_success 'provider global marker invalidates every tracked entry' ' ) ' +test_expect_success \ + 'directory attribute invalidation requires an indexed cone' ' + test_create_repo directory-attributes && + ( + cd directory-attributes && + mkdir tracked-dir && + test_commit base tracked-dir/tracked && + test_hook --setup fsmonitor-test <<-\EOF && + if test -f .git/report-cone + then + printf "cone-token\0tracked-dir/\0" + elif test -f .git/report-unmatched + then + printf "unmatched-token\0untracked/\0" + else + printf "base-token\0" + fi + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + + > .git/report-cone && + GIT_TRACE2_EVENT="$PWD/.git/cone.trace" \ + GIT_TRACE_FSMONITOR="$PWD/.git/cone.fsm" \ + git status --porcelain=v2 >.git/cone.actual && + test_must_be_empty .git/cone.actual && + test_grep "fsmonitor_refresh_callback.*tracked-dir/" \ + .git/cone.fsm && + test_trace2_data fsmonitor semantic/attributes-cone 1 \ + <.git/cone.trace && + + rm .git/report-cone && + > .git/report-unmatched && + GIT_TRACE2_EVENT="$PWD/.git/unmatched.trace" \ + GIT_TRACE_FSMONITOR="$PWD/.git/unmatched.fsm" \ + git status --porcelain=v2 >.git/unmatched.actual && + test_must_be_empty .git/unmatched.actual && + test_grep "fsmonitor_refresh_callback.*untracked/" \ + .git/unmatched.fsm && + test_grep ! \ + "\"category\":\"fsmonitor\",\"key\":\"semantic/attributes-cone\"" \ + .git/unmatched.trace + ) +' + +test_expect_success 'directory events invalidate cached attributes' ' + test_create_repo directory-attribute-cache && + ( + cd directory-attribute-cache && + mkdir tracked-dir && + test_write_lines "tracked marker=old" \ + >tracked-dir/.gitattributes && + test_write_lines tracked >tracked-dir/tracked && + git add tracked-dir && + git commit -m base && + test_hook --setup fsmonitor-test <<-\EOF && + printf "new-token\0tracked-dir/\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + test-tool read-cache \ + --test-fsmonitor-directory-attributes + ) +' + test_expect_success HARDLINKS,!MINGW,!CYGWIN \ 'multiply-linked files stay fsmonitor-invalid' ' test_when_finished "rm -f hardlink-alias" && From fbedfac5f5ebf43c6a89f8361b94cc4b2ab86e87 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 144/403] path-namespace: compare complete filesystem object identities Comparing only a pathname, device, and inode does not establish that two observations still describe the same unchanged filesystem object. Users that reopen a path need a reusable comparison covering the represented metadata fields. Represent an object's stat identity as a zero-initialized, fixed-width array containing device, inode, mode, link count, ownership, size, and modification and change timestamps. Include nanoseconds where available and add birth time and generation on Apple platforms. Provide comparison helpers and register both their library source and Clar unit suite in the Makefile and Meson builds. The unit tests check identity equality and reject changes to each represented stat field. No exclude-file validation or production caller is introduced here. Signed-off-by: Taylor Blau --- Makefile | 2 + meson.build | 1 + path-namespace.c | 47 ++++++++++++++++++++++ path-namespace.h | 18 +++++++++ t/meson.build | 1 + t/unit-tests/u-path-namespace.c | 69 +++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+) create mode 100644 path-namespace.c create mode 100644 path-namespace.h create mode 100644 t/unit-tests/u-path-namespace.c diff --git a/Makefile b/Makefile index fac3e8879c8377..edac13257671b1 100644 --- a/Makefile +++ b/Makefile @@ -1255,6 +1255,7 @@ LIB_OBJS += parse-options.o LIB_OBJS += patch-delta.o LIB_OBJS += patch-ids.o LIB_OBJS += path.o +LIB_OBJS += path-namespace.o LIB_OBJS += path-walk.o LIB_OBJS += pathspec.o LIB_OBJS += pkt-line.o @@ -1546,6 +1547,7 @@ CLAR_TEST_SUITES += u-odb-inmemory CLAR_TEST_SUITES += u-oid-array CLAR_TEST_SUITES += u-oidmap CLAR_TEST_SUITES += u-oidtree +CLAR_TEST_SUITES += u-path-namespace CLAR_TEST_SUITES += u-prio-queue CLAR_TEST_SUITES += u-reftable-basics CLAR_TEST_SUITES += u-reftable-block diff --git a/meson.build b/meson.build index 7073d5844d2531..a777f4722a2061 100644 --- a/meson.build +++ b/meson.build @@ -460,6 +460,7 @@ libgit_sources = [ 'patch-delta.c', 'patch-ids.c', 'path.c', + 'path-namespace.c', 'path-walk.c', 'pathspec.c', 'pkt-line.c', diff --git a/path-namespace.c b/path-namespace.c new file mode 100644 index 00000000000000..48fc0aae434ef7 --- /dev/null +++ b/path-namespace.c @@ -0,0 +1,47 @@ +#include "git-compat-util.h" +#include "path-namespace.h" + +void path_stat_identity_init(struct path_stat_identity *identity, + const struct stat *st) +{ + memset(identity, 0, sizeof(*identity)); + identity->fields[0] = st->st_dev; + identity->fields[1] = st->st_ino; + identity->fields[2] = st->st_mode; + identity->fields[3] = st->st_nlink; + identity->fields[4] = st->st_uid; + identity->fields[5] = st->st_gid; + identity->fields[6] = st->st_size; + identity->fields[7] = st->st_mtime; +#ifdef __APPLE__ + identity->fields[8] = st->st_mtimespec.tv_nsec; +#else + identity->fields[8] = ST_MTIME_NSEC(*st); +#endif + identity->fields[9] = st->st_ctime; +#ifdef __APPLE__ + identity->fields[10] = st->st_ctimespec.tv_nsec; +#else + identity->fields[10] = ST_CTIME_NSEC(*st); +#endif +#ifdef __APPLE__ + identity->fields[11] = st->st_birthtimespec.tv_sec; + identity->fields[12] = st->st_birthtimespec.tv_nsec; + identity->fields[13] = st->st_gen; +#endif +} + +int path_stat_identity_equal(const struct path_stat_identity *a, + const struct path_stat_identity *b) +{ + return !memcmp(a, b, sizeof(*a)); +} + +int path_namespace_stat_equal(const struct stat *a, const struct stat *b) +{ + struct path_stat_identity first, second; + + path_stat_identity_init(&first, a); + path_stat_identity_init(&second, b); + return path_stat_identity_equal(&first, &second); +} diff --git a/path-namespace.h b/path-namespace.h new file mode 100644 index 00000000000000..0a93683e0b2de1 --- /dev/null +++ b/path-namespace.h @@ -0,0 +1,18 @@ +#ifndef PATH_NAMESPACE_H +#define PATH_NAMESPACE_H + +struct stat; + +#define PATH_STAT_IDENTITY_FIELDS 14 + +struct path_stat_identity { + uint64_t fields[PATH_STAT_IDENTITY_FIELDS]; +}; + +void path_stat_identity_init(struct path_stat_identity *identity, + const struct stat *st); +int path_stat_identity_equal(const struct path_stat_identity *a, + const struct path_stat_identity *b); +int path_namespace_stat_equal(const struct stat *a, const struct stat *b); + +#endif /* PATH_NAMESPACE_H */ diff --git a/t/meson.build b/t/meson.build index a25f37d2f5ae7d..d8734eae5ff77f 100644 --- a/t/meson.build +++ b/t/meson.build @@ -10,6 +10,7 @@ clar_test_suites = [ 'unit-tests/u-oid-array.c', 'unit-tests/u-oidmap.c', 'unit-tests/u-oidtree.c', + 'unit-tests/u-path-namespace.c', 'unit-tests/u-prio-queue.c', 'unit-tests/u-reftable-basics.c', 'unit-tests/u-reftable-block.c', diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c new file mode 100644 index 00000000000000..702104597b1df9 --- /dev/null +++ b/t/unit-tests/u-path-namespace.c @@ -0,0 +1,69 @@ +#include "unit-test.h" + +#include "path-namespace.h" + +#define ASSERT_STAT_FIELD_MATTERS(base, changed, field) do { \ + (changed) = (base); \ + (changed).field = !(base).field; \ + cl_assert(!path_namespace_stat_equal(&(base), &(changed))); \ +} while (0) + +void test_path_namespace__stat_identity(void) +{ + struct stat st = { 0 }; + struct path_stat_identity first, second; + size_t i; + + st.st_dev = 1; + st.st_ino = 2; + st.st_mode = S_IFREG | 0644; + st.st_nlink = 3; + st.st_uid = 4; + st.st_gid = 5; + st.st_size = 6; + st.st_mtime = 7; + st.st_ctime = 8; + + path_stat_identity_init(&first, &st); + path_stat_identity_init(&second, &st); + cl_assert(path_stat_identity_equal(&first, &second)); + cl_assert(path_namespace_stat_equal(&st, &st)); + + for (i = 0; i < PATH_STAT_IDENTITY_FIELDS; i++) { + second = first; + second.fields[i]++; + cl_assert(!path_stat_identity_equal(&first, &second)); + } +} + +void test_path_namespace__stat_fields(void) +{ + struct stat st, changed; + + cl_must_pass(stat(".", &st)); + changed = st; + cl_assert(path_namespace_stat_equal(&st, &changed)); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_dev); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ino); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mode); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_nlink); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_uid); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_gid); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_size); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mtime); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ctime); +#ifndef NO_NSEC +#ifdef USE_ST_TIMESPEC + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mtimespec.tv_nsec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ctimespec.tv_nsec); +#else + ASSERT_STAT_FIELD_MATTERS(st, changed, st_mtim.tv_nsec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_ctim.tv_nsec); +#endif +#endif +#ifdef __APPLE__ + ASSERT_STAT_FIELD_MATTERS(st, changed, st_birthtimespec.tv_sec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_birthtimespec.tv_nsec); + ASSERT_STAT_FIELD_MATTERS(st, changed, st_gen); +#endif +} From 200dc98cc8893380a80b1f302d4ee3ded7e92f0b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:24:52 -0700 Subject: [PATCH 145/403] status: verify semantic proof ranges in parallel Serial proof preparation hashes every eligible indexed file on one worker. Independent index ranges can instead use separate pinned directory state, conversion checks, buffers, and result counters without allowing workers to mutate shared cache entries. Partition the index into at most 32 bounded ranges after preparing conversion state on the calling thread. Start one worker per range, join started workers, and merge their stat updates and counters in index order. Without thread support, use one worker; if thread creation fails, finish unstarted ranges synchronously. Extend test-tool semantic-verify with an explicit thread count. Add a regression that compares the complete results for one and four workers over nested directories with distinct attribute files. Each worker requires its own 256 KiB hash buffer and attribute check. The regression establishes deterministic classifications, not a timed speedup, and no production status command enables this verifier. Signed-off-by: Taylor Blau --- semantic-verify-internal.h | 5 ++ semantic-verify-worker.c | 13 ++++- semantic-verify.c | 100 ++++++++++++++++++++++++++++---- semantic-verify.h | 10 ++++ t/helper/test-semantic-verify.c | 19 +++++- t/t7531-semantic-verify.sh | 47 +++++++++++++++ 6 files changed, 180 insertions(+), 14 deletions(-) diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index f090787dd1d079..70f253ba2885ed 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -3,6 +3,7 @@ #include "hash.h" #include "statinfo.h" +#include "thread-utils.h" #ifdef __linux__ #include @@ -100,6 +101,8 @@ struct semantic_verify_entry_identity { }; struct semantic_verify_worker { + pthread_t pthread; + unsigned int started; struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; @@ -119,6 +122,7 @@ struct semantic_verify_worker { size_t hardlinks; size_t active_filters; unsigned int namespace_unstable; + unsigned int validate_filter_scope; }; void semantic_verify_worker_run(struct semantic_verify_worker *worker); @@ -142,6 +146,7 @@ struct semantic_verify_proof { size_t hardlinks; size_t active_filters; unsigned int namespace_unstable; + unsigned int filter_scope_checked; }; #endif /* SEMANTIC_VERIFY_INTERNAL_H */ diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index 47e46e8e19b0ee..b0f00099577b0d 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -64,19 +64,30 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) struct cache_entry *ce = worker->istate->cache[i]; struct semantic_verify_result *result = &worker->results[i]; struct semantic_verify_file_result file; + int active_filter; - if (!semantic_verify_classify_entry(worker->istate, ce, check, 0, + if (!semantic_verify_classify_entry(worker->istate, ce, check, + worker->validate_filter_scope, &file)) { result->kind = file.kind; + if (file.active_filter) { + result->flags |= SEMANTIC_VERIFY_ACTIVE_FILTER; + worker->active_filters++; + } count_result(worker, result->kind); continue; } + active_filter = file.active_filter; semantic_verify_file(worker->root, path, ce, i, worker->istate->repo, buffer, &file); result->kind = file.kind; result->error = file.error > UINT16_MAX ? EIO : file.error; + if (active_filter) { + result->flags |= SEMANTIC_VERIFY_ACTIVE_FILTER; + worker->active_filters++; + } worker->bytes_hashed += file.bytes_hashed; if (result->kind == SEMANTIC_VERIFY_RAW_CLEAN) { if (file.persistable) diff --git a/semantic-verify.c b/semantic-verify.c index e932addc6c1b54..52582793760ce2 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -13,6 +13,37 @@ #define SEMANTIC_VERIFY_ENTRY_FLAGS \ (CE_VALID | CE_STAGEMASK | CE_INTENT_TO_ADD | CE_SKIP_WORKTREE | \ CE_UPTODATE | CE_FSMONITOR_VALID | CE_CONTENT_CHECK_REQUIRED) +#define SEMANTIC_VERIFY_MAX_THREADS 32 + +static void *run_worker(void *data) +{ + semantic_verify_worker_run(data); + return NULL; +} + +static unsigned int select_thread_count( + size_t cache_nr, + const struct semantic_verify_options *options) +{ + unsigned int nr; + + if (!HAVE_THREADS) + return 1; + if (options && options->nr_threads) { + nr = options->nr_threads; + } else { + unsigned int cpus = online_cpus(); + + nr = cpus > SEMANTIC_VERIFY_MAX_THREADS / 2 ? + SEMANTIC_VERIFY_MAX_THREADS : cpus * 2; + } + if (nr > SEMANTIC_VERIFY_MAX_THREADS) + nr = SEMANTIC_VERIFY_MAX_THREADS; + if (nr > cache_nr && cache_nr) + nr = cache_nr; + return nr; +} + static void combine_worker(struct semantic_verify_proof *proof, struct semantic_verify_worker *worker) { @@ -36,24 +67,30 @@ static void combine_worker(struct semantic_verify_proof *proof, proof->unstable += worker->unstable; proof->errors += worker->errors; proof->hardlinks += worker->hardlinks; + proof->active_filters += worker->active_filters; proof->namespace_unstable |= worker->namespace_unstable; free(worker->updates); } int semantic_verify_prepare(struct index_state *istate, + const struct semantic_verify_options *options, struct semantic_verify_proof **proof_out) { struct semantic_verify_proof *proof; - struct semantic_verify_worker worker = { 0 }; + struct semantic_verify_worker *workers; + unsigned int nr_threads; + size_t updates_nr = 0; + int create_threads = 1; if (!istate || !proof_out) BUG("semantic_verify_prepare requires an index and output"); if (sizeof(struct semantic_verify_result) != 8) BUG("semantic verify result unexpectedly grew to %"PRIuMAX" bytes", (uintmax_t)sizeof(struct semantic_verify_result)); - CALLOC_ARRAY(proof, 1); proof->istate = istate; + proof->filter_scope_checked = options && + options->validate_filter_scope; proof->cache_nr = istate->cache_nr; CALLOC_ARRAY(proof->results, proof->cache_nr); CALLOC_ARRAY(proof->entry_identities, proof->cache_nr); @@ -94,18 +131,55 @@ int semantic_verify_prepare(struct index_state *istate, /* Initialize conversion config and default attribute state serially. */ convert_attrs_prepare(istate); + nr_threads = select_thread_count(proof->cache_nr, options); + CALLOC_ARRAY(workers, nr_threads); trace2_region_enter("semantic_verify", "prepare", istate->repo); - trace2_data_intmax("semantic_verify", istate->repo, "threads", 1); + trace2_data_intmax("semantic_verify", istate->repo, + "threads", nr_threads); trace2_data_intmax("semantic_verify", istate->repo, "result-bytes", sizeof(struct semantic_verify_result)); - worker.istate = istate; - worker.root = proof->root; - worker.results = proof->results; - worker.end = proof->cache_nr; - semantic_verify_worker_run(&worker); - ALLOC_ARRAY(proof->stat_updates, worker.updates_nr); - combine_worker(proof, &worker); + for (unsigned int i = 0; i < nr_threads; i++) { + struct semantic_verify_worker *worker = &workers[i]; + int err; + + worker->istate = istate; + worker->root = proof->root; + worker->results = proof->results; + worker->start = st_mult(proof->cache_nr, i) / nr_threads; + worker->end = st_mult(proof->cache_nr, i + 1) / nr_threads; + worker->validate_filter_scope = proof->filter_scope_checked; + if (nr_threads == 1 || !create_threads) { + semantic_verify_worker_run(worker); + continue; + } + err = pthread_create(&worker->pthread, NULL, run_worker, worker); + if (!err) { + worker->started = 1; + continue; + } + create_threads = 0; + trace2_data_intmax("semantic_verify", istate->repo, + "thread-failure", err); + semantic_verify_worker_run(worker); + } + for (unsigned int i = 0; i < nr_threads; i++) { + int err; + + if (!workers[i].started) + continue; + err = pthread_join(workers[i].pthread, NULL); + if (err) + die("could not join semantic verifier thread: %s", + strerror(err)); + } + + for (unsigned int i = 0; i < nr_threads; i++) + updates_nr += workers[i].updates_nr; + ALLOC_ARRAY(proof->stat_updates, updates_nr); + for (unsigned int i = 0; i < nr_threads; i++) + combine_worker(proof, &workers[i]); + free(workers); trace2_data_intmax("semantic_verify", istate->repo, "raw-clean", proof->raw_clean); @@ -121,6 +195,10 @@ int semantic_verify_prepare(struct index_state *istate, "errors", proof->errors); trace2_data_intmax("semantic_verify", istate->repo, "bytes-hashed", proof->bytes_hashed); + trace2_data_intmax("semantic_verify", istate->repo, + "active-filters", proof->active_filters); + trace2_data_intmax("semantic_verify", istate->repo, + "filter-scope-checked", proof->filter_scope_checked); trace2_region_leave("semantic_verify", "prepare", istate->repo); return 0; } @@ -146,7 +224,9 @@ void semantic_verify_get_stats(const struct semantic_verify_proof *proof, stats->unstable = proof->unstable; stats->errors = proof->errors; stats->hardlinks = proof->hardlinks; + stats->active_filters = proof->active_filters; stats->namespace_unstable = proof->namespace_unstable; + stats->filter_scope_checked = proof->filter_scope_checked; } const struct semantic_verify_result *semantic_verify_result_at( diff --git a/semantic-verify.h b/semantic-verify.h index dc2f02fffdacf6..87692a3e88a424 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -4,6 +4,13 @@ struct index_state; struct semantic_verify_proof; +struct semantic_verify_options { + unsigned int nr_threads; + unsigned int validate_filter_scope : 1; +}; + +#define SEMANTIC_VERIFY_OPTIONS_INIT { 0 } + enum semantic_verify_kind { SEMANTIC_VERIFY_UNCHECKED = 0, SEMANTIC_VERIFY_SKIPPED, @@ -42,11 +49,14 @@ struct semantic_verify_stats { size_t unstable; size_t errors; size_t hardlinks; + size_t active_filters; unsigned int namespace_unstable; + unsigned int filter_scope_checked; }; /* Build a proof candidate without changing the index. */ int semantic_verify_prepare(struct index_state *istate, + const struct semantic_verify_options *options, struct semantic_verify_proof **proof_out); int semantic_verify_apply_after_closure( struct index_state *istate, diff --git a/t/helper/test-semantic-verify.c b/t/helper/test-semantic-verify.c index b0048dea791490..a868eb80fc4be2 100644 --- a/t/helper/test-semantic-verify.c +++ b/t/helper/test-semantic-verify.c @@ -33,10 +33,13 @@ static const char *kind_name(enum semantic_verify_kind kind) int cmd__semantic_verify(int argc, const char **argv) { + struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; struct semantic_verify_proof *proof = NULL; struct semantic_verify_stats stats; + int thread_count = 0; int show_results = 0; int apply = 0; + int validate_filter_scope = 0; int applied = -2; int before_uptodate = 0, after_uptodate = 0; int before_valid = 0, after_valid = 0; @@ -47,9 +50,13 @@ int cmd__semantic_verify(int argc, const char **argv) NULL }; struct option opts[] = { + OPT_INTEGER(0, "threads", &thread_count, + "number of verifier threads"), OPT_BOOL(0, "show-results", &show_results, "show one result per cache entry"), OPT_BOOL(0, "apply", &apply, "apply the completed proof"), + OPT_BOOL(0, "validate-filter-scope", &validate_filter_scope, + "classify filter use for every index entry"), OPT_STRING(0, "replace-after-prepare", &replace_after_prepare, "path", "replace an entry after preparing the proof"), OPT_END() @@ -58,6 +65,10 @@ int cmd__semantic_verify(int argc, const char **argv) argc = parse_options(argc, argv, NULL, opts, usage, 0); if (argc) usage_with_options(usage, opts); + if (thread_count < 0) + die("negative semantic verifier thread count"); + options.nr_threads = thread_count; + options.validate_filter_scope = validate_filter_scope; setup_git_directory(the_repository); repo_config(the_repository, git_default_config, NULL); @@ -65,7 +76,7 @@ int cmd__semantic_verify(int argc, const char **argv) the_repository->settings.command_requires_full_index = 0; if (repo_read_index(the_repository) < 0) die("unable to read index"); - ret = semantic_verify_prepare(the_repository->index, &proof); + ret = semantic_verify_prepare(the_repository->index, &options, &proof); semantic_verify_get_stats(proof, &stats); if (replace_after_prepare) { struct index_state *istate = the_repository->index; @@ -118,7 +129,8 @@ int cmd__semantic_verify(int argc, const char **argv) " bytes=%"PRIuMAX" stat_updates=%"PRIuMAX " root_stable=%d namespace_stable=%d applied=%d" " before_uptodate=%d before_valid=%d" - " after_uptodate=%d after_valid=%d\n", + " after_uptodate=%d after_valid=%d" + " active_filters=%"PRIuMAX" filter_scope_checked=%d\n", (uintmax_t)stats.cache_nr, (uintmax_t)stats.raw_clean, (uintmax_t)stats.raw_modified, (uintmax_t)stats.sensitive, (uintmax_t)stats.structural, (uintmax_t)stats.unstable, @@ -127,7 +139,8 @@ int cmd__semantic_verify(int argc, const char **argv) (uintmax_t)stats.stat_updates_nr, semantic_verify_root_is_stable(proof), !stats.namespace_unstable, applied, - before_uptodate, before_valid, after_uptodate, after_valid); + before_uptodate, before_valid, after_uptodate, after_valid, + (uintmax_t)stats.active_filters, stats.filter_scope_checked); semantic_verify_proof_clear(proof); return !!ret; } diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh index 828a1aaa9595be..a6e7edab9db034 100755 --- a/t/t7531-semantic-verify.sh +++ b/t/t7531-semantic-verify.sh @@ -110,6 +110,26 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "after_uptodate=0 after_valid=0" actual ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN,PTHREADS \ + 'parallel verification preserves index-order results' ' + test_create_repo parallel && + i=0 && + while test $i -lt 16 + do + mkdir "parallel/d$i" && + printf "*.txt -text attr_%s=value\n" "$i" \ + >"parallel/d$i/.gitattributes" && + printf "content %s\n" "$i" >"parallel/d$i/file.txt" && + i=$((i + 1)) || return 1 + done && + git -C parallel add . && + git -C parallel commit -m base && + + verify_repo parallel --threads=1 --show-results >expect && + verify_repo parallel --threads=4 --show-results >actual && + test_cmp expect actual +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'raw hashing uses the repository object format' ' git init --object-format=sha256 sha256 && @@ -120,4 +140,31 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "^tracked raw-clean persist=1" actual ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'filter scope includes early semantic exits' ' + test_create_repo filter-scope && + printf "unmatched filter=demo\n" >filter-scope/.gitattributes && + test_write_lines content >filter-scope/ordinary && + test_write_lines content >filter-scope/assumed && + git -C filter-scope add . && + git -C filter-scope commit -m base && + git -C filter-scope config filter.demo.clean cat && + git -C filter-scope update-index --assume-unchanged assumed && + verify_repo filter-scope --threads=4 --validate-filter-scope \ + --apply >actual.unused && + test_grep "applied=2 .*active_filters=0 " actual.unused && + test_grep "filter_scope_checked=1" actual.unused && + + test_write_lines "ordinary filter=demo" "assumed filter=demo" \ + >filter-scope/.gitattributes && + git -C filter-scope add .gitattributes && + git -C filter-scope commit -m attributes && + + verify_repo filter-scope --threads=4 --validate-filter-scope \ + --show-results --apply >actual && + test_grep "^assumed skipped" actual && + test_grep "applied=-1 .*active_filters=2 " actual && + test_grep "filter_scope_checked=1" actual +' + test_done From fda379383dce1730a448eae7e5bfa4a18b1937e1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:29:12 -0700 Subject: [PATCH 146/403] status: fingerprint configuration for semantic clean proofs A clean result cannot be reused after configuration changes that alter status or the conversion of tracked worktree bytes. Treating every configuration-origin change as a conversion change would also discard semantic proofs whose effective conversion rules remain identical. Add independently length-framed full and semantic configuration digests using a caller-selected Git object hash algorithm. Bind each full entry to its key, optional value, scope, origin type, and source file. Include effective line-ending and round-trip encoding settings, plus clean, process, and required filters, in the narrower semantic stream. Mark configured clean-side filters unsafe for direct raw verification. Register the configuration implementation and its unit suite in both Make and Meson. The tests distinguish ordinary status changes from conversion changes, confirm that an origin-only change affects only the full digest, and distinguish clean filters from smudge-only rules. This patch exposes and tests digest construction. Neither semantic proof preparation nor proof application consumes these digests, and no production status or index-read path uses them at this boundary. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-config.c | 94 ++++++++++++++++++++++ clean-status-config.h | 26 ++++++ hash-framing.h | 30 +++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-config.c | 113 +++++++++++++++++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 clean-status-config.c create mode 100644 clean-status-config.h create mode 100644 hash-framing.h create mode 100644 t/unit-tests/u-clean-status-config.c diff --git a/Makefile b/Makefile index 0c8545ab270abe..a4720dc87aaaea 100644 --- a/Makefile +++ b/Makefile @@ -1123,6 +1123,7 @@ LIB_OBJS += cbtree.o LIB_OBJS += chdir-notify.o LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o +LIB_OBJS += clean-status-config.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o @@ -1542,6 +1543,7 @@ THIRD_PARTY_SOURCES += sha1dc/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% +CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-config.c b/clean-status-config.c new file mode 100644 index 00000000000000..951893ac833117 --- /dev/null +++ b/clean-status-config.c @@ -0,0 +1,94 @@ +#include "git-compat-util.h" +#include "clean-status-config.h" +#include "config.h" +#include "hash-framing.h" +#include "strbuf.h" + +#define CLEAN_STATUS_FILTER_PROOF_DOMAIN \ + "clean-status-configured-filter-scope-v1" + +void clean_status_config_init(struct clean_status_config_digest *digest, + const struct git_hash_algo *algo) +{ + if (!algo) + BUG("clean-status config digest requires a hash algorithm"); + memset(digest, 0, sizeof(*digest)); + git_hash_init(&digest->ctx, algo); + git_hash_init(&digest->semantic_ctx, algo); + /* Invalidate proofs written before multiply-linked files stayed dirty. */ + hash_optional_cstring(&digest->ctx, + "clean-status-config-hardlink-v1"); + digest->initialized = 1; +} + +static void hash_config_entry(struct git_hash_ctx *ctx, + const char *key, const char *value, + const struct config_context *config_ctx) +{ + uint32_t metadata[2] = { 0 }; + + hash_optional_cstring(ctx, key); + hash_optional_cstring(ctx, value); + if (config_ctx && config_ctx->kvi) { + put_be32(&metadata[0], config_ctx->kvi->scope); + put_be32(&metadata[1], config_ctx->kvi->origin_type); + hash_length_delimited(ctx, metadata, sizeof(metadata)); + hash_optional_cstring(ctx, config_ctx->kvi->filename); + } else { + hash_length_delimited(ctx, metadata, sizeof(metadata)); + hash_optional_cstring(ctx, NULL); + } +} + +static void hash_effective_config_entry(struct git_hash_ctx *ctx, + const char *key, + const char *value) +{ + hash_optional_cstring(ctx, key); + hash_optional_cstring(ctx, value); +} + +void clean_status_config_add(struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx) +{ + const char *suffix; + int semantic; + + if (!digest->initialized || digest->finalized) + BUG("invalid clean-status config digest state"); + hash_config_entry(&digest->ctx, key, value, ctx); + semantic = !strcmp(key, "core.autocrlf") || + !strcmp(key, "core.eol") || + !strcmp(key, "core.checkroundtripencoding"); + if (skip_prefix(key, "filter.", &suffix) && + (ends_with(suffix, ".clean") || ends_with(suffix, ".process") || + ends_with(suffix, ".required"))) { + digest->filter_configured = 1; + semantic = 1; + } + if (semantic) { + hash_effective_config_entry(&digest->semantic_ctx, key, value); + digest->semantic_config_explicit = 1; + } +} + +void clean_status_config_final(struct clean_status_config_digest *digest) +{ + if (!digest->initialized || digest->finalized) + BUG("invalid clean-status config digest state"); + if (digest->filter_configured) { + /* + * Leave repositories without configured clean filters in their + * existing proof domain. Configured filters require a proof which + * has classified every tracked path before it may be reused. + */ + hash_optional_cstring(&digest->ctx, + CLEAN_STATUS_FILTER_PROOF_DOMAIN); + hash_optional_cstring(&digest->semantic_ctx, + CLEAN_STATUS_FILTER_PROOF_DOMAIN); + } + git_hash_final(digest->hash, &digest->ctx); + git_hash_final(digest->semantic_hash, &digest->semantic_ctx); + digest->finalized = 1; +} diff --git a/clean-status-config.h b/clean-status-config.h new file mode 100644 index 00000000000000..47420ed282d4d9 --- /dev/null +++ b/clean-status-config.h @@ -0,0 +1,26 @@ +#ifndef CLEAN_STATUS_CONFIG_H +#define CLEAN_STATUS_CONFIG_H + +#include "hash.h" + +struct config_context; + +struct clean_status_config_digest { + struct git_hash_ctx ctx; + struct git_hash_ctx semantic_ctx; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned initialized : 1; + unsigned finalized : 1; + unsigned filter_configured : 1; + unsigned semantic_config_explicit : 1; +}; + +void clean_status_config_init(struct clean_status_config_digest *digest, + const struct git_hash_algo *algo); +void clean_status_config_add(struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx); +void clean_status_config_final(struct clean_status_config_digest *digest); + +#endif /* CLEAN_STATUS_CONFIG_H */ diff --git a/hash-framing.h b/hash-framing.h new file mode 100644 index 00000000000000..b15294b684a90d --- /dev/null +++ b/hash-framing.h @@ -0,0 +1,30 @@ +#ifndef HASH_FRAMING_H +#define HASH_FRAMING_H + +#include "hash.h" + +static inline void hash_length_delimited(struct git_hash_ctx *ctx, + const void *data, size_t len) +{ + uint32_t size; + + if (len > UINT32_MAX) + BUG("length-delimited hash input too long"); + put_be32(&size, len); + git_hash_update(ctx, &size, sizeof(size)); + if (len) + git_hash_update(ctx, data, len); +} + +static inline void hash_optional_cstring(struct git_hash_ctx *ctx, + const char *value) +{ + static const unsigned char missing = 0; + + if (value) + hash_length_delimited(ctx, value, strlen(value)); + else + hash_length_delimited(ctx, &missing, sizeof(missing)); +} + +#endif /* HASH_FRAMING_H */ diff --git a/meson.build b/meson.build index ff471a3a092f5a..962f4e1dd1283c 100644 --- a/meson.build +++ b/meson.build @@ -331,6 +331,7 @@ libgit_sources = [ 'chdir-notify.c', 'checkout.c', 'chunk-format.c', + 'clean-status-config.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/t/meson.build b/t/meson.build index 4ecf8343a2f935..639db8354a46cb 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,4 +1,5 @@ clar_test_suites = [ + 'unit-tests/u-clean-status-config.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c new file mode 100644 index 00000000000000..cc88bb0680518c --- /dev/null +++ b/t/unit-tests/u-clean-status-config.c @@ -0,0 +1,113 @@ +#include "unit-test.h" +#include "clean-status-config.h" +#include "config.h" + +static void digest_one(struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx) +{ + clean_status_config_init(digest, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_add(digest, key, value, ctx); + clean_status_config_final(digest); +} + +static int hashes_equal(const unsigned char *a, const unsigned char *b) +{ + return hasheq(a, b, &hash_algos[GIT_HASH_SHA1]); +} + +void test_clean_status_config__non_semantic_values_only_change_full_hash(void) +{ + struct clean_status_config_digest a, b; + + digest_one(&a, "status.showuntrackedfiles", "normal", NULL); + digest_one(&b, "status.showuntrackedfiles", "all", NULL); + cl_assert(!hashes_equal(a.hash, b.hash)); + cl_assert(hashes_equal(a.semantic_hash, b.semantic_hash)); + cl_assert(!a.semantic_config_explicit); + cl_assert(!b.semantic_config_explicit); +} + +void test_clean_status_config__semantic_values_change_semantic_hash(void) +{ + struct clean_status_config_digest a, b; + + digest_one(&a, "core.autocrlf", "true", NULL); + digest_one(&b, "core.autocrlf", "false", NULL); + cl_assert(!hashes_equal(a.semantic_hash, b.semantic_hash)); + cl_assert(a.semantic_config_explicit); + cl_assert(b.semantic_config_explicit); +} + +void test_clean_status_config__origin_only_affects_full_hash(void) +{ + struct key_value_info global_kvi = KVI_INIT; + struct key_value_info local_kvi = KVI_INIT; + struct config_context global_ctx = { .kvi = &global_kvi }; + struct config_context local_ctx = { .kvi = &local_kvi }; + struct clean_status_config_digest global, local; + + global_kvi.scope = CONFIG_SCOPE_GLOBAL; + global_kvi.origin_type = CONFIG_ORIGIN_FILE; + global_kvi.filename = "/global"; + local_kvi.scope = CONFIG_SCOPE_LOCAL; + local_kvi.origin_type = CONFIG_ORIGIN_FILE; + local_kvi.filename = "/local"; + digest_one(&global, "core.eol", "lf", &global_ctx); + digest_one(&local, "core.eol", "lf", &local_ctx); + cl_assert(!hashes_equal(global.hash, local.hash)); + cl_assert(hashes_equal(global.semantic_hash, local.semantic_hash)); +} + +static void digest_without_final_domain( + const struct clean_status_config_digest *digest, + unsigned char *full_hash, unsigned char *semantic_hash) +{ + struct git_hash_ctx full, semantic; + + git_hash_init(&full, &hash_algos[GIT_HASH_SHA1]); + git_hash_init(&semantic, &hash_algos[GIT_HASH_SHA1]); + git_hash_clone(&full, &digest->ctx); + git_hash_clone(&semantic, &digest->semantic_ctx); + git_hash_final(full_hash, &full); + git_hash_final(semantic_hash, &semantic); +} + +void test_clean_status_config__configured_filters_bump_proof_domains(void) +{ + static const char *const configured_suffixes[] = { + "clean", "process", "required", + }; + struct clean_status_config_digest smudge; + unsigned char smudge_full[GIT_MAX_RAWSZ]; + unsigned char smudge_semantic[GIT_MAX_RAWSZ]; + + for (size_t i = 0; i < ARRAY_SIZE(configured_suffixes); i++) { + struct clean_status_config_digest configured; + unsigned char full[GIT_MAX_RAWSZ]; + unsigned char semantic[GIT_MAX_RAWSZ]; + char *key = xstrfmt("filter.demo.%s", configured_suffixes[i]); + + clean_status_config_init(&configured, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_add(&configured, key, "command", NULL); + digest_without_final_domain(&configured, full, semantic); + clean_status_config_final(&configured); + cl_assert(configured.filter_configured); + cl_assert(configured.semantic_config_explicit); + cl_assert(!hashes_equal(configured.hash, full)); + cl_assert(!hashes_equal(configured.semantic_hash, semantic)); + free(key); + } + + clean_status_config_init(&smudge, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_add( + &smudge, "filter.demo.smudge", "command", NULL); + digest_without_final_domain( + &smudge, smudge_full, smudge_semantic); + clean_status_config_final(&smudge); + + cl_assert(!smudge.filter_configured); + cl_assert(!smudge.semantic_config_explicit); + cl_assert(hashes_equal(smudge.hash, smudge_full)); + cl_assert(hashes_equal(smudge.semantic_hash, smudge_semantic)); +} From c720c583c407173f0b624b8e744595e5a22cf829 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:03:23 -0700 Subject: [PATCH 147/403] dir: compare all saved metadata for UNTR directories valid_cached_dir() used match_stat_data_racy(), whose treatment of ctime and other fields follows core.trustCtime and core.checkStat. Tracked entries can correct a false stat match with a later content comparison, but cached directories have no equivalent check. Renaming a child and restoring its parent's mtime can therefore hide untracked paths under weak stat settings. Compare every field persisted in directory stat_data regardless of those tracked-file settings, and retain the existing racy-timestamp check. The untracked-cache status test renames a child, restores the directory mtime, and verifies that cached and uncached status agree. Signed-off-by: Taylor Blau --- dir.c | 39 ++++++++++++++++++++++++++++++- t/t7063-status-untracked-cache.sh | 26 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/dir.c b/dir.c index 95d8a1cce90f77..f55c9377df6942 100644 --- a/dir.c +++ b/dir.c @@ -71,6 +71,42 @@ struct cached_dir { struct untracked_cache_dir *ucd; }; +/* + * Unlike cache entries, an untracked-cache directory has no later content + * check to correct a false stat match. Compare every field saved in UNTR, + * regardless of core.checkStat or core.trustCtime. + */ +static int match_untracked_dir_stat(const struct stat_data *sd, + const struct stat *st) +{ + return !S_ISDIR(st->st_mode) || + sd->sd_ctime.sec != (unsigned int)st->st_ctime || + sd->sd_ctime.nsec != ST_CTIME_NSEC(*st) || + sd->sd_mtime.sec != (unsigned int)st->st_mtime || + sd->sd_mtime.nsec != ST_MTIME_NSEC(*st) || + sd->sd_dev != (unsigned int)st->st_dev || + sd->sd_ino != (unsigned int)st->st_ino || + sd->sd_uid != (unsigned int)st->st_uid || + sd->sd_gid != (unsigned int)st->st_gid || + sd->sd_size != (unsigned int)st->st_size; +} + +static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, + const struct stat_data *sd, + const struct stat *st) +{ + if (timestamp->sec && +#ifdef USE_NSEC + (timestamp->sec < sd->sd_mtime.sec || + (timestamp->sec == sd->sd_mtime.sec && + timestamp->nsec <= sd->sd_mtime.nsec))) +#else + timestamp->sec <= sd->sd_mtime.sec) +#endif + return MTIME_CHANGED; + return match_untracked_dir_stat(sd, st); +} + static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct index_state *istate, const char *path, int len, struct untracked_cache_dir *untracked, @@ -2541,7 +2577,8 @@ static int valid_cached_dir(struct dir_struct *dir, return 0; } if (!untracked->valid || - match_stat_data_racy(istate, &untracked->stat_data, &st)) { + match_untracked_dir_stat_racy( + &istate->timestamp, &untracked->stat_data, &st)) { fill_stat_data(&untracked->stat_data, &st); return 0; } diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 8929ef481f926c..4ab20cc0693dc4 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -991,4 +991,30 @@ test_expect_success 'empty repo (no index) and core.untrackedCache' ' git -C emptyrepo -c core.untrackedCache=true write-tree ' +test_expect_success 'directory snapshots ignore weak file-stat configuration' ' + test_create_repo weak-dir && + ( + cd weak-dir && + mkdir nested && + echo tracked >tracked && + echo one >nested/one && + git add tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + git config core.trustCtime false && + git config core.checkStat minimal && + avoid_racy && + git status --porcelain -uall >/dev/null && + git status --porcelain -uall >/dev/null && + dir_mtime=$(test-tool chmtime --get nested) && + mv nested/one nested/two && + test-tool chmtime =$dir_mtime nested && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain -uall >.git/expect && + git status --porcelain -uall >.git/actual && + test_cmp .git/expect .git/actual + ) +' + test_done From 146d359dfeba1e7ecc37b546950041077599a5d6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:30:22 -0700 Subject: [PATCH 148/403] status: write bounded attribute manifests A reusable clean-status proof must identify the .gitattributes source that governs conversion in every tracked directory. An unordered list of paths and hashes cannot distinguish duplicate records, ambiguous paths, or a worktree source from its indexed counterpart. Add a length-delimited manifest writer with an entry count, repository-relative .gitattributes paths, explicit source kinds, and object-format-sized hashes. Reject invalid paths, unknown source kinds, overflows, duplicate records, and nonincreasing path order before appending an entry. Register the new library and Clar suite in both Make and Meson. Focused tests cover ordered records and reject malformed, duplicate, and out-of-order paths. This defines a tested encoding; it does not enable a status fast path. Signed-off-by: Taylor Blau --- Makefile | 2 + attr-manifest.c | 94 ++++++++++++++++++++++++++++++++++ attr-manifest.h | 35 +++++++++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-attr-manifest.c | 53 +++++++++++++++++++ 6 files changed, 186 insertions(+) create mode 100644 attr-manifest.c create mode 100644 attr-manifest.h create mode 100644 t/unit-tests/u-attr-manifest.c diff --git a/Makefile b/Makefile index a4720dc87aaaea..67916eeab63f72 100644 --- a/Makefile +++ b/Makefile @@ -1110,6 +1110,7 @@ LIB_OBJS += archive-tar.o LIB_OBJS += archive-zip.o LIB_OBJS += archive.o LIB_OBJS += attr.o +LIB_OBJS += attr-manifest.o LIB_OBJS += base85.o LIB_OBJS += bisect.o LIB_OBJS += blame.o @@ -1543,6 +1544,7 @@ THIRD_PARTY_SOURCES += sha1dc/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% +CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir diff --git a/attr-manifest.c b/attr-manifest.c new file mode 100644 index 00000000000000..41220073ff59c7 --- /dev/null +++ b/attr-manifest.c @@ -0,0 +1,94 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "environment.h" +#include "read-cache-ll.h" +#include "strbuf.h" + +/* + * A manifest begins with a 32-bit entry count. Each entry contains a 32-bit + * path length, four bytes of source metadata, an object-format hash, and the + * unterminated path. Paths are strictly increasing. + */ +static int attr_manifest_path_valid(const unsigned char *path, size_t len) +{ + const char *base; + char *copy; + int valid; + + if (!len || path[0] == '/' || memchr(path, '\0', len)) + return 0; + copy = xmemdupz(path, len); + base = strrchr(copy, '/'); + base = base ? base + 1 : copy; + valid = !strcmp(base, GITATTRIBUTES_FILE) && + verify_path(copy, S_IFREG | 0644); + free(copy); + return valid; +} + +static int attr_manifest_entry_cmp(const struct attr_manifest_entry *a, + const struct attr_manifest_entry *b) +{ + size_t common = a->path_len < b->path_len ? a->path_len : b->path_len; + int cmp = memcmp(a->path, b->path, common); + + if (cmp) + return cmp; + return a->path_len < b->path_len ? -1 : a->path_len > b->path_len; +} + +void attr_manifest_writer_init(struct attr_manifest_writer *writer, + struct strbuf *buf, + const struct git_hash_algo *algo) +{ + uint32_t count; + + if (!algo) + BUG("attribute manifest requires a hash algorithm"); + memset(writer, 0, sizeof(*writer)); + writer->buf = buf; + writer->algo = algo; + strbuf_reset(buf); + put_be32(&count, 0); + strbuf_add(buf, &count, sizeof(count)); +} + +int attr_manifest_writer_add(struct attr_manifest_writer *writer, + const char *path, + enum attr_manifest_source source, + const unsigned char *hash) +{ + struct attr_manifest_entry previous, current; + unsigned char metadata[4] = { source, 0, 0, 0 }; + uint32_t path_len_be; + size_t entry_offset, path_len = strlen(path); + + if (!writer->buf || !writer->algo || !hash || !path_len || + path_len > UINT32_MAX || writer->nr == UINT32_MAX || + (source != ATTR_MANIFEST_WORKTREE && + source != ATTR_MANIFEST_INDEX) || + !attr_manifest_path_valid((const unsigned char *)path, path_len)) + return -1; + + current.path = (const unsigned char *)path; + current.path_len = path_len; + if (writer->nr) { + previous.path = (const unsigned char *)writer->buf->buf + + writer->last_path_offset; + previous.path_len = writer->last_path_len; + if (attr_manifest_entry_cmp(&previous, ¤t) >= 0) + return -1; + } + + entry_offset = writer->buf->len; + put_be32(&path_len_be, path_len); + strbuf_add(writer->buf, &path_len_be, sizeof(path_len_be)); + strbuf_add(writer->buf, metadata, sizeof(metadata)); + strbuf_add(writer->buf, hash, writer->algo->rawsz); + strbuf_add(writer->buf, path, path_len); + writer->last_path_offset = entry_offset + sizeof(path_len_be) + + sizeof(metadata) + writer->algo->rawsz; + writer->last_path_len = path_len; + put_be32(writer->buf->buf, ++writer->nr); + return 0; +} diff --git a/attr-manifest.h b/attr-manifest.h new file mode 100644 index 00000000000000..75296bae8f1f0e --- /dev/null +++ b/attr-manifest.h @@ -0,0 +1,35 @@ +#ifndef ATTR_MANIFEST_H +#define ATTR_MANIFEST_H + +#include "hash.h" + +struct strbuf; + +enum attr_manifest_source { + ATTR_MANIFEST_WORKTREE = 1, + ATTR_MANIFEST_INDEX = 2, +}; + +struct attr_manifest_entry { + const unsigned char *path; + uint32_t path_len; + enum attr_manifest_source source; + const unsigned char *hash; +}; + +struct attr_manifest_writer { + struct strbuf *buf; + const struct git_hash_algo *algo; + size_t last_path_offset; + uint32_t last_path_len; + uint32_t nr; +}; + +void attr_manifest_writer_init(struct attr_manifest_writer *writer, + struct strbuf *buf, + const struct git_hash_algo *algo); +int attr_manifest_writer_add(struct attr_manifest_writer *writer, + const char *path, + enum attr_manifest_source source, + const unsigned char *hash); +#endif /* ATTR_MANIFEST_H */ diff --git a/meson.build b/meson.build index 962f4e1dd1283c..56b903ab289d75 100644 --- a/meson.build +++ b/meson.build @@ -317,6 +317,7 @@ libgit_sources = [ 'archive-tar.c', 'archive-zip.c', 'archive.c', + 'attr-manifest.c', 'attr.c', 'base85.c', 'bisect.c', diff --git a/t/meson.build b/t/meson.build index 639db8354a46cb..d38d0f5be3cb08 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,4 +1,5 @@ clar_test_suites = [ + 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c new file mode 100644 index 00000000000000..277e3101d042e7 --- /dev/null +++ b/t/unit-tests/u-attr-manifest.c @@ -0,0 +1,53 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "strbuf.h" + +static void fill_hash(unsigned char *hash, unsigned char value, + const struct git_hash_algo *algo) +{ + memset(hash, value, algo->rawsz); +} + +static void add_entry(struct attr_manifest_writer *writer, const char *path, + enum attr_manifest_source source, unsigned char value) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + fill_hash(hash, value, writer->algo); + cl_assert_equal_i(attr_manifest_writer_add(writer, path, source, hash), 0); +} + +void test_attr_manifest__writer_serializes_sorted_entries(void) +{ + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + + attr_manifest_writer_init(&writer, &manifest, &hash_algos[GIT_HASH_SHA256]); + add_entry(&writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&writer, "a/.gitattributes", ATTR_MANIFEST_WORKTREE, 2); + cl_assert_equal_i(get_be32(manifest.buf), 2); + cl_assert_equal_i(writer.nr, 2); + strbuf_release(&manifest); +} + +void test_attr_manifest__writer_rejects_invalid_or_unsorted_paths(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + + fill_hash(hash, 1, algo); + attr_manifest_writer_init(&writer, &manifest, algo); + add_entry(&writer, "b/.gitattributes", ATTR_MANIFEST_INDEX, 1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "a/.gitattributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "b/.gitattributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "/.gitattributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(attr_manifest_writer_add(&writer, "b/not-attributes", + ATTR_MANIFEST_INDEX, hash), -1); + cl_assert_equal_i(writer.nr, 1); + strbuf_release(&manifest); +} From eb023d3f5af0a4c5e62735b709b6774b7e7caedf Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:04:03 -0700 Subject: [PATCH 149/403] dir: consume preloaded UNTR directory stat results valid_cached_dir() performs a synchronous lstat() whenever an untracked-cache directory cannot rely on fsmonitor. A separate validation pass cannot remove that duplicated work unless traversal knows whether a saved result was checked and matched. Add transient stat_checked and stat_matches bits to each cached directory and let traversal consume them only when its caller marks the cache preloaded. Clear that marker after traversal and on the symlink-leading-path exit. Existing callers leave the marker clear, so ordinary lstat() validation and the fsmonitor path remain unchanged. Signed-off-by: Taylor Blau --- dir.c | 28 +++++++++++++++++++--------- dir.h | 4 ++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/dir.c b/dir.c index f55c9377df6942..2bfe4dbd1637e4 100644 --- a/dir.c +++ b/dir.c @@ -2572,15 +2572,23 @@ static int valid_cached_dir(struct dir_struct *dir, */ refresh_fsmonitor(istate); if (!(dir->untracked->use_fsmonitor && untracked->valid)) { - if (lstat(path->len ? path->buf : ".", &st)) { - memset(&untracked->stat_data, 0, sizeof(untracked->stat_data)); - return 0; - } - if (!untracked->valid || - match_untracked_dir_stat_racy( - &istate->timestamp, &untracked->stat_data, &st)) { - fill_stat_data(&untracked->stat_data, &st); - return 0; + if (dir->internal.untracked_cache_preloaded && + untracked->stat_checked) { + if (!untracked->valid || !untracked->stat_matches) + return 0; + } else { + if (lstat(path->len ? path->buf : ".", &st)) { + memset(&untracked->stat_data, 0, + sizeof(untracked->stat_data)); + return 0; + } + if (!untracked->valid || + match_untracked_dir_stat_racy( + &istate->timestamp, + &untracked->stat_data, &st)) { + fill_stat_data(&untracked->stat_data, &st); + return 0; + } } } @@ -3179,6 +3187,7 @@ int read_directory(struct dir_struct *dir, struct index_state *istate, dir->internal.visited_directories = 0; if (has_symlink_leading_path(path, len)) { + dir->internal.untracked_cache_preloaded = 0; trace2_region_leave("dir", "read_directory", istate->repo); return dir->nr; } @@ -3217,6 +3226,7 @@ int read_directory(struct dir_struct *dir, struct index_state *istate, } } + dir->internal.untracked_cache_preloaded = 0; return dir->nr; } diff --git a/dir.h b/dir.h index 83e0f648a81f36..d6fc0c8ef8676a 100644 --- a/dir.h +++ b/dir.h @@ -182,6 +182,9 @@ struct untracked_cache_dir { /* all data except 'dirs' in this struct are good */ unsigned int valid : 1; unsigned int recurse : 1; + /* transient results from directory-stat preloading */ + unsigned int stat_checked : 1; + unsigned int stat_matches : 1; /* null object ID means this directory does not have .gitignore */ struct object_id exclude_oid; char name[FLEX_ARRAY]; @@ -353,6 +356,7 @@ struct dir_struct { /* Stats about the traversal */ unsigned visited_paths; unsigned visited_directories; + unsigned untracked_cache_preloaded : 1; } internal; }; From ff2bab5876a9e6eb20505f1887dc364c601e0452 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:01 -0700 Subject: [PATCH 150/403] status: validate attribute manifest records A persisted attribute manifest is untrusted even when its writer was careful. A truncated record, forged entry count, invalid source, or trailing byte could otherwise make a later reader accept incomplete or ambiguous conversion history. Add a bounded cursor for the format from S07/P01. Check the declared count against the minimum record size, enforce valid paths, known source kinds, zero reserved bytes, object-format-sized hashes, strict path ordering, and exact consumption of the input. Test round trips, an empty manifest, truncation, trailing data, nonzero reserved bytes, and an overstated entry count. Decoding only exposes records; it does not publish or consume status history. Signed-off-by: Taylor Blau --- attr-manifest.c | 79 ++++++++++++++++++++++++++++++++++ attr-manifest.h | 17 ++++++++ t/unit-tests/u-attr-manifest.c | 59 +++++++++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/attr-manifest.c b/attr-manifest.c index 41220073ff59c7..694c787d45fd82 100644 --- a/attr-manifest.c +++ b/attr-manifest.c @@ -92,3 +92,82 @@ int attr_manifest_writer_add(struct attr_manifest_writer *writer, put_be32(writer->buf->buf, ++writer->nr); return 0; } + +int attr_manifest_cursor_init(struct attr_manifest_cursor *cursor, + const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *bytes = data; + size_t minimum_entry_size; + + if (!algo) + BUG("attribute manifest requires a hash algorithm"); + if (len < sizeof(uint32_t)) + return -1; + minimum_entry_size = sizeof(uint32_t) + 4 + algo->rawsz + 1; + cursor->p = bytes + sizeof(uint32_t); + cursor->end = bytes + len; + cursor->last_path = NULL; + cursor->algo = algo; + cursor->last_path_len = 0; + cursor->remaining = get_be32(bytes); + if (cursor->remaining > + (len - sizeof(uint32_t)) / minimum_entry_size) + return -1; + return 0; +} + +int attr_manifest_cursor_next(struct attr_manifest_cursor *cursor, + struct attr_manifest_entry *entry) +{ + struct attr_manifest_entry previous; + uint32_t path_len; + size_t available; + + if (!cursor->remaining) + return cursor->p == cursor->end ? 0 : -1; + available = cursor->end - cursor->p; + if (available < sizeof(uint32_t) + 4 + cursor->algo->rawsz) + return -1; + path_len = get_be32(cursor->p); + cursor->p += sizeof(uint32_t); + entry->source = cursor->p[0]; + if ((entry->source != ATTR_MANIFEST_WORKTREE && + entry->source != ATTR_MANIFEST_INDEX) || + cursor->p[1] || cursor->p[2] || cursor->p[3]) + return -1; + cursor->p += 4; + entry->hash = cursor->p; + cursor->p += cursor->algo->rawsz; + available = cursor->end - cursor->p; + if (!path_len || available < path_len || + !attr_manifest_path_valid(cursor->p, path_len)) + return -1; + entry->path = cursor->p; + entry->path_len = path_len; + if (cursor->last_path) { + previous.path = cursor->last_path; + previous.path_len = cursor->last_path_len; + if (attr_manifest_entry_cmp(&previous, entry) >= 0) + return -1; + } + cursor->last_path = entry->path; + cursor->last_path_len = entry->path_len; + cursor->p += path_len; + cursor->remaining--; + return 1; +} + +int attr_manifest_valid(const void *data, size_t len, + const struct git_hash_algo *algo) +{ + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + int ret; + + if (attr_manifest_cursor_init(&cursor, data, len, algo)) + return 0; + while ((ret = attr_manifest_cursor_next(&cursor, &entry)) > 0) + ; + return !ret; +} diff --git a/attr-manifest.h b/attr-manifest.h index 75296bae8f1f0e..a3e6de4b556c33 100644 --- a/attr-manifest.h +++ b/attr-manifest.h @@ -17,6 +17,15 @@ struct attr_manifest_entry { const unsigned char *hash; }; +struct attr_manifest_cursor { + const unsigned char *p; + const unsigned char *end; + const unsigned char *last_path; + const struct git_hash_algo *algo; + uint32_t last_path_len; + uint32_t remaining; +}; + struct attr_manifest_writer { struct strbuf *buf; const struct git_hash_algo *algo; @@ -32,4 +41,12 @@ int attr_manifest_writer_add(struct attr_manifest_writer *writer, const char *path, enum attr_manifest_source source, const unsigned char *hash); +int attr_manifest_cursor_init(struct attr_manifest_cursor *cursor, + const void *data, size_t len, + const struct git_hash_algo *algo); +int attr_manifest_cursor_next(struct attr_manifest_cursor *cursor, + struct attr_manifest_entry *entry); +int attr_manifest_valid(const void *data, size_t len, + const struct git_hash_algo *algo); + #endif /* ATTR_MANIFEST_H */ diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 277e3101d042e7..d03cc41c273da5 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -51,3 +51,62 @@ void test_attr_manifest__writer_rejects_invalid_or_unsorted_paths(void) cl_assert_equal_i(writer.nr, 1); strbuf_release(&manifest); } + +void test_attr_manifest__reader_round_trips_entries(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + struct attr_manifest_writer writer; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct strbuf manifest = STRBUF_INIT; + + attr_manifest_writer_init(&writer, &manifest, algo); + add_entry(&writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&writer, "a/.gitattributes", ATTR_MANIFEST_WORKTREE, 2); + cl_assert(attr_manifest_valid(manifest.buf, manifest.len, algo)); + cl_assert_equal_i(attr_manifest_cursor_init(&cursor, manifest.buf, + manifest.len, algo), 0); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_INDEX); + cl_assert_equal_i(entry.hash[0], 1); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_WORKTREE); + cl_assert_equal_i(entry.hash[0], 2); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + strbuf_release(&manifest); +} + +void test_attr_manifest__reader_rejects_corrupt_encoding(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + size_t metadata_offset = 2 * sizeof(uint32_t); + unsigned char saved; + + attr_manifest_writer_init(&writer, &manifest, algo); + add_entry(&writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len - 1, algo)); + strbuf_addch(&manifest, 0); + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len, algo)); + strbuf_setlen(&manifest, manifest.len - 1); + + saved = manifest.buf[metadata_offset + 1]; + manifest.buf[metadata_offset + 1] = 1; + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len, algo)); + manifest.buf[metadata_offset + 1] = saved; + put_be32(manifest.buf, 2); + cl_assert(!attr_manifest_valid(manifest.buf, manifest.len, algo)); + strbuf_release(&manifest); +} + +void test_attr_manifest__reader_accepts_empty_manifest(void) +{ + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + + attr_manifest_writer_init(&writer, &manifest, &hash_algos[GIT_HASH_SHA1]); + cl_assert(attr_manifest_valid(manifest.buf, manifest.len, + &hash_algos[GIT_HASH_SHA1])); + strbuf_release(&manifest); +} From 9bf0c8abccd7320a9f1056343a90eb12b80017ba Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:05:22 -0700 Subject: [PATCH 151/403] dir: snapshot UNTR directory validation inputs Tracked-index refresh can invalidate the mutable untracked-cache tree. A concurrent directory-validation worker therefore cannot discover nodes or read their validation inputs directly from that live tree. Capture each node pointer, copied pathname, saved stat data, and prior validity before concurrent work begins. The opaque preload object also retains the cache, root, index timestamp, repository, and directory flags needed to recognize its original context. Expose construction and release as a complete ownership boundary. This preparatory change allocates one snapshot per cached directory but has no production caller and does not start workers or publish results. Signed-off-by: Taylor Blau --- dir.c | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dir.h | 5 ++++ 2 files changed, 91 insertions(+) diff --git a/dir.c b/dir.c index 2bfe4dbd1637e4..a66050a6f55e9b 100644 --- a/dir.c +++ b/dir.c @@ -71,6 +71,92 @@ struct cached_dir { struct untracked_cache_dir *ucd; }; +struct untracked_cache_preload_task { + struct untracked_cache_dir *ucd; + char *path; + struct stat_data stat_data; + unsigned int was_valid : 1; + unsigned int stat_checked : 1; + unsigned int stat_matches : 1; + unsigned int update_stat_data : 1; +}; + +struct untracked_cache_preload { + struct repository *repo; + struct untracked_cache *uc; + struct untracked_cache_dir *root; + struct untracked_cache_preload_task *tasks; + struct cache_time index_timestamp; + size_t nr; + unsigned int dir_flags; +}; + +static void collect_untracked_cache_preload_tasks( + struct untracked_cache_dir *ucd, + struct strbuf *path, + struct untracked_cache_preload_task **tasks, + size_t *nr, + size_t *alloc) +{ + size_t i; + + ALLOC_GROW(*tasks, *nr + 1, *alloc); + memset(&(*tasks)[*nr], 0, sizeof(**tasks)); + (*tasks)[*nr].ucd = ucd; + (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); + (*tasks)[*nr].stat_data = ucd->stat_data; + (*tasks)[*nr].was_valid = ucd->valid; + (*nr)++; + + for (i = 0; i < ucd->dirs_nr; i++) { + struct untracked_cache_dir *child = ucd->dirs[i]; + size_t old_len = path->len; + + if (path->len) + strbuf_addch(path, '/'); + strbuf_addstr(path, child->name); + collect_untracked_cache_preload_tasks(child, path, tasks, nr, + alloc); + strbuf_setlen(path, old_len); + } +} + +struct untracked_cache_preload *untracked_cache_preload_start_ordinary( + struct index_state *istate, unsigned int dir_flags) +{ + struct untracked_cache *uc = istate->untracked; + struct untracked_cache_preload *preload; + struct strbuf path = STRBUF_INIT; + size_t alloc = 0; + + if (!uc || !uc->root || uc->use_fsmonitor || + uc->dir_flags != dir_flags) + return NULL; + + CALLOC_ARRAY(preload, 1); + preload->repo = istate->repo; + preload->uc = uc; + preload->root = uc->root; + preload->index_timestamp = istate->timestamp; + preload->dir_flags = dir_flags; + collect_untracked_cache_preload_tasks( + uc->root, &path, &preload->tasks, &preload->nr, &alloc); + strbuf_release(&path); + return preload; +} + +void untracked_cache_preload_release(struct untracked_cache_preload *preload) +{ + size_t i; + + if (!preload) + return; + for (i = 0; i < preload->nr; i++) + free(preload->tasks[i].path); + free(preload->tasks); + free(preload); +} + /* * Unlike cache entries, an untracked-cache directory has no later content * check to correct a false stat match. Compare every field saved in UNTR, diff --git a/dir.h b/dir.h index d6fc0c8ef8676a..c6273985981a60 100644 --- a/dir.h +++ b/dir.h @@ -611,6 +611,11 @@ void untracked_cache_invalidate_trimmed_path(struct index_state *, void untracked_cache_remove_from_index(struct index_state *, const char *); void untracked_cache_add_to_index(struct index_state *, const char *); +struct untracked_cache_preload; +struct untracked_cache_preload *untracked_cache_preload_start_ordinary( + struct index_state *, unsigned int dir_flags); +void untracked_cache_preload_release(struct untracked_cache_preload *); + void free_untracked_cache(struct untracked_cache *); struct untracked_cache *read_untracked_extension(const void *data, unsigned long sz); void write_untracked_extension(struct strbuf *out, struct untracked_cache *untracked); From 4b7653a0a59bad777a625a8d273ede6132137f2c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:54 -0700 Subject: [PATCH 152/403] status: iterate changed attribute manifest entries Refreshing conversion history must invalidate added, removed, and changed .gitattributes sources without reporting unchanged paths. Acting on a partially decoded stream would be worse: a corrupt trailing record could leave some paths invalidated before the failure is known. Validate both complete manifests with S07/P02 before invoking a callback. Merge their ordered cursors without copying entries, report each added or removed path once, and treat a source-kind or hash change as a modification. Do not invoke the callback for records whose path, source, and hash all match. Up-front validation deliberately reads each entire manifest before merging. The additional pass prevents partial callback effects without materializing another collection of paths. Unit tests cover additions, removals, source changes, identical manifests, and a malformed tail that must produce no callbacks. Signed-off-by: Taylor Blau --- attr-manifest.c | 59 ++++++++++++++++++++++++++ attr-manifest.h | 7 ++++ t/unit-tests/u-attr-manifest.c | 77 ++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/attr-manifest.c b/attr-manifest.c index 694c787d45fd82..46aed49a430050 100644 --- a/attr-manifest.c +++ b/attr-manifest.c @@ -37,6 +37,14 @@ static int attr_manifest_entry_cmp(const struct attr_manifest_entry *a, return a->path_len < b->path_len ? -1 : a->path_len > b->path_len; } +static int attr_manifest_entry_equal(const struct attr_manifest_entry *a, + const struct attr_manifest_entry *b, + const struct git_hash_algo *algo) +{ + return !attr_manifest_entry_cmp(a, b) && a->source == b->source && + !memcmp(a->hash, b->hash, algo->rawsz); +} + void attr_manifest_writer_init(struct attr_manifest_writer *writer, struct strbuf *buf, const struct git_hash_algo *algo) @@ -171,3 +179,54 @@ int attr_manifest_valid(const void *data, size_t len, ; return !ret; } + +int attr_manifest_for_each_changed(const void *old_data, size_t old_len, + const void *new_data, size_t new_len, + const struct git_hash_algo *algo, + attr_manifest_change_fn fn, void *data) +{ + struct attr_manifest_cursor old_cursor, new_cursor; + struct attr_manifest_entry old_entry, new_entry; + int old_ret, new_ret; + + /* + * Callers use this as a transactional change set. Validate both + * streams before allowing the callback to observe any entry. + */ + if (!attr_manifest_valid(old_data, old_len, algo) || + !attr_manifest_valid(new_data, new_len, algo)) + return -1; + if (attr_manifest_cursor_init(&old_cursor, old_data, old_len, algo) || + attr_manifest_cursor_init(&new_cursor, new_data, new_len, algo)) + return -1; + old_ret = attr_manifest_cursor_next(&old_cursor, &old_entry); + new_ret = attr_manifest_cursor_next(&new_cursor, &new_entry); + while (old_ret > 0 || new_ret > 0) { + struct attr_manifest_entry changed; + int has_changed = 1; + int cmp; + + if (old_ret <= 0) + cmp = 1; + else if (new_ret <= 0) + cmp = -1; + else + cmp = attr_manifest_entry_cmp(&old_entry, &new_entry); + if (cmp < 0) { + changed = old_entry; + old_ret = attr_manifest_cursor_next(&old_cursor, &old_entry); + } else if (cmp > 0) { + changed = new_entry; + new_ret = attr_manifest_cursor_next(&new_cursor, &new_entry); + } else { + changed = new_entry; + has_changed = !attr_manifest_entry_equal( + &old_entry, &new_entry, algo); + old_ret = attr_manifest_cursor_next(&old_cursor, &old_entry); + new_ret = attr_manifest_cursor_next(&new_cursor, &new_entry); + } + if (has_changed && fn(&changed, data)) + return -1; + } + return old_ret < 0 || new_ret < 0 ? -1 : 0; +} diff --git a/attr-manifest.h b/attr-manifest.h index a3e6de4b556c33..a38acccc224832 100644 --- a/attr-manifest.h +++ b/attr-manifest.h @@ -34,6 +34,9 @@ struct attr_manifest_writer { uint32_t nr; }; +typedef int (*attr_manifest_change_fn)(const struct attr_manifest_entry *entry, + void *data); + void attr_manifest_writer_init(struct attr_manifest_writer *writer, struct strbuf *buf, const struct git_hash_algo *algo); @@ -48,5 +51,9 @@ int attr_manifest_cursor_next(struct attr_manifest_cursor *cursor, struct attr_manifest_entry *entry); int attr_manifest_valid(const void *data, size_t len, const struct git_hash_algo *algo); +int attr_manifest_for_each_changed(const void *old_data, size_t old_len, + const void *new_data, size_t new_len, + const struct git_hash_algo *algo, + attr_manifest_change_fn fn, void *data); #endif /* ATTR_MANIFEST_H */ diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index d03cc41c273da5..41f1d606889606 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -110,3 +110,80 @@ void test_attr_manifest__reader_accepts_empty_manifest(void) &hash_algos[GIT_HASH_SHA1])); strbuf_release(&manifest); } + +static int record_changed_path(const struct attr_manifest_entry *entry, + void *data) +{ + struct strbuf *paths = data; + + if (paths->len) + strbuf_addch(paths, ' '); + strbuf_add(paths, entry->path, entry->path_len); + return 0; +} + +void test_attr_manifest__iterates_added_removed_and_modified_entries(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer old_writer, new_writer; + struct strbuf old = STRBUF_INIT, new = STRBUF_INIT, changed = STRBUF_INIT; + + attr_manifest_writer_init(&old_writer, &old, algo); + add_entry(&old_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&old_writer, "a/.gitattributes", ATTR_MANIFEST_INDEX, 2); + add_entry(&old_writer, "c/.gitattributes", ATTR_MANIFEST_INDEX, 3); + attr_manifest_writer_init(&new_writer, &new, algo); + add_entry(&new_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + add_entry(&new_writer, "a/.gitattributes", ATTR_MANIFEST_WORKTREE, 2); + add_entry(&new_writer, "b/.gitattributes", ATTR_MANIFEST_INDEX, 4); + + cl_assert_equal_i(attr_manifest_for_each_changed( + old.buf, old.len, new.buf, new.len, algo, + record_changed_path, &changed), 0); + cl_assert_equal_s(changed.buf, + "a/.gitattributes b/.gitattributes c/.gitattributes"); + strbuf_release(&changed); + strbuf_release(&new); + strbuf_release(&old); +} + +void test_attr_manifest__does_not_report_identical_entries(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer old_writer, new_writer; + struct strbuf old = STRBUF_INIT, new = STRBUF_INIT, changed = STRBUF_INIT; + + attr_manifest_writer_init(&old_writer, &old, algo); + add_entry(&old_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + attr_manifest_writer_init(&new_writer, &new, algo); + add_entry(&new_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + cl_assert_equal_i(attr_manifest_for_each_changed( + old.buf, old.len, new.buf, new.len, algo, + record_changed_path, &changed), 0); + cl_assert_equal_i(changed.len, 0); + strbuf_release(&changed); + strbuf_release(&new); + strbuf_release(&old); +} + +void test_attr_manifest__rejects_malformed_tail_before_callbacks(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct attr_manifest_writer old_writer, new_writer; + struct strbuf old = STRBUF_INIT, new = STRBUF_INIT, changed = STRBUF_INIT; + + attr_manifest_writer_init(&old_writer, &old, algo); + add_entry(&old_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 1); + attr_manifest_writer_init(&new_writer, &new, algo); + add_entry(&new_writer, ".gitattributes", ATTR_MANIFEST_INDEX, 2); + strbuf_addch(&new, 0); + + cl_assert_equal_i(attr_manifest_for_each_changed( + old.buf, old.len, new.buf, new.len, algo, + record_changed_path, &changed), -1); + cl_assert_equal_i(changed.len, 0); + + strbuf_release(&changed); + strbuf_release(&new); + strbuf_release(&old); +} From dc59c731d76ce274ba44625a67c82ba04b2c747c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:06:00 -0700 Subject: [PATCH 153/403] dir: validate captured UNTR directory stats A captured untracked-cache directory must not be marked reusable from a stale snapshot: tracked-index refresh may invalidate its live node, replace the cache, or change the traversal's directory flags. Run lstat() against each saved pathname and compare its immutable stat snapshot using the strict, racy-aware directory comparison. Publish the checked result only if the current cache, root, and directory flags still match. Preserve any invalidation that happened after capture; failed or changed stats leave ordinary traversal responsible for rescan. Validation remains synchronous, and no status caller invokes the new finish operation at this boundary. Signed-off-by: Taylor Blau --- dir.c | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dir.h | 2 ++ 2 files changed, 78 insertions(+) diff --git a/dir.c b/dir.c index a66050a6f55e9b..5dbd5659b8a9bb 100644 --- a/dir.c +++ b/dir.c @@ -91,6 +91,10 @@ struct untracked_cache_preload { unsigned int dir_flags; }; +static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, + const struct stat_data *sd, + const struct stat *st); + static void collect_untracked_cache_preload_tasks( struct untracked_cache_dir *ucd, struct strbuf *path, @@ -145,6 +149,78 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( return preload; } +static void validate_untracked_cache_preload( + struct untracked_cache_preload *preload) +{ + size_t i; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct stat st; + + if (!task->was_valid) + continue; + task->stat_checked = 1; + if (lstat(task->path, &st)) { + memset(&task->stat_data, 0, sizeof(task->stat_data)); + task->update_stat_data = 1; + continue; + } + if (!match_untracked_dir_stat_racy( + &preload->index_timestamp, &task->stat_data, &st)) { + task->stat_matches = 1; + continue; + } + fill_stat_data(&task->stat_data, &st); + task->update_stat_data = 1; + } +} + +int untracked_cache_preload_finish(struct untracked_cache_preload *preload, + struct index_state *istate, + unsigned int dir_flags) +{ + struct untracked_cache *uc; + size_t i; + int applied = 0; + int valid = 1; + + if (!preload) + return 0; + validate_untracked_cache_preload(preload); + uc = istate->untracked; + if (uc != preload->uc || !uc || uc->root != preload->root || + dir_flags != preload->dir_flags) + goto done; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct untracked_cache_dir *ucd = task->ucd; + + ucd->stat_checked = 0; + ucd->stat_matches = 0; + /* Invalidation performed after the snapshot always wins. */ + if (!task->was_valid || !ucd->valid) { + valid = 0; + continue; + } + ucd->stat_checked = task->stat_checked; + ucd->stat_matches = task->stat_matches; + if (!task->stat_checked || !task->stat_matches) + valid = 0; + if (task->update_stat_data) + ucd->stat_data = task->stat_data; + } + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/valid", valid); + applied = 1; +done: + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/applied", applied); + untracked_cache_preload_release(preload); + return applied; +} + void untracked_cache_preload_release(struct untracked_cache_preload *preload) { size_t i; diff --git a/dir.h b/dir.h index c6273985981a60..631bdad2b2b845 100644 --- a/dir.h +++ b/dir.h @@ -614,6 +614,8 @@ void untracked_cache_add_to_index(struct index_state *, const char *); struct untracked_cache_preload; struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *, unsigned int dir_flags); +int untracked_cache_preload_finish(struct untracked_cache_preload *, + struct index_state *, unsigned int dir_flags); void untracked_cache_preload_release(struct untracked_cache_preload *); void free_untracked_cache(struct untracked_cache *); From c3b6d957c18457fd69072ad1e4984e892479cd56 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:33:17 -0700 Subject: [PATCH 154/403] status: read attribute sources through pinned paths Hashing .gitattributes through an absolute pathname does not establish that the file stayed inside the original worktree. Replacing an ancestor can redirect the read, while replacing or changing the source during the read can make a pathname recheck certify different bytes. Resolve each source beneath the root and parent descriptors introduced by S06/P01 and S06/P02. Read a regular, singly linked file in bounded chunks; compare descriptor and pathname identities before and after reading, reject truncation and appended data, and use the reopen check from S06/P03 for the final component. A missing, nonregular, or oversized worktree source remains eligible for indexed fallback. A hard link, unstable parent, read error, or unsupported anchored-open platform instead rejects the observation. Register the source library and Clar suite in both Make and Meson. Tests cover a file larger than one read buffer, missing and nonregular sources, hard links, and a replaced cached parent. The tested reader does not change ordinary status. Signed-off-by: Taylor Blau --- Makefile | 2 + meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-worktree-attr-source.c | 193 ++++++++++++++++++++++++++ worktree-attr-source.c | 93 +++++++++++++ worktree-attr-source.h | 12 ++ 6 files changed, 302 insertions(+) create mode 100644 t/unit-tests/u-worktree-attr-source.c create mode 100644 worktree-attr-source.c create mode 100644 worktree-attr-source.h diff --git a/Makefile b/Makefile index 67916eeab63f72..9faa8a6587171b 100644 --- a/Makefile +++ b/Makefile @@ -1383,6 +1383,7 @@ LIB_OBJS += versioncmp.o LIB_OBJS += walker.o LIB_OBJS += wildmatch.o LIB_OBJS += worktree.o +LIB_OBJS += worktree-attr-source.o LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o @@ -1575,6 +1576,7 @@ CLAR_TEST_SUITES += u-strvec CLAR_TEST_SUITES += u-trailer CLAR_TEST_SUITES += u-urlmatch-normalization CLAR_TEST_SUITES += u-utf8-width +CLAR_TEST_SUITES += u-worktree-attr-source CLAR_TEST_PROG = $(UNIT_TEST_BIN)/unit-tests$(X) CLAR_TEST_OBJS = $(patsubst %,$(UNIT_TEST_DIR)/%.o,$(CLAR_TEST_SUITES)) CLAR_TEST_OBJS += $(UNIT_TEST_DIR)/clar/clar.o diff --git a/meson.build b/meson.build index 56b903ab289d75..912e7522aa1ace 100644 --- a/meson.build +++ b/meson.build @@ -584,6 +584,7 @@ libgit_sources = [ 'walker.c', 'wildmatch.c', 'worktree.c', + 'worktree-attr-source.c', 'wrapper.c', 'write-or-die.c', 'ws.c', diff --git a/t/meson.build b/t/meson.build index d38d0f5be3cb08..73372cd547f84c 100644 --- a/t/meson.build +++ b/t/meson.build @@ -31,6 +31,7 @@ clar_test_suites = [ 'unit-tests/u-trailer.c', 'unit-tests/u-urlmatch-normalization.c', 'unit-tests/u-utf8-width.c', + 'unit-tests/u-worktree-attr-source.c', ] clar_sources = [ diff --git a/t/unit-tests/u-worktree-attr-source.c b/t/unit-tests/u-worktree-attr-source.c new file mode 100644 index 00000000000000..59bddc563639a1 --- /dev/null +++ b/t/unit-tests/u-worktree-attr-source.c @@ -0,0 +1,193 @@ +#include "unit-test.h" + +#include "dir.h" +#include "hash.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "strbuf.h" +#include "worktree-attr-source.h" +#include "wrapper.h" + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +struct worktree_attr_source_fixture { + char *worktree; + struct repository repo; + struct semantic_verify_root *root; + struct semantic_verify_path *path; +}; + +static void source_fixture_init(struct worktree_attr_source_fixture *fixture) +{ + const char *tmp = getenv("TMPDIR"); + + memset(fixture, 0, sizeof(*fixture)); + fixture->worktree = xstrfmt( + "%s/worktree-attr-source.XXXXXX", tmp ? tmp : "/tmp"); + cl_assert(mkdtemp(fixture->worktree) != NULL); + fixture->repo.worktree = fixture->worktree; + fixture->repo.hash_algo = &hash_algos[GIT_HASH_SHA1]; + cl_must_pass(semantic_verify_root_init( + &fixture->repo, &fixture->root)); + fixture->path = semantic_verify_path_new(fixture->root); + cl_assert(fixture->path != NULL); +} + +static void source_fixture_release( + struct worktree_attr_source_fixture *fixture, + unsigned int *namespace_unstable, + size_t *namespace_unstable_from) +{ + struct strbuf worktree = STRBUF_INIT; + + semantic_verify_path_free( + fixture->path, namespace_unstable, namespace_unstable_from); + semantic_verify_root_clear(fixture->root); + strbuf_addstr(&worktree, fixture->worktree); + cl_must_pass(remove_dir_recursively(&worktree, 0)); + strbuf_release(&worktree); + free(fixture->worktree); +} + +static void make_directory(struct worktree_attr_source_fixture *fixture, + const char *name) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addf(&path, "%s/%s", fixture->worktree, name); + cl_must_pass(mkdir(path.buf, 0777)); + strbuf_release(&path); +} +#endif + +void test_worktree_attr_source__hashes_large_regular_file(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct git_hash_ctx ctx; + struct strbuf contents = STRBUF_INIT; + struct strbuf source = STRBUF_INIT; + unsigned char actual[GIT_MAX_RAWSZ], expected[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + make_directory(&fixture, "a"); + strbuf_addchars(&contents, 'x', 64 * 1024 + 17); + strbuf_addf(&source, "%s/a/.gitattributes", fixture.worktree); + write_file_buf(source.buf, contents.buf, contents.len); + git_hash_init(&ctx, algo); + git_hash_update(&ctx, contents.buf, contents.len); + git_hash_final(expected, &ctx); + git_hash_discard(&ctx); + + cl_must_pass(worktree_attr_source_read( + fixture.path, "a/.gitattributes", 7, algo, actual, &found)); + cl_assert_equal_i(found, 1); + cl_assert(!memcmp(actual, expected, algo->rawsz)); + + source_fixture_release(&fixture, &namespace_unstable, NULL); + cl_assert_equal_i(namespace_unstable, 0); + strbuf_release(&source); + strbuf_release(&contents); +#endif +} + +void test_worktree_attr_source__reports_missing_and_non_regular_files(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + cl_must_pass(worktree_attr_source_read( + fixture.path, ".gitattributes", 0, algo, hash, &found)); + cl_assert_equal_i(found, 0); + cl_must_pass(worktree_attr_source_read( + fixture.path, "missing/.gitattributes", 1, + algo, hash, &found)); + cl_assert_equal_i(found, 0); + + make_directory(&fixture, "attributes-directory"); + cl_must_pass(worktree_attr_source_read( + fixture.path, "attributes-directory", 2, algo, hash, &found)); + cl_assert_equal_i(found, 0); + + source_fixture_release(&fixture, &namespace_unstable, NULL); + cl_assert_equal_i(namespace_unstable, 0); +#endif +} + +void test_worktree_attr_source__rejects_hardlinked_file(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct strbuf alias = STRBUF_INIT, source = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + make_directory(&fixture, "a"); + strbuf_addf(&source, "%s/a/.gitattributes", fixture.worktree); + strbuf_addf(&alias, "%s/attributes-alias", fixture.worktree); + write_file(source.buf, "*.dat text\n"); + cl_must_pass(link(source.buf, alias.buf)); + + cl_assert_equal_i(worktree_attr_source_read( + fixture.path, "a/.gitattributes", 3, algo, hash, &found), -1); + cl_assert_equal_i(found, 0); + + source_fixture_release(&fixture, &namespace_unstable, NULL); + cl_assert_equal_i(namespace_unstable, 0); + strbuf_release(&source); + strbuf_release(&alias); +#endif +} + +void test_worktree_attr_source__detects_replaced_cached_parent(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct worktree_attr_source_fixture fixture; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct strbuf old_parent = STRBUF_INIT, parent = STRBUF_INIT; + struct strbuf source = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + size_t namespace_unstable_from; + unsigned int namespace_unstable; + int found; + + source_fixture_init(&fixture); + make_directory(&fixture, "a"); + strbuf_addf(&parent, "%s/a", fixture.worktree); + strbuf_addf(&old_parent, "%s/a-old", fixture.worktree); + strbuf_addf(&source, "%s/.gitattributes", parent.buf); + write_file(source.buf, "*.dat text\n"); + cl_must_pass(worktree_attr_source_read( + fixture.path, "a/.gitattributes", 17, algo, hash, &found)); + cl_assert_equal_i(found, 1); + + cl_must_pass(rename(parent.buf, old_parent.buf)); + cl_must_pass(mkdir(parent.buf, 0777)); + source_fixture_release( + &fixture, &namespace_unstable, &namespace_unstable_from); + cl_assert_equal_i(namespace_unstable, 1); + cl_assert_equal_i(namespace_unstable_from, 17); + + strbuf_release(&source); + strbuf_release(&old_parent); + strbuf_release(&parent); +#endif +} diff --git a/worktree-attr-source.c b/worktree-attr-source.c new file mode 100644 index 00000000000000..d56043eba7a576 --- /dev/null +++ b/worktree-attr-source.c @@ -0,0 +1,93 @@ +#include "git-compat-util.h" +#include "attr.h" +#include "hash.h" +#include "path-namespace.h" +#include "semantic-verify-internal.h" +#include "worktree-attr-source.h" + +#define WORKTREE_ATTR_HASH_BUFFER_SIZE (64 * 1024) + +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + +int worktree_attr_source_read( + struct semantic_verify_path *path UNUSED, + const char *name UNUSED, size_t position UNUSED, + const struct git_hash_algo *algo UNUSED, + unsigned char *hash UNUSED, int *found) +{ + *found = 0; + return -1; +} + +#else + +int worktree_attr_source_read(struct semantic_verify_path *path, + const char *name, size_t position, + const struct git_hash_algo *algo, + unsigned char *hash, int *found) +{ + struct git_hash_ctx ctx = { 0 }; + struct stat before, after, named; + unsigned char buffer[WORKTREE_ATTR_HASH_BUFFER_SIZE]; + const char *basename; + ssize_t got; + size_t remaining, size; + int parent_fd, fd = -1, ret = -1; + char extra; + + *found = 0; + if (semantic_verify_resolve_parent(path, name, position, + &parent_fd, &basename)) { + if (errno == ENOENT || errno == ENOTDIR || errno == ELOOP || + errno == EXDEV) + return 0; + return -1; + } + if (fstatat(parent_fd, basename, &before, AT_SYMLINK_NOFOLLOW)) + return errno == ENOENT || errno == ENOTDIR ? 0 : -1; + if (!S_ISREG(before.st_mode) || + before.st_size < 0 || before.st_size >= ATTR_MAX_FILE_SIZE) + return 0; + if (before.st_nlink != 1) + return -1; + + fd = semantic_verify_openat(parent_fd, basename, + O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0) + return -1; + if (fstat(fd, &after) || + !path_namespace_stat_equal(&before, &after)) + goto done; + size = xsize_t(before.st_size); + remaining = size; + git_hash_init(&ctx, algo); + while (remaining) { + size_t want = remaining < sizeof(buffer) ? + remaining : sizeof(buffer); + + got = xread(fd, buffer, want); + if (got <= 0) + goto done; + git_hash_update(&ctx, buffer, got); + remaining -= got; + } + got = xread(fd, &extra, 1); + if (got != 0 || + fstat(fd, &after) || + fstatat(parent_fd, basename, &named, AT_SYMLINK_NOFOLLOW) || + !path_namespace_stat_equal(&before, &after) || + !path_namespace_stat_equal(&after, &named) || + path_namespace_reopen_component( + parent_fd, basename, O_RDONLY | O_NONBLOCK | O_NOFOLLOW, + semantic_verify_openat, &after)) + goto done; + git_hash_final(hash, &ctx); + *found = 1; + ret = 0; +done: + git_hash_discard(&ctx); + close(fd); + return ret; +} + +#endif /* SEMANTIC_VERIFY_HAS_ANCHORED_OPEN */ diff --git a/worktree-attr-source.h b/worktree-attr-source.h new file mode 100644 index 00000000000000..2db26c68a71119 --- /dev/null +++ b/worktree-attr-source.h @@ -0,0 +1,12 @@ +#ifndef WORKTREE_ATTR_SOURCE_H +#define WORKTREE_ATTR_SOURCE_H + +struct git_hash_algo; +struct semantic_verify_path; + +int worktree_attr_source_read(struct semantic_verify_path *path, + const char *name, size_t position, + const struct git_hash_algo *algo, + unsigned char *hash, int *found); + +#endif /* WORKTREE_ATTR_SOURCE_H */ From 31f0dcb5563f2d4912b92945a303ce7842fd5f3d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:07:06 -0700 Subject: [PATCH 155/403] dir: validate captured UNTR directory stats in parallel Synchronous validation still places every cached-directory lstat() on one execution path. Independent, already-captured directory snapshots can instead be divided among bounded workers without reading the live cache from those workers. Partition the snapshots using approximately 1,000 directories per worker, cap the worker count at six and at three times the available CPU count, and permit a bounded test override. Workers retain results in their own snapshot ranges; the existing finish operation joins them before publishing anything. Record worker count, thread-creation failures, directory count, and elapsed worker time through the threads, thread_failure, dirs, and wall_us Trace2 keys. Publication validity and applied-result counters remain in the earlier finishing boundary. Run the work synchronously for a single worker or without pthreads. If thread creation stops partway through, join started workers and process every unstarted range synchronously. No status caller enables the preload at this boundary. Signed-off-by: Taylor Blau --- dir.c | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 12 deletions(-) diff --git a/dir.c b/dir.c index 5dbd5659b8a9bb..73494b54768602 100644 --- a/dir.c +++ b/dir.c @@ -33,6 +33,8 @@ #include "strbuf.h" #include "submodule-config.h" #include "symlinks.h" +#include "thread-utils.h" +#include "trace.h" #include "trace2.h" #include "tree.h" #include "hex.h" @@ -81,19 +83,35 @@ struct untracked_cache_preload_task { unsigned int update_stat_data : 1; }; +struct untracked_cache_preload; + +struct untracked_cache_preload_data { + pthread_t pthread; + struct untracked_cache_preload *preload; + size_t offset, nr; + int started; +}; + struct untracked_cache_preload { struct repository *repo; struct untracked_cache *uc; struct untracked_cache_dir *root; struct untracked_cache_preload_task *tasks; + struct untracked_cache_preload_data *data; struct cache_time index_timestamp; size_t nr; + int threads; unsigned int dir_flags; + uint64_t started_at; }; +#define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 +#define UNTRACKED_CACHE_PRELOAD_COST 1000 + static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat_data *sd, const struct stat *st); +static void *preload_untracked_cache_thread(void *data); static void collect_untracked_cache_preload_tasks( struct untracked_cache_dir *ucd, @@ -131,7 +149,9 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; struct strbuf path = STRBUF_INIT; - size_t alloc = 0; + size_t alloc = 0, offset = 0, work, i; + unsigned long test_threads; + int threads, online, create_threads = 1; if (!uc || !uc->root || uc->use_fsmonitor || uc->dir_flags != dir_flags) @@ -146,15 +166,61 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( collect_untracked_cache_preload_tasks( uc->root, &path, &preload->tasks, &preload->nr, &alloc); strbuf_release(&path); + + threads = HAVE_THREADS ? preload->nr / UNTRACKED_CACHE_PRELOAD_COST : 1; + online = HAVE_THREADS ? online_cpus() : 1; + if (threads > online * 3) + threads = online * 3; + if (threads > UNTRACKED_CACHE_MAX_OVERLAP_THREADS) + threads = UNTRACKED_CACHE_MAX_OVERLAP_THREADS; + test_threads = git_env_ulong("GIT_TEST_UNTRACKED_CACHE_THREADS", 0); + if (test_threads && HAVE_THREADS) + threads = test_threads > UNTRACKED_CACHE_MAX_OVERLAP_THREADS ? + UNTRACKED_CACHE_MAX_OVERLAP_THREADS : test_threads; + if (threads < 1) + threads = 1; + if ((size_t)threads > preload->nr) + threads = preload->nr; + preload->threads = threads; + + preload->started_at = getnanotime(); + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/threads", threads); + CALLOC_ARRAY(preload->data, threads); + work = DIV_ROUND_UP(preload->nr, threads); + for (i = 0; i < threads; i++) { + struct untracked_cache_preload_data *data = &preload->data[i]; + int err; + + data->preload = preload; + data->offset = offset; + data->nr = offset < preload->nr ? + (preload->nr - offset < work ? + preload->nr - offset : work) : 0; + offset += data->nr; + if (threads == 1 || !create_threads) + continue; + err = pthread_create(&data->pthread, NULL, + preload_untracked_cache_thread, data); + if (err) { + create_threads = 0; + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/thread_failure", err); + continue; + } + data->started = 1; + } return preload; } -static void validate_untracked_cache_preload( - struct untracked_cache_preload *preload) +static void *preload_untracked_cache_thread(void *_data) { + struct untracked_cache_preload_data *data = _data; + struct untracked_cache_preload *preload = data->preload; size_t i; - for (i = 0; i < preload->nr; i++) { + for (i = data->offset; i < data->offset + data->nr; i++) { struct untracked_cache_preload_task *task = &preload->tasks[i]; struct stat st; @@ -174,6 +240,43 @@ static void validate_untracked_cache_preload( fill_stat_data(&task->stat_data, &st); task->update_stat_data = 1; } + return NULL; +} + +static void untracked_cache_preload_join( + struct untracked_cache_preload *preload) +{ + int i; + + if (preload->threads == 1) { + preload_untracked_cache_thread(&preload->data[0]); + return; + } + for (i = 0; i < preload->threads; i++) { + if (!preload->data[i].started) { + preload_untracked_cache_thread(&preload->data[i]); + continue; + } + if (pthread_join(preload->data[i].pthread, NULL)) + die(_("unable to join untracked-cache preload thread")); + } +} + +static void untracked_cache_preload_free( + struct untracked_cache_preload *preload) +{ + size_t i; + + trace2_data_intmax("dir", preload->repo, + "preload_untracked_cache/dirs", preload->nr); + trace2_data_intmax("dir", preload->repo, + "preload_untracked_cache/wall_us", + (getnanotime() - preload->started_at) / 1000); + free(preload->data); + for (i = 0; i < preload->nr; i++) + free(preload->tasks[i].path); + free(preload->tasks); + free(preload); } int untracked_cache_preload_finish(struct untracked_cache_preload *preload, @@ -187,7 +290,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, if (!preload) return 0; - validate_untracked_cache_preload(preload); + untracked_cache_preload_join(preload); uc = istate->untracked; if (uc != preload->uc || !uc || uc->root != preload->root || dir_flags != preload->dir_flags) @@ -217,20 +320,16 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, done: trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/applied", applied); - untracked_cache_preload_release(preload); + untracked_cache_preload_free(preload); return applied; } void untracked_cache_preload_release(struct untracked_cache_preload *preload) { - size_t i; - if (!preload) return; - for (i = 0; i < preload->nr; i++) - free(preload->tasks[i].path); - free(preload->tasks); - free(preload); + untracked_cache_preload_join(preload); + untracked_cache_preload_free(preload); } /* From 2de88abaa7d504d815860dbaf03adc92c2f5679f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:34:40 -0700 Subject: [PATCH 156/403] status: build worktree attribute manifests Checking only tracked .gitattributes entries misses worktree attribute files in ancestor directories. Conversely, silently substituting an indexed source for a hard-linked or unstable worktree file would certify conversion rules that Git did not safely observe. Collect the root and every directory scope containing a tracked entry. Read each candidate with S07/P04, prefer a stable worktree source, and use an available indexed object only when the worktree source is absent, nonregular, or oversized. Serialize existing sources in strict path order and hash the complete validated manifest with the repository's object algorithm. Reject unmerged or sparse indexes, missing indexed objects, source-read errors, hard links, replaced ancestors, and an unstable worktree root. Reset the output on failure so callers cannot retain a partial proof. Register the new library in both build systems. Unit tests cover tracked scopes, indexed fallback, a hard-linked worktree source despite an available indexed object, missing objects, and structural indexes. The builder is independently testable but does not run from status. Signed-off-by: Taylor Blau --- Makefile | 1 + hash-framing.h | 11 ++ meson.build | 1 + t/unit-tests/u-attr-manifest.c | 284 +++++++++++++++++++++++++++++++++ worktree-attr-manifest.c | 210 ++++++++++++++++++++++++ worktree-attr-manifest.h | 19 +++ 6 files changed, 526 insertions(+) create mode 100644 worktree-attr-manifest.c create mode 100644 worktree-attr-manifest.h diff --git a/Makefile b/Makefile index 9faa8a6587171b..2eb2b731f6a1d3 100644 --- a/Makefile +++ b/Makefile @@ -1383,6 +1383,7 @@ LIB_OBJS += versioncmp.o LIB_OBJS += walker.o LIB_OBJS += wildmatch.o LIB_OBJS += worktree.o +LIB_OBJS += worktree-attr-manifest.o LIB_OBJS += worktree-attr-source.o LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o diff --git a/hash-framing.h b/hash-framing.h index b15294b684a90d..f20b455e590f87 100644 --- a/hash-framing.h +++ b/hash-framing.h @@ -27,4 +27,15 @@ static inline void hash_optional_cstring(struct git_hash_ctx *ctx, hash_length_delimited(ctx, &missing, sizeof(missing)); } +static inline void hash_buffer_digest(const struct git_hash_algo *algo, + const void *data, size_t len, + unsigned char *hash) +{ + struct git_hash_ctx ctx; + + git_hash_init(&ctx, algo); + git_hash_update(&ctx, data, len); + git_hash_final(hash, &ctx); +} + #endif /* HASH_FRAMING_H */ diff --git a/meson.build b/meson.build index 912e7522aa1ace..98a692d05c1bb3 100644 --- a/meson.build +++ b/meson.build @@ -584,6 +584,7 @@ libgit_sources = [ 'walker.c', 'wildmatch.c', 'worktree.c', + 'worktree-attr-manifest.c', 'worktree-attr-source.c', 'wrapper.c', 'write-or-die.c', diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 41f1d606889606..9f00a7d6f22b04 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -1,6 +1,15 @@ #include "unit-test.h" #include "attr-manifest.h" +#include "dir.h" +#include "hash.h" +#include "odb.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "setup.h" #include "strbuf.h" +#include "worktree-attr-manifest.h" +#include "wrapper.h" static void fill_hash(unsigned char *hash, unsigned char value, const struct git_hash_algo *algo) @@ -187,3 +196,278 @@ void test_attr_manifest__rejects_malformed_tail_before_callbacks(void) strbuf_release(&new); strbuf_release(&old); } + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static char *create_worktree(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/attr-manifest.XXXXXX", tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void remove_worktree(char *worktree) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addstr(&path, worktree); + cl_assert_equal_i(remove_dir_recursively(&path, 0), 0); + strbuf_release(&path); + free(worktree); +} + +static struct cache_entry *add_index_path(struct index_state *istate, + size_t pos, const char *path, + unsigned int stage) +{ + size_t len = strlen(path); + struct cache_entry *ce = make_empty_cache_entry(istate, len); + + ce->ce_mode = S_IFREG | 0644; + ce->ce_flags = create_ce_flags(stage); + ce->ce_namelen = len; + memcpy(ce->name, path, len + 1); + istate->cache[pos] = ce; + return ce; +} + +static void init_object_store(struct repository *repo, const char *worktree) +{ + struct strbuf object_dir = STRBUF_INIT; + + strbuf_addf(&object_dir, "%s/objects", worktree); + repo->objects = odb_new(repo, object_dir.buf, ""); + strbuf_release(&object_dir); +} +#endif + +void test_attr_manifest__builds_sources_for_tracked_scopes(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct git_hash_ctx ctx; + struct strbuf path = STRBUF_INIT, manifest = STRBUF_INIT; + char root_source[] = "*.root text\n"; + unsigned char expected[GIT_MAX_RAWSZ], hash[GIT_MAX_RAWSZ]; + + strbuf_addf(&path, "%s/a", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/b", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/.gitattributes", worktree); + write_file_buf(path.buf, root_source, strlen(root_source)); + git_hash_init(&ctx, algo); + git_hash_update(&ctx, root_source, strlen(root_source)); + git_hash_final(expected, &ctx); + strbuf_reset(&path); + strbuf_addf(&path, "%s/a/.gitattributes", worktree); + write_file(path.buf, "*.dat -text\n"); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_index_path(&istate, 0, "a/file", 0); + add_index_path(&istate, 1, "b/file", 0); + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), 0); + cl_assert_equal_i(stats.candidates, 3); + cl_assert_equal_i(stats.worktree_sources, 2); + cl_assert_equal_i(stats.index_sources, 0); + cl_assert(attr_manifest_valid(manifest.buf, manifest.len, algo)); + + cl_assert_equal_i(attr_manifest_cursor_init( + &cursor, manifest.buf, manifest.len, algo), 0); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_WORKTREE); + cl_assert_equal_i(entry.path_len, strlen(GITATTRIBUTES_FILE)); + cl_assert(!memcmp(entry.path, GITATTRIBUTES_FILE, entry.path_len)); + cl_assert(!memcmp(entry.hash, expected, algo->rawsz)); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_WORKTREE); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + + strbuf_release(&manifest); + strbuf_release(&path); + release_index(&istate); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__falls_back_to_index_source(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + char source[] = "*.dat text\n"; + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct cache_entry *attributes; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + int have_repository, ret; + + init_object_store(&repo, worktree); + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + attributes = add_index_path(&istate, 0, GITATTRIBUTES_FILE, 0); + cl_must_pass(odb_pretend_object( + repo.objects, source, strlen(source), OBJ_BLOB, + &attributes->oid)); + + have_repository = startup_info->have_repository; + startup_info->have_repository = 1; + ret = worktree_attr_manifest_build( + &istate, &manifest, hash, &stats); + startup_info->have_repository = have_repository; + cl_assert_equal_i(ret, 0); + cl_assert_equal_i(stats.candidates, 1); + cl_assert_equal_i(stats.worktree_sources, 0); + cl_assert_equal_i(stats.index_sources, 1); + cl_assert_equal_i(attr_manifest_cursor_init( + &cursor, manifest.buf, manifest.len, algo), 0); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_INDEX); + cl_assert_equal_i(entry.path_len, strlen(GITATTRIBUTES_FILE)); + cl_assert(!memcmp(entry.path, GITATTRIBUTES_FILE, entry.path_len)); + cl_assert(!memcmp(entry.hash, attributes->oid.hash, algo->rawsz)); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + + strbuf_release(&manifest); + release_index(&istate); + odb_free(repo.objects); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__rejects_hardlinked_source_over_index(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + char indexed_source[] = "*.dat text\n"; + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct cache_entry *attributes; + struct strbuf source = STRBUF_INIT, alias = STRBUF_INIT; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + int have_repository, ret; + + init_object_store(&repo, worktree); + strbuf_addf(&source, "%s/a", worktree); + cl_assert_equal_i(mkdir(source.buf, 0777), 0); + strbuf_addstr(&source, "/" GITATTRIBUTES_FILE); + write_file(source.buf, "*.dat -text\n"); + strbuf_addf(&alias, "%s/attributes-alias", worktree); + cl_assert_equal_i(link(source.buf, alias.buf), 0); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + attributes = add_index_path( + &istate, 0, "a/" GITATTRIBUTES_FILE, 0); + add_index_path(&istate, 1, "a/file", 0); + cl_must_pass(odb_pretend_object( + repo.objects, indexed_source, strlen(indexed_source), OBJ_BLOB, + &attributes->oid)); + + have_repository = startup_info->have_repository; + startup_info->have_repository = 1; + ret = worktree_attr_manifest_build( + &istate, &manifest, hash, &stats); + startup_info->have_repository = have_repository; + cl_assert_equal_i(ret, -1); + cl_assert_equal_i(manifest.len, 0); + cl_assert_equal_i(stats.worktree_sources, 0); + cl_assert_equal_i(stats.index_sources, 0); + + strbuf_release(&manifest); + strbuf_release(&alias); + strbuf_release(&source); + release_index(&istate); + odb_free(repo.objects); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__rejects_missing_index_source(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct cache_entry *attributes; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned char missing[GIT_MAX_RAWSZ]; + + init_object_store(&repo, worktree); + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + attributes = add_index_path(&istate, 0, GITATTRIBUTES_FILE, 0); + fill_hash(missing, 0x42, algo); + oidread(&attributes->oid, missing, algo); + strbuf_addstr(&manifest, "discard me"); + + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), -1); + cl_assert_equal_i(manifest.len, 0); + + strbuf_release(&manifest); + release_index(&istate); + odb_free(repo.objects); + remove_worktree(worktree); +#endif +} + +void test_attr_manifest__builder_rejects_structural_indexes(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct strbuf manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + add_index_path(&istate, 0, "file", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), -1); + cl_assert_equal_i(manifest.len, 0); + istate.cache[0]->ce_flags = create_ce_flags(0); + istate.sparse_index = INDEX_COLLAPSED; + cl_assert_equal_i(worktree_attr_manifest_build( + &istate, &manifest, hash, &stats), -1); + cl_assert_equal_i(manifest.len, 0); + + strbuf_release(&manifest); + release_index(&istate); + remove_worktree(worktree); +#endif +} diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c new file mode 100644 index 00000000000000..f30757677068cb --- /dev/null +++ b/worktree-attr-manifest.c @@ -0,0 +1,210 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "dir.h" +#include "environment.h" +#include "hash-framing.h" +#include "object.h" +#include "odb.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "string-list.h" +#include "strbuf.h" +#include "worktree-attr-manifest.h" +#include "worktree-attr-source.h" + +struct attr_manifest_candidate { + unsigned char worktree_hash[GIT_MAX_RAWSZ]; + unsigned char index_hash[GIT_MAX_RAWSZ]; + unsigned int index_present : 1; + unsigned int worktree_present : 1; + unsigned int error : 1; +}; + +struct attr_manifest_probe_data { + struct string_list *candidates; + struct semantic_verify_root *root; + const struct git_hash_algo *algo; + size_t start; + size_t end; + unsigned int namespace_unstable; +}; + +static int collect_candidates(struct index_state *istate, + struct string_list *candidates) +{ + struct strbuf candidate = STRBUF_INIT; + const char *previous = NULL; + size_t previous_len = 0; + unsigned int i; + int ret = -1; + + string_list_append(candidates, GITATTRIBUTES_FILE); + for (i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + const char *slash = ce->name; + + if (ce_stage(ce) || S_ISSPARSEDIR(ce->ce_mode)) + goto done; + while ((slash = strchr(slash, '/')) != NULL) { + size_t len = slash - ce->name; + + if (!previous || previous_len <= len || + !is_dir_sep(previous[len]) || + fspathncmp(previous, ce->name, len)) { + strbuf_reset(&candidate); + strbuf_add(&candidate, ce->name, len + 1); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + string_list_append(candidates, candidate.buf); + } + slash++; + } + previous = ce->name; + previous_len = ce->ce_namelen; + } + string_list_sort(candidates); + string_list_remove_duplicates(candidates, 0); + ret = candidates->nr <= UINT32_MAX ? 0 : -1; +done: + strbuf_release(&candidate); + return ret; +} + +static int collect_index_sources(struct index_state *istate, + struct string_list *candidates) +{ + struct strbuf candidate = STRBUF_INIT; + unsigned int i; + int ret = 0; + + for (i = 0; i < candidates->nr; i++) { + struct attr_manifest_candidate *state; + + CALLOC_ARRAY(state, 1); + candidates->items[i].util = state; + } + for (i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + const char *base = strrchr(ce->name, '/'); + struct string_list_item *item; + struct attr_manifest_candidate *state; + + base = base ? base + 1 : ce->name; + if (fspathcmp(base, GITATTRIBUTES_FILE)) + continue; + strbuf_reset(&candidate); + if (base != ce->name) + strbuf_add(&candidate, ce->name, base - ce->name); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + item = string_list_lookup(candidates, candidate.buf); + if (!item) + BUG("tracked attribute source lacks manifest candidate"); + state = item->util; + if ((S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) && + odb_has_object(istate->repo->objects, &ce->oid, 0)) { + state->index_present = 1; + memcpy(state->index_hash, ce->oid.hash, + istate->repo->hash_algo->rawsz); + } else if (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) { + ret = -1; + break; + } + } + strbuf_release(&candidate); + return ret; +} + +static void probe_attr_manifest_candidates( + struct attr_manifest_probe_data *data) +{ + struct semantic_verify_path *path = + semantic_verify_path_new(data->root); + size_t i; + + for (i = data->start; i < data->end; i++) { + struct string_list_item *item = &data->candidates->items[i]; + struct attr_manifest_candidate *candidate = item->util; + int found; + + if (worktree_attr_source_read(path, item->string, i, data->algo, + candidate->worktree_hash, &found)) + candidate->error = 1; + else + candidate->worktree_present = found; + } + semantic_verify_path_free(path, &data->namespace_unstable, NULL); +} + +static int probe_candidates(struct string_list *candidates, + struct semantic_verify_root *root, + const struct git_hash_algo *algo) +{ + struct attr_manifest_probe_data data = { + .candidates = candidates, + .root = root, + .algo = algo, + .end = candidates->nr, + }; + + probe_attr_manifest_candidates(&data); + return data.namespace_unstable ? -1 : 0; +} + +int worktree_attr_manifest_build( + struct index_state *istate, + struct strbuf *manifest, + unsigned char *manifest_hash, + struct worktree_attr_manifest_stats *stats) +{ + struct string_list candidates = STRING_LIST_INIT_DUP; + struct semantic_verify_root *root = NULL; + struct attr_manifest_writer writer; + const struct git_hash_algo *algo = istate->repo->hash_algo; + unsigned int i; + int ret = -1; + + memset(stats, 0, sizeof(*stats)); + if (istate->sparse_index != INDEX_EXPANDED || + semantic_verify_root_init(istate->repo, &root) || + collect_candidates(istate, &candidates) || + collect_index_sources(istate, &candidates)) + goto done; + stats->candidates = candidates.nr; + if (probe_candidates(&candidates, root, algo)) + goto done; + attr_manifest_writer_init(&writer, manifest, algo); + for (i = 0; i < candidates.nr; i++) { + const char *name = candidates.items[i].string; + struct attr_manifest_candidate *state = candidates.items[i].util; + enum attr_manifest_source source; + const unsigned char *hash; + + if (state->error) + goto done; + if (state->worktree_present) { + source = ATTR_MANIFEST_WORKTREE; + hash = state->worktree_hash; + stats->worktree_sources++; + } else if (state->index_present) { + source = ATTR_MANIFEST_INDEX; + hash = state->index_hash; + stats->index_sources++; + } else { + continue; + } + if (attr_manifest_writer_add(&writer, name, source, hash)) + goto done; + } + if (!semantic_verify_root_stable(root)) + goto done; + if (!attr_manifest_valid(manifest->buf, manifest->len, algo)) + BUG("newly built attribute manifest is invalid"); + hash_buffer_digest(algo, manifest->buf, manifest->len, manifest_hash); + ret = 0; +done: + semantic_verify_root_clear(root); + string_list_clear(&candidates, 1); + if (ret) + strbuf_reset(manifest); + return ret; +} diff --git a/worktree-attr-manifest.h b/worktree-attr-manifest.h new file mode 100644 index 00000000000000..4c3e8dc4ba762c --- /dev/null +++ b/worktree-attr-manifest.h @@ -0,0 +1,19 @@ +#ifndef WORKTREE_ATTR_MANIFEST_H +#define WORKTREE_ATTR_MANIFEST_H + +struct index_state; +struct strbuf; + +struct worktree_attr_manifest_stats { + size_t candidates; + size_t worktree_sources; + size_t index_sources; +}; + +int worktree_attr_manifest_build( + struct index_state *istate, + struct strbuf *manifest, + unsigned char *manifest_hash, + struct worktree_attr_manifest_stats *stats); + +#endif /* WORKTREE_ATTR_MANIFEST_H */ From 99b44a3eb111eb7f9fc9241a8ab922e813c418e0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:56:51 -0500 Subject: [PATCH 157/403] status: overlap UNTR validation with tracked-index refresh Directory validation and tracked-index refresh inspect different snapshots, but running them consecutively leaves both operations on the status command's critical path. Start the cached-directory preload after reading the index and before refresh_index(). Join its workers after configuring excludes and before collecting untracked paths, then pass their results into directory traversal. Release any unfinished preload when status buffers are freed. Keep activation behind GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD until production eligibility is defined. The untracked-cache status test checks unchanged and modified directories, preserved output, and the worker count selected by the running build's pthread support. Signed-off-by: Taylor Blau --- builtin/commit.c | 1 + dir.c | 2 ++ t/t7063-status-untracked-cache.sh | 49 +++++++++++++++++++++++++++++++ wt-status.c | 32 ++++++++++++++++++-- wt-status.h | 4 +++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 28f61745034506..54e41c8ba578c1 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1627,6 +1627,7 @@ struct repository *repo UNUSED) status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); + wt_status_start_untracked_cache_preload(&s); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED|progress_flag, &s.pathspec, NULL, NULL); diff --git a/dir.c b/dir.c index 73494b54768602..c97f59cbf3b1de 100644 --- a/dir.c +++ b/dir.c @@ -153,6 +153,8 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( unsigned long test_threads; int threads, online, create_threads = 1; + if (!git_env_bool("GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD", 0)) + return NULL; if (!uc || !uc->root || uc->use_fsmonitor || uc->dir_flags != dir_flags) return NULL; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 4ab20cc0693dc4..8f49cfeebb6896 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1017,4 +1017,53 @@ test_expect_success 'directory snapshots ignore weak file-stat configuration' ' ) ' +test_expect_success 'status preloads cached-directory validation' ' + test_create_repo auto-preload && + ( + cd auto-preload && + mkdir -p nested/deep && + echo tracked >nested/tracked && + git add nested/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + echo visible >nested/deep/visible && + git -c core.untrackedCache=false status --porcelain \ + >.git/expect && + avoid_racy && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=3 \ + GIT_TRACE2_EVENT="$PWD/.git/normal.trace" \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual && + if test_have_prereq PTHREADS + then + expect_threads=3 + else + expect_threads=1 + fi && + test_grep \ + "preload_untracked_cache/threads.*value.*$expect_threads" \ + .git/normal.trace && + test_grep "preload_untracked_cache/valid.*value.*1" \ + .git/normal.trace && + test_grep "opendir.*value.*0" .git/normal.trace && + echo changed >nested/deep/changed && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain >.git/expect-changed && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=3 \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain >.git/actual-changed && + test_cmp .git/expect-changed .git/actual-changed && + test_grep "preload_untracked_cache/valid.*value.*0" \ + .git/changed.trace && + test_grep "opendir.*value.*[1-9][0-9]*" \ + .git/changed.trace + ) +' + test_done diff --git a/wt-status.c b/wt-status.c index 57772c7501fdba..ffb9ff8f044fd9 100644 --- a/wt-status.c +++ b/wt-status.c @@ -803,6 +803,26 @@ static void wt_status_collect_changes_initial(struct wt_status *s) strbuf_release(&base); } +static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) +{ + if (s->show_untracked_files == SHOW_ALL_UNTRACKED_FILES) + return 0; + return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; +} + +void wt_status_start_untracked_cache_preload(struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + unsigned int dir_flags; + + if (s->untracked_cache_preload) + BUG("untracked-cache preload already started"); + + dir_flags = wt_status_untracked_dir_flags(s); + s->untracked_cache_preload = + untracked_cache_preload_start_ordinary(istate, dir_flags); +} + static void wt_status_collect_untracked(struct wt_status *s) { int i; @@ -814,8 +834,7 @@ static void wt_status_collect_untracked(struct wt_status *s) return; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) - dir.flags |= - DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; + dir.flags |= wt_status_untracked_dir_flags(s); if (s->show_ignored_mode) { dir.flags |= DIR_SHOW_IGNORED_TOO; @@ -826,6 +845,13 @@ static void wt_status_collect_untracked(struct wt_status *s) } setup_standard_excludes(&dir); + if (s->untracked_cache_preload) { + s->untracked_cache_preloaded = untracked_cache_preload_finish( + s->untracked_cache_preload, istate, dir.flags); + s->untracked_cache_preload = NULL; + } + dir.internal.untracked_cache_preloaded = + s->untracked_cache_preloaded; fill_directory(&dir, istate, &s->pathspec); @@ -889,6 +915,8 @@ void wt_status_collect(struct wt_status *s) void wt_status_collect_free_buffers(struct wt_status *s) { + untracked_cache_preload_release(s->untracked_cache_preload); + s->untracked_cache_preload = NULL; wt_status_state_free_buffers(&s->state); } diff --git a/wt-status.h b/wt-status.h index e9fe32e98cc18c..e64eda2d9cc666 100644 --- a/wt-status.h +++ b/wt-status.h @@ -8,6 +8,7 @@ struct repository; struct worktree; +struct untracked_cache_preload; enum color_wt_status { WT_STATUS_HEADER = 0, @@ -145,6 +146,8 @@ struct wt_status { struct string_list untracked; struct string_list ignored; uint32_t untracked_in_ms; + struct untracked_cache_preload *untracked_cache_preload; + unsigned untracked_cache_preloaded : 1; }; size_t wt_status_locate_end(const char *s, size_t len); @@ -153,6 +156,7 @@ void wt_status_add_cut_line(struct wt_status *s); void wt_status_prepare(struct repository *r, struct wt_status *s); void wt_status_print(struct wt_status *s); void wt_status_collect(struct wt_status *s); +void wt_status_start_untracked_cache_preload(struct wt_status *s); /* * Collect all changes between the two trees. Changes will be displayed as if From 16c249db396ee32f302dd01b3fb73d43907e02c1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:40:39 -0700 Subject: [PATCH 158/403] status: probe attribute sources in parallel The manifest builder from S07/P05 probes every tracked directory scope, including scopes without a worktree .gitattributes file. Those independent filesystem observations can run concurrently; parallel probing does not eliminate or reduce the source probes. Divide the sorted candidate list into contiguous ranges and give each worker its own anchored path state. Limit the worker count, preserve one-worker execution on builds without threads, and keep serialization on the calling thread so scheduling cannot change manifest order. If creating a worker fails, finish that range and all unstarted ranges on the calling thread. Join started workers and reject observed namespace instability before emitting the manifest. Unit tests compare serial and parallel manifest bytes and hashes and inject a worker-creation failure to verify complete, identical fallback output. Worker and descriptor state is bounded; no timing or memory result is claimed. Signed-off-by: Taylor Blau --- t/unit-tests/u-attr-manifest.c | 141 +++++++++++++++++++++++++++++++++ worktree-attr-manifest.c | 99 ++++++++++++++++++++--- worktree-attr-manifest.h | 2 + 3 files changed, 229 insertions(+), 13 deletions(-) diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 9f00a7d6f22b04..a41eb2a7975fac 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -8,9 +8,15 @@ #include "semantic-verify-internal.h" #include "setup.h" #include "strbuf.h" +#include "thread-utils.h" #include "worktree-attr-manifest.h" #include "wrapper.h" +#define ATTR_MANIFEST_TEST_THREADS "GIT_TEST_ATTR_MANIFEST_THREADS" +#define ATTR_MANIFEST_TEST_THREAD_FAIL_AT \ + "GIT_TEST_ATTR_MANIFEST_THREAD_FAIL_AT" +#define ATTR_MANIFEST_TEST_SOURCE_NR 257 + static void fill_hash(unsigned char *hash, unsigned char value, const struct git_hash_algo *algo) { @@ -441,6 +447,141 @@ void test_attr_manifest__rejects_missing_index_source(void) #endif } +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +struct many_sources_fixture { + const struct git_hash_algo *algo; + char *worktree; + struct repository repo; + struct index_state istate; +}; + +static void many_sources_fixture_init(struct many_sources_fixture *fixture) +{ + struct strbuf path = STRBUF_INIT; + size_t i; + + memset(fixture, 0, sizeof(*fixture)); + fixture->algo = &hash_algos[GIT_HASH_SHA1]; + fixture->worktree = create_worktree(); + fixture->repo.worktree = fixture->worktree; + fixture->repo.hash_algo = fixture->algo; + index_state_init(&fixture->istate, &fixture->repo); + CALLOC_ARRAY(fixture->istate.cache, ATTR_MANIFEST_TEST_SOURCE_NR); + fixture->istate.cache_alloc = fixture->istate.cache_nr = + ATTR_MANIFEST_TEST_SOURCE_NR; + + for (i = 0; i < ATTR_MANIFEST_TEST_SOURCE_NR; i++) { + strbuf_reset(&path); + strbuf_addf(&path, "%s/d%03" PRIuMAX, + fixture->worktree, (uintmax_t)i); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_addstr(&path, "/" GITATTRIBUTES_FILE); + write_file(path.buf, "source %" PRIuMAX "\n", (uintmax_t)i); + strbuf_reset(&path); + strbuf_addf(&path, "d%03" PRIuMAX "/file", (uintmax_t)i); + add_index_path(&fixture->istate, i, path.buf, 0); + } + strbuf_release(&path); +} + +static void many_sources_fixture_release(struct many_sources_fixture *fixture) +{ + release_index(&fixture->istate); + remove_worktree(fixture->worktree); +} + +static void clear_attr_manifest_thread_env(void *unused UNUSED) +{ + unsetenv(ATTR_MANIFEST_TEST_THREADS); + unsetenv(ATTR_MANIFEST_TEST_THREAD_FAIL_AT); +} +#endif + +void test_attr_manifest__parallel_probes_match_serial_output(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct many_sources_fixture fixture; + struct worktree_attr_manifest_stats serial_stats, parallel_stats; + struct strbuf serial = STRBUF_INIT, parallel = STRBUF_INIT; + unsigned char serial_hash[GIT_MAX_RAWSZ]; + unsigned char parallel_hash[GIT_MAX_RAWSZ]; + + if (!HAVE_THREADS) + return; + cl_set_cleanup(clear_attr_manifest_thread_env, NULL); + many_sources_fixture_init(&fixture); + + xsetenv(ATTR_MANIFEST_TEST_THREADS, "1", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, &serial, serial_hash, &serial_stats), 0); + cl_assert_equal_i(serial_stats.candidates, + ATTR_MANIFEST_TEST_SOURCE_NR + 1); + cl_assert_equal_i(serial_stats.threads, 1); + cl_assert_equal_i(serial_stats.worktree_sources, + ATTR_MANIFEST_TEST_SOURCE_NR); + + xsetenv(ATTR_MANIFEST_TEST_THREADS, "2", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, ¶llel, parallel_hash, ¶llel_stats), 0); + cl_assert_equal_i(parallel_stats.candidates, + ATTR_MANIFEST_TEST_SOURCE_NR + 1); + cl_assert_equal_i(parallel_stats.threads, 2); + cl_assert_equal_i(parallel_stats.thread_failures, 0); + cl_assert_equal_i(parallel_stats.worktree_sources, + ATTR_MANIFEST_TEST_SOURCE_NR); + cl_assert_equal_i(serial.len, parallel.len); + cl_assert(!memcmp(serial.buf, parallel.buf, serial.len)); + cl_assert(!memcmp(serial_hash, parallel_hash, fixture.algo->rawsz)); + + strbuf_release(¶llel); + strbuf_release(&serial); + many_sources_fixture_release(&fixture); + clear_attr_manifest_thread_env(NULL); +#endif +} + +void test_attr_manifest__thread_failure_completes_remaining_ranges(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + struct many_sources_fixture fixture; + struct worktree_attr_manifest_stats serial_stats, fallback_stats; + struct strbuf serial = STRBUF_INIT, fallback = STRBUF_INIT; + unsigned char serial_hash[GIT_MAX_RAWSZ]; + unsigned char fallback_hash[GIT_MAX_RAWSZ]; + + if (!HAVE_THREADS) + return; + cl_set_cleanup(clear_attr_manifest_thread_env, NULL); + many_sources_fixture_init(&fixture); + + xsetenv(ATTR_MANIFEST_TEST_THREADS, "1", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, &serial, serial_hash, &serial_stats), 0); + xsetenv(ATTR_MANIFEST_TEST_THREADS, "2", 1); + xsetenv(ATTR_MANIFEST_TEST_THREAD_FAIL_AT, "1", 1); + cl_assert_equal_i(worktree_attr_manifest_build( + &fixture.istate, &fallback, fallback_hash, &fallback_stats), 0); + cl_assert_equal_i(fallback_stats.candidates, + ATTR_MANIFEST_TEST_SOURCE_NR + 1); + cl_assert_equal_i(fallback_stats.threads, 2); + cl_assert_equal_i(fallback_stats.thread_failures, 1); + cl_assert_equal_i(fallback_stats.worktree_sources, + ATTR_MANIFEST_TEST_SOURCE_NR); + cl_assert_equal_i(serial.len, fallback.len); + cl_assert(!memcmp(serial.buf, fallback.buf, serial.len)); + cl_assert(!memcmp(serial_hash, fallback_hash, fixture.algo->rawsz)); + + strbuf_release(&fallback); + strbuf_release(&serial); + many_sources_fixture_release(&fixture); + clear_attr_manifest_thread_env(NULL); +#endif +} + void test_attr_manifest__builder_rejects_structural_indexes(void) { #if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c index f30757677068cb..1a0234360c67cb 100644 --- a/worktree-attr-manifest.c +++ b/worktree-attr-manifest.c @@ -5,14 +5,19 @@ #include "hash-framing.h" #include "object.h" #include "odb.h" +#include "parse.h" #include "read-cache-ll.h" #include "repository.h" #include "semantic-verify-internal.h" #include "string-list.h" #include "strbuf.h" +#include "thread-utils.h" #include "worktree-attr-manifest.h" #include "worktree-attr-source.h" +#define ATTR_MANIFEST_FILES_PER_THREAD 256 +#define ATTR_MANIFEST_MAX_THREADS 32 + struct attr_manifest_candidate { unsigned char worktree_hash[GIT_MAX_RAWSZ]; unsigned char index_hash[GIT_MAX_RAWSZ]; @@ -30,6 +35,12 @@ struct attr_manifest_probe_data { unsigned int namespace_unstable; }; +struct attr_manifest_thread { + struct attr_manifest_probe_data probe; + pthread_t pthread; + unsigned int started : 1; +}; + static int collect_candidates(struct index_state *istate, struct string_list *candidates) { @@ -114,9 +125,9 @@ static int collect_index_sources(struct index_state *istate, return ret; } -static void probe_attr_manifest_candidates( - struct attr_manifest_probe_data *data) +static void *probe_attr_manifest_candidates(void *cb_data) { + struct attr_manifest_probe_data *data = cb_data; struct semantic_verify_path *path = semantic_verify_path_new(data->root); size_t i; @@ -133,21 +144,83 @@ static void probe_attr_manifest_candidates( candidate->worktree_present = found; } semantic_verify_path_free(path, &data->namespace_unstable, NULL); + return NULL; +} + +static size_t select_thread_count(size_t candidates) +{ + size_t cpus, test_threads, threads; + + if (!HAVE_THREADS) + return 1; + threads = DIV_ROUND_UP(candidates, ATTR_MANIFEST_FILES_PER_THREAD); + cpus = online_cpus(); + if (threads > cpus * 2) + threads = cpus * 2; + test_threads = git_env_ulong("GIT_TEST_ATTR_MANIFEST_THREADS", 0); + if (test_threads) + threads = test_threads; + if (threads > ATTR_MANIFEST_MAX_THREADS) + threads = ATTR_MANIFEST_MAX_THREADS; + if (threads > candidates) + threads = candidates; + return threads ? threads : 1; +} + +static int create_probe_thread(struct attr_manifest_thread *worker, + size_t thread_id) +{ + if (git_env_ulong("GIT_TEST_ATTR_MANIFEST_THREAD_FAIL_AT", + ULONG_MAX) == thread_id) + return EAGAIN; + return pthread_create(&worker->pthread, NULL, + probe_attr_manifest_candidates, &worker->probe); } static int probe_candidates(struct string_list *candidates, struct semantic_verify_root *root, - const struct git_hash_algo *algo) + const struct git_hash_algo *algo, + struct worktree_attr_manifest_stats *stats) { - struct attr_manifest_probe_data data = { - .candidates = candidates, - .root = root, - .algo = algo, - .end = candidates->nr, - }; - - probe_attr_manifest_candidates(&data); - return data.namespace_unstable ? -1 : 0; + struct attr_manifest_thread *workers; + size_t thread_id, threads = select_thread_count(candidates->nr); + int create_threads = HAVE_THREADS; + int ret = 0; + + CALLOC_ARRAY(workers, threads); + for (thread_id = 0; thread_id < threads; thread_id++) { + struct attr_manifest_thread *worker = &workers[thread_id]; + struct attr_manifest_probe_data *data = &worker->probe; + int err; + + data->candidates = candidates; + data->root = root; + data->algo = algo; + data->start = st_mult(candidates->nr, thread_id) / threads; + data->end = st_mult(candidates->nr, thread_id + 1) / threads; + if (threads == 1 || !create_threads) { + probe_attr_manifest_candidates(data); + continue; + } + err = create_probe_thread(worker, thread_id); + if (!err) { + worker->started = 1; + continue; + } + stats->thread_failures++; + create_threads = 0; + probe_attr_manifest_candidates(data); + } + for (thread_id = 0; thread_id < threads; thread_id++) { + struct attr_manifest_thread *worker = &workers[thread_id]; + + if (worker->started && pthread_join(worker->pthread, NULL)) + die("unable to join attribute manifest thread"); + ret |= worker->probe.namespace_unstable; + } + stats->threads = threads; + free(workers); + return ret ? -1 : 0; } int worktree_attr_manifest_build( @@ -170,7 +243,7 @@ int worktree_attr_manifest_build( collect_index_sources(istate, &candidates)) goto done; stats->candidates = candidates.nr; - if (probe_candidates(&candidates, root, algo)) + if (probe_candidates(&candidates, root, algo, stats)) goto done; attr_manifest_writer_init(&writer, manifest, algo); for (i = 0; i < candidates.nr; i++) { diff --git a/worktree-attr-manifest.h b/worktree-attr-manifest.h index 4c3e8dc4ba762c..4b3e17c26abd35 100644 --- a/worktree-attr-manifest.h +++ b/worktree-attr-manifest.h @@ -6,8 +6,10 @@ struct strbuf; struct worktree_attr_manifest_stats { size_t candidates; + size_t threads; size_t worktree_sources; size_t index_sources; + size_t thread_failures; }; int worktree_attr_manifest_build( From a850434f0772f3591715ba47174ba3c67e19a855 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:48:16 -0500 Subject: [PATCH 159/403] status: gate automatic UNTR validation preloads Starting directory-validation workers for a small cache, restricted pathspec, incompatible traversal, or fsmonitor-managed cache adds work without providing a safe whole-worktree reuse opportunity. Enable automatic preload only when the existing untracked cache and its root are valid, fsmonitor is disabled, traversal flags agree, and a bounded count finds at least 2,000 cached directories. Reject pathspecs, disabled untracked output, ignored-output modes, and incompatible -uall cache settings. Keep the test override for focused small-cache coverage. Status tests exercise both sides of the directory threshold and verify that restricted pathspecs and incompatible -uall requests retain the ordinary traversal path. Signed-off-by: Taylor Blau --- dir.c | 44 ++++++++++++++++++++++++++--- t/t7063-status-untracked-cache.sh | 47 ++++++++++++++++++++++++++++++- wt-status.c | 5 ++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/dir.c b/dir.c index c97f59cbf3b1de..8ad94248770a39 100644 --- a/dir.c +++ b/dir.c @@ -143,8 +143,33 @@ static void collect_untracked_cache_preload_tasks( } } -struct untracked_cache_preload *untracked_cache_preload_start_ordinary( - struct index_state *istate, unsigned int dir_flags) +static size_t count_untracked_cache_dirs_bounded( + const struct untracked_cache_dir *ucd, + size_t limit) +{ + size_t i, nr = 1; + + for (i = 0; i < ucd->dirs_nr && nr < limit; i++) + nr += count_untracked_cache_dirs_bounded(ucd->dirs[i], limit - nr); + return nr; +} + +#define UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS 2000 + +static int untracked_cache_auto_preload_worthwhile( + const struct untracked_cache *uc) +{ + if (!uc || !uc->root || uc->use_fsmonitor || !uc->root->valid) + return 0; + if (git_env_bool("GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD", 0)) + return 1; + return count_untracked_cache_dirs_bounded( + uc->root, UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS) >= + UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS; +} + +static struct untracked_cache_preload *untracked_cache_preload_start_1( + struct index_state *istate, unsigned int dir_flags, int automatic) { struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; @@ -153,8 +178,6 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( unsigned long test_threads; int threads, online, create_threads = 1; - if (!git_env_bool("GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD", 0)) - return NULL; if (!uc || !uc->root || uc->use_fsmonitor || uc->dir_flags != dir_flags) return NULL; @@ -188,6 +211,8 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( preload->started_at = getnanotime(); trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/threads", threads); + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/automatic", automatic); CALLOC_ARRAY(preload->data, threads); work = DIV_ROUND_UP(preload->nr, threads); for (i = 0; i < threads; i++) { @@ -216,6 +241,17 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( return preload; } +struct untracked_cache_preload *untracked_cache_preload_start_ordinary( + struct index_state *istate, unsigned int dir_flags) +{ + struct untracked_cache *uc = istate->untracked; + + if (!uc || uc->dir_flags != dir_flags || + !untracked_cache_auto_preload_worthwhile(uc)) + return NULL; + return untracked_cache_preload_start_1(istate, dir_flags, 1); +} + static void *preload_untracked_cache_thread(void *_data) { struct untracked_cache_preload_data *data = _data; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 8f49cfeebb6896..9f7c5fee79d639 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1017,6 +1017,35 @@ test_expect_success 'directory snapshots ignore weak file-stat configuration' ' ) ' +test_expect_success 'automatic preload observes its directory threshold' ' + test_create_repo auto-preload-threshold && + ( + cd auto-preload-threshold && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor false && + sane_unset GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD && + for i in $(test_seq 1 1998) + do + mkdir "d$i" && + >"d$i/file" || return 1 + done && + git status --porcelain >/dev/null && + GIT_TRACE2_EVENT="$PWD/.git/below-threshold.trace" \ + git status --porcelain >/dev/null && + test_grep ! "preload_untracked_cache/automatic" \ + .git/below-threshold.trace && + + mkdir d1999 && + >d1999/file && + git status --porcelain >/dev/null && + GIT_TRACE2_EVENT="$PWD/.git/at-threshold.trace" \ + git status --porcelain >/dev/null && + test_grep \ + "preload_untracked_cache/automatic.*value.*1" \ + .git/at-threshold.trace + ) +' test_expect_success 'status preloads cached-directory validation' ' test_create_repo auto-preload && ( @@ -1062,7 +1091,23 @@ test_expect_success 'status preloads cached-directory validation' ' test_grep "preload_untracked_cache/valid.*value.*0" \ .git/changed.trace && test_grep "opendir.*value.*[1-9][0-9]*" \ - .git/changed.trace + .git/changed.trace && + + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain -- nested >.git/expect-pathspec && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TRACE2_EVENT="$PWD/.git/pathspec.trace" \ + git status --porcelain -- nested >.git/actual-pathspec && + test_cmp .git/expect-pathspec .git/actual-pathspec && + test_grep ! 'preload_untracked_cache/threads' \ + .git/pathspec.trace && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain -uall >.git/expect-uall && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TRACE2_EVENT="$PWD/.git/uall.trace" \ + git status --porcelain -uall >.git/actual-uall && + test_cmp .git/expect-uall .git/actual-uall && + test_grep ! 'preload_untracked_cache/threads' .git/uall.trace ) ' diff --git a/wt-status.c b/wt-status.c index ffb9ff8f044fd9..da642642d4a229 100644 --- a/wt-status.c +++ b/wt-status.c @@ -817,6 +817,11 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); + if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED || + s->pathspec.nr || + s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode) + return; dir_flags = wt_status_untracked_dir_flags(s); s->untracked_cache_preload = From aacaaa4439f0b70fefa8a0b20638a20832d58322 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:30:48 -0700 Subject: [PATCH 160/403] fsmonitor: add a bounded clean-proof record format A provider token cannot establish clean status unless its configuration, conversion semantics, attribute sources, and manifest are recorded as one verifiable observation. Parsing a malformed record directly into index state could also publish part of an invalid proof. Define a versioned, length-delimited clean-proof codec with distinct magic, bounded flags, a bounded token, object-format-sized configuration and attribute hashes, the validated attribute manifest, and a trailing checksum. Parse into temporary state and publish the decoded view only after all lengths, flags, token bytes, manifest records, and the checksum agree. Allow a generic writer to retain validated history while clearing its token and stat bindings instead of asserting a fresh provider epoch. Register the library and Clar suite in both Make and Meson. Tests cover both object formats, corrupt and truncated records, invalid flags, embedded token NULs, checksum changes, and selective clearing of epoch bindings. The codec does not attach an extension to an index or enable an early status answer. Signed-off-by: Taylor Blau --- Makefile | 2 + fsmonitor-clean-proof.c | 116 ++++++++++++++++++++ fsmonitor-clean-proof.h | 42 ++++++++ hash-framing.h | 10 ++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-fsmonitor-clean-proof.c | 141 +++++++++++++++++++++++++ 7 files changed, 313 insertions(+) create mode 100644 fsmonitor-clean-proof.c create mode 100644 fsmonitor-clean-proof.h create mode 100644 t/unit-tests/u-fsmonitor-clean-proof.c diff --git a/Makefile b/Makefile index 2eb2b731f6a1d3..f2fe60decb2d5b 100644 --- a/Makefile +++ b/Makefile @@ -1176,6 +1176,7 @@ LIB_OBJS += fetch-object-info.o LIB_OBJS += fetch-pack.o LIB_OBJS += fmt-merge-msg.o LIB_OBJS += fsck.o +LIB_OBJS += fsmonitor-clean-proof.o LIB_OBJS += fsmonitor.o LIB_OBJS += fsmonitor-ipc.o LIB_OBJS += fsmonitor-settings.o @@ -1552,6 +1553,7 @@ CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate CLAR_TEST_SUITES += u-fsmonitor-attributes +CLAR_TEST_SUITES += u-fsmonitor-clean-proof CLAR_TEST_SUITES += u-hash CLAR_TEST_SUITES += u-hashmap CLAR_TEST_SUITES += u-list-objects-filter-options diff --git a/fsmonitor-clean-proof.c b/fsmonitor-clean-proof.c new file mode 100644 index 00000000000000..3c45c014469737 --- /dev/null +++ b/fsmonitor-clean-proof.c @@ -0,0 +1,116 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" +#include "strbuf.h" + +#define FSMONITOR_CLEAN_PROOF_MAGIC 0x46534331 /* "FSC1" */ +#define FSMONITOR_CLEAN_PROOF_HEADER_WORDS 5 + +int fsmonitor_clean_proof_parse(struct fsmonitor_clean_proof *proof, + const void *data, size_t len, + const struct git_hash_algo *algo) +{ + struct fsmonitor_clean_proof parsed = { 0 }; + const unsigned char *p = data; + const unsigned char *end = p + len; + unsigned char checksum[GIT_MAX_RAWSZ]; + size_t hashes_len = 4 * algo->rawsz; + uint32_t token_len, manifest_len; + + memset(proof, 0, sizeof(*proof)); + if (len < FSMONITOR_CLEAN_PROOF_HEADER_WORDS * sizeof(uint32_t) + + hashes_len + 1) + return -1; + if (get_be32(p) != FSMONITOR_CLEAN_PROOF_VERSION) + return -1; + p += sizeof(uint32_t); + if (get_be32(p) != FSMONITOR_CLEAN_PROOF_MAGIC) + return -1; + p += sizeof(uint32_t); + parsed.flags = get_be32(p); + p += sizeof(uint32_t); + token_len = get_be32(p); + p += sizeof(uint32_t); + manifest_len = get_be32(p); + p += sizeof(uint32_t); + if (parsed.flags & ~FSMONITOR_CLEAN_PROOF_ALL || !token_len || + token_len > FSMONITOR_CLEAN_PROOF_TOKEN_MAX || + manifest_len < sizeof(uint32_t) || + (size_t)(end - p) < token_len || memchr(p, '\0', token_len)) + return -1; + parsed.token = p; + parsed.token_len = token_len; + p += token_len; + if ((size_t)(end - p) < hashes_len || + (size_t)(end - p) - hashes_len != manifest_len) + return -1; + parsed.config_hash = p; + p += algo->rawsz; + parsed.semantic_hash = p; + p += algo->rawsz; + parsed.attr_hash = p; + p += algo->rawsz; + parsed.attr_manifest = p; + parsed.attr_manifest_len = manifest_len; + p += manifest_len; + if (!attr_manifest_valid(parsed.attr_manifest, + parsed.attr_manifest_len, algo)) + return -1; + hash_buffer_digest(algo, data, len - algo->rawsz, checksum); + if (memcmp(checksum, p, algo->rawsz)) + return -1; + *proof = parsed; + return 0; +} + +int fsmonitor_clean_proof_write(struct strbuf *out, + const struct fsmonitor_clean_proof *proof, + const struct git_hash_algo *algo) +{ + uint32_t value; + + strbuf_reset(out); + if (!proof->token || !proof->token_len || + proof->token_len > FSMONITOR_CLEAN_PROOF_TOKEN_MAX || + proof->token_len > UINT32_MAX || + memchr(proof->token, '\0', proof->token_len) || + proof->flags & ~FSMONITOR_CLEAN_PROOF_ALL || + !proof->config_hash || !proof->semantic_hash || !proof->attr_hash || + !proof->attr_manifest || proof->attr_manifest_len > UINT32_MAX || + !attr_manifest_valid(proof->attr_manifest, + proof->attr_manifest_len, algo)) + return -1; + + put_be32(&value, FSMONITOR_CLEAN_PROOF_VERSION); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, FSMONITOR_CLEAN_PROOF_MAGIC); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, proof->flags); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, proof->token_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, proof->attr_manifest_len); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, proof->token, proof->token_len); + strbuf_add(out, proof->config_hash, algo->rawsz); + strbuf_add(out, proof->semantic_hash, algo->rawsz); + strbuf_add(out, proof->attr_hash, algo->rawsz); + strbuf_add(out, proof->attr_manifest, proof->attr_manifest_len); + hash_append_checksum(out, algo); + return 0; +} + +int fsmonitor_clean_proof_copy_without_bindings( + struct strbuf *out, const void *data, size_t len, + const struct git_hash_algo *algo) +{ + struct fsmonitor_clean_proof proof; + + strbuf_reset(out); + if (fsmonitor_clean_proof_parse(&proof, data, len, algo)) + return -1; + proof.flags &= ~(FSMONITOR_CLEAN_PROOF_TOKEN_BOUND | + FSMONITOR_CLEAN_PROOF_STAT_BOUND); + return fsmonitor_clean_proof_write(out, &proof, algo); +} diff --git a/fsmonitor-clean-proof.h b/fsmonitor-clean-proof.h new file mode 100644 index 00000000000000..0d4da4cd725803 --- /dev/null +++ b/fsmonitor-clean-proof.h @@ -0,0 +1,42 @@ +#ifndef FSMONITOR_CLEAN_PROOF_H +#define FSMONITOR_CLEAN_PROOF_H + +#include "hash.h" + +struct strbuf; + +#define FSMONITOR_CLEAN_PROOF_VERSION 1 +#define FSMONITOR_CLEAN_PROOF_TOKEN_MAX 4096 + +#define FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE (1u << 0) +#define FSMONITOR_CLEAN_PROOF_TOKEN_BOUND (1u << 1) +#define FSMONITOR_CLEAN_PROOF_STAT_BOUND (1u << 2) +#define FSMONITOR_CLEAN_PROOF_FULL_INDEX (1u << 3) +#define FSMONITOR_CLEAN_PROOF_ALL \ + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | \ + FSMONITOR_CLEAN_PROOF_TOKEN_BOUND | \ + FSMONITOR_CLEAN_PROOF_STAT_BOUND | \ + FSMONITOR_CLEAN_PROOF_FULL_INDEX) + +struct fsmonitor_clean_proof { + uint32_t flags; + const unsigned char *token; + size_t token_len; + const unsigned char *config_hash; + const unsigned char *semantic_hash; + const unsigned char *attr_hash; + const unsigned char *attr_manifest; + size_t attr_manifest_len; +}; + +int fsmonitor_clean_proof_parse(struct fsmonitor_clean_proof *proof, + const void *data, size_t len, + const struct git_hash_algo *algo); +int fsmonitor_clean_proof_write(struct strbuf *out, + const struct fsmonitor_clean_proof *proof, + const struct git_hash_algo *algo); +int fsmonitor_clean_proof_copy_without_bindings( + struct strbuf *out, const void *data, size_t len, + const struct git_hash_algo *algo); + +#endif /* FSMONITOR_CLEAN_PROOF_H */ diff --git a/hash-framing.h b/hash-framing.h index f20b455e590f87..6808cc288faa04 100644 --- a/hash-framing.h +++ b/hash-framing.h @@ -2,6 +2,7 @@ #define HASH_FRAMING_H #include "hash.h" +#include "strbuf.h" static inline void hash_length_delimited(struct git_hash_ctx *ctx, const void *data, size_t len) @@ -38,4 +39,13 @@ static inline void hash_buffer_digest(const struct git_hash_algo *algo, git_hash_final(hash, &ctx); } +static inline void hash_append_checksum(struct strbuf *out, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + hash_buffer_digest(algo, out->buf, out->len, hash); + strbuf_add(out, hash, algo->rawsz); +} + #endif /* HASH_FRAMING_H */ diff --git a/meson.build b/meson.build index 98a692d05c1bb3..c81db56fb078b4 100644 --- a/meson.build +++ b/meson.build @@ -380,6 +380,7 @@ libgit_sources = [ 'fetch-pack.c', 'fmt-merge-msg.c', 'fsck.c', + 'fsmonitor-clean-proof.c', 'fsmonitor.c', 'fsmonitor-ipc.c', 'fsmonitor-settings.c', diff --git a/t/meson.build b/t/meson.build index 73372cd547f84c..812a11c51d18d7 100644 --- a/t/meson.build +++ b/t/meson.build @@ -5,6 +5,7 @@ clar_test_suites = [ 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', 'unit-tests/u-fsmonitor-attributes.c', + 'unit-tests/u-fsmonitor-clean-proof.c', 'unit-tests/u-hash.c', 'unit-tests/u-hashmap.c', 'unit-tests/u-list-objects-filter-options.c', diff --git a/t/unit-tests/u-fsmonitor-clean-proof.c b/t/unit-tests/u-fsmonitor-clean-proof.c new file mode 100644 index 00000000000000..b4691221c75f49 --- /dev/null +++ b/t/unit-tests/u-fsmonitor-clean-proof.c @@ -0,0 +1,141 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "fsmonitor-clean-proof.h" +#include "strbuf.h" + +struct proof_fixture { + struct strbuf manifest; + struct strbuf encoded; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char attr_hash[GIT_MAX_RAWSZ]; + struct fsmonitor_clean_proof proof; +}; + +static void fixture_init(struct proof_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct attr_manifest_writer writer; + unsigned char hash[GIT_MAX_RAWSZ]; + static const unsigned char token[] = "builtin:1:2"; + + memset(fixture, 0, sizeof(*fixture)); + fixture->manifest = (struct strbuf)STRBUF_INIT; + fixture->encoded = (struct strbuf)STRBUF_INIT; + memset(hash, 1, algo->rawsz); + memset(fixture->config_hash, 2, algo->rawsz); + memset(fixture->semantic_hash, 3, algo->rawsz); + memset(fixture->attr_hash, 4, algo->rawsz); + attr_manifest_writer_init(&writer, &fixture->manifest, algo); + cl_assert_equal_i(attr_manifest_writer_add( + &writer, ".gitattributes", ATTR_MANIFEST_INDEX, hash), 0); + fixture->proof.flags = FSMONITOR_CLEAN_PROOF_ALL; + fixture->proof.token = token; + fixture->proof.token_len = sizeof(token) - 1; + fixture->proof.config_hash = fixture->config_hash; + fixture->proof.semantic_hash = fixture->semantic_hash; + fixture->proof.attr_hash = fixture->attr_hash; + fixture->proof.attr_manifest = + (const unsigned char *)fixture->manifest.buf; + fixture->proof.attr_manifest_len = fixture->manifest.len; +} + +static void fixture_release(struct proof_fixture *fixture) +{ + strbuf_release(&fixture->encoded); + strbuf_release(&fixture->manifest); +} + +static void assert_round_trip(const struct git_hash_algo *algo) +{ + struct proof_fixture fixture; + struct fsmonitor_clean_proof parsed; + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture.encoded, &fixture.proof, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.flags, fixture.proof.flags); + cl_assert_equal_i(parsed.token_len, fixture.proof.token_len); + cl_assert(!memcmp(parsed.token, fixture.proof.token, parsed.token_len)); + cl_assert(!memcmp(parsed.config_hash, fixture.config_hash, algo->rawsz)); + cl_assert(!memcmp(parsed.attr_manifest, fixture.manifest.buf, + parsed.attr_manifest_len)); + fixture_release(&fixture); +} + +static void assert_rejected(struct fsmonitor_clean_proof *parsed, + const struct strbuf *encoded, + const struct git_hash_algo *algo) +{ + memset(parsed, 0xff, sizeof(*parsed)); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + parsed, encoded->buf, encoded->len, algo), -1); + cl_assert_equal_i(parsed->flags, 0); + cl_assert_equal_p(parsed->token, NULL); + cl_assert_equal_i(parsed->token_len, 0); + cl_assert_equal_p(parsed->config_hash, NULL); + cl_assert_equal_p(parsed->semantic_hash, NULL); + cl_assert_equal_p(parsed->attr_hash, NULL); + cl_assert_equal_p(parsed->attr_manifest, NULL); + cl_assert_equal_i(parsed->attr_manifest_len, 0); +} + +void test_fsmonitor_clean_proof__round_trips_both_object_formats(void) +{ + assert_round_trip(&hash_algos[GIT_HASH_SHA1]); + assert_round_trip(&hash_algos[GIT_HASH_SHA256]); +} + +void test_fsmonitor_clean_proof__rejects_corrupt_records(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct proof_fixture fixture; + struct fsmonitor_clean_proof parsed; + size_t token_offset = 5 * sizeof(uint32_t); + uint32_t saved; + unsigned char byte; + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture.encoded, &fixture.proof, algo), 0); + fixture.encoded.len--; + assert_rejected(&parsed, &fixture.encoded, algo); + fixture.encoded.len++; + saved = get_be32(fixture.encoded.buf + 2 * sizeof(uint32_t)); + put_be32(fixture.encoded.buf + 2 * sizeof(uint32_t), 1u << 31); + assert_rejected(&parsed, &fixture.encoded, algo); + put_be32(fixture.encoded.buf + 2 * sizeof(uint32_t), saved); + byte = fixture.encoded.buf[token_offset]; + fixture.encoded.buf[token_offset] = '\0'; + assert_rejected(&parsed, &fixture.encoded, algo); + fixture.encoded.buf[token_offset] = byte; + fixture.encoded.buf[fixture.encoded.len - 1] ^= 1; + assert_rejected(&parsed, &fixture.encoded, algo); + fixture_release(&fixture); +} + +void test_fsmonitor_clean_proof__clears_only_epoch_bindings(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + struct proof_fixture fixture; + struct fsmonitor_clean_proof parsed; + struct strbuf unbound = STRBUF_INIT; + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture.encoded, &fixture.proof, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_copy_without_bindings( + &unbound, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, unbound.buf, unbound.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + cl_assert_equal_i(parsed.token_len, fixture.proof.token_len); + cl_assert(!memcmp(parsed.attr_manifest, fixture.manifest.buf, + fixture.manifest.len)); + strbuf_release(&unbound); + fixture_release(&fixture); +} From b41c6d7c43099bb32a0d9f608f08d676ba485266 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:04 -0500 Subject: [PATCH 161/403] fsmonitor: rediscover linked worktrees when starting a daemon An implicitly started fsmonitor daemon inherits its caller's repository environment and current directory. In a linked worktree, inherited Git directory, worktree, common-directory, prefix, and index settings can make the child discover a different repository than the worktree whose status requested the daemon. Resolve the requested worktree to its canonical path, start the child from that directory, and remove repository-addressing variables from its environment. Keep the existing daemon start command and return an error if the worktree cannot be resolved. Add a macOS regression that implicitly starts fsmonitor from a linked worktree and checks the daemon child's working directory in Trace2. Signed-off-by: Taylor Blau --- fsmonitor-ipc.c | 29 ++++++++++++++++++++++++++++- t/t7527-builtin-fsmonitor.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 6112d130644f04..78720fa4aba04c 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -1,6 +1,8 @@ #define USE_THE_REPOSITORY_VARIABLE #include "git-compat-util.h" +#include "abspath.h" +#include "environment.h" #include "gettext.h" #include "simple-ipc.h" #include "fsmonitor-ipc.h" @@ -45,6 +47,17 @@ int fsmonitor_ipc__send_command(const char *command UNUSED, #else +static void prepare_spawn_env(struct strvec *env) +{ + /* Let the child rediscover this repository from the worktree. */ + strvec_push(env, GIT_DIR_ENVIRONMENT); + strvec_push(env, GIT_WORK_TREE_ENVIRONMENT); + strvec_push(env, GIT_COMMON_DIR_ENVIRONMENT); + strvec_push(env, GIT_PREFIX_ENVIRONMENT); + strvec_push(env, GIT_IMPLICIT_WORK_TREE_ENVIRONMENT); + strvec_push(env, INDEX_ENVIRONMENT); +} + int fsmonitor_ipc__is_supported(void) { return 1; @@ -58,7 +71,18 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) static int spawn_daemon(void) { struct child_process cmd = CHILD_PROCESS_INIT; + struct strbuf canonical_worktree = STRBUF_INIT; + const char *worktree = repo_get_work_tree(the_repository); + int ret = -1; + if (!worktree || + !strbuf_realpath(&canonical_worktree, worktree, 0)) { + error(_("cannot start fsmonitor daemon without a work tree")); + goto done; + } + + prepare_spawn_env(&cmd.env); + cmd.dir = canonical_worktree.buf; cmd.git_cmd = 1; cmd.no_stdin = 1; cmd.no_stdout = 1; @@ -67,7 +91,10 @@ static int spawn_daemon(void) cmd.trace2_child_class = "fsmonitor"; strvec_pushl(&cmd.args, "fsmonitor--daemon", "start", NULL); - return run_command(&cmd); + ret = run_command(&cmd); +done: + strbuf_release(&canonical_worktree); + return ret; } int fsmonitor_ipc__send_query(const char *since_token, diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 86195770e97779..9f463a3f7a8dbd 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1389,4 +1389,33 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep -q " M dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-try3.out" ' +test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' + test_when_finished " + git -C reexec-linked-wt fsmonitor--daemon stop 2>/dev/null || : + git -C reexec-linked-main worktree remove --force \ + ../reexec-linked-wt 2>/dev/null || : + " && + test_create_repo reexec-linked-main && + ( + cd reexec-linked-main && + test_commit base tracked && + git worktree add ../reexec-linked-wt && + git -C ../reexec-linked-wt config core.untrackedCache true && + git -C ../reexec-linked-wt config core.fsmonitor true && + linked_worktree=$(test-tool path-utils real_path \ + ../reexec-linked-wt) && + GIT_TRACE2_EVENT="$PWD/../reexec-linked.trace" \ + git -C ../reexec-linked-wt status --porcelain=v2 \ + >../reexec-linked.actual && + test_must_be_empty ../reexec-linked.actual && + test_subcommand git fsmonitor--daemon start \ + <../reexec-linked.trace && + test_grep \ + "\"child_class\":\"fsmonitor\",\"cd\":\"$linked_worktree\"" \ + ../reexec-linked.trace && + git -C ../reexec-linked-wt fsmonitor--daemon stop && + git worktree remove ../reexec-linked-wt + ) +' + test_done From f8853e4f1c7ee8a762f07a0f3424008cff7967c1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:42:56 -0700 Subject: [PATCH 162/403] status: retain validated worktree attribute manifests A persisted attribute manifest must not become current merely because its bytes were present in an index. Invalid records or unknown proof flags could otherwise leave half-loaded history available to a later clean-status decision. Introduce a state object that owns separate persisted and current manifest buffers, their hashes, proof flags, and validity bits. Validate incoming bytes with S07/P02 and accept only the flags defined by S07/P07. Clear the persisted view before loading; copy it into the current view only through explicit adoption. Register the state library and Clar suite in both Make and Meson. Tests cover valid loading and adoption, explicit current-state invalidation, and removal of a previously valid persisted view after malformed input. This state does not read an index extension, scan the worktree, invalidate attribute paths, or activate a status optimization. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-manifest.c | 56 +++++++++++++++++++++ clean-status-manifest.h | 29 +++++++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-manifest.c | 70 ++++++++++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 clean-status-manifest.c create mode 100644 clean-status-manifest.h create mode 100644 t/unit-tests/u-clean-status-manifest.c diff --git a/Makefile b/Makefile index f2fe60decb2d5b..8bdaab2391f7b2 100644 --- a/Makefile +++ b/Makefile @@ -1125,6 +1125,7 @@ LIB_OBJS += chdir-notify.o LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o @@ -1549,6 +1550,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config +CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-manifest.c b/clean-status-manifest.c new file mode 100644 index 00000000000000..713d8bd4d5e104 --- /dev/null +++ b/clean-status-manifest.c @@ -0,0 +1,56 @@ +#include "git-compat-util.h" +#include "attr-manifest.h" +#include "clean-status-manifest.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" + +void clean_status_manifest_init(struct clean_status_manifest_state *state) +{ + memset(state, 0, sizeof(*state)); + strbuf_init(&state->disk, 0); + strbuf_init(&state->current, 0); +} + +void clean_status_manifest_release(struct clean_status_manifest_state *state) +{ + strbuf_release(&state->disk); + strbuf_release(&state->current); +} + +int clean_status_manifest_load(struct clean_status_manifest_state *state, + const void *data, size_t len, uint32_t flags, + const struct git_hash_algo *algo) +{ + state->disk_valid = 0; + state->disk_flags = 0; + strbuf_reset(&state->disk); + if (flags & ~FSMONITOR_CLEAN_PROOF_ALL || + !attr_manifest_valid(data, len, algo)) + return -1; + strbuf_add(&state->disk, data, len); + hash_buffer_digest(algo, data, len, state->disk_hash); + state->disk_flags = flags; + state->disk_valid = 1; + return 0; +} + +void clean_status_manifest_adopt_disk( + struct clean_status_manifest_state *state) +{ + if (!state->disk_valid) + BUG("cannot adopt an invalid clean-status manifest"); + strbuf_reset(&state->current); + strbuf_addbuf(&state->current, &state->disk); + memcpy(state->current_hash, state->disk_hash, + sizeof(state->current_hash)); + state->current_flags = state->disk_flags; + state->current_valid = 1; + state->checked = 1; +} + +void clean_status_manifest_invalidate( + struct clean_status_manifest_state *state) +{ + state->current_valid = 0; + state->current_flags = 0; +} diff --git a/clean-status-manifest.h b/clean-status-manifest.h new file mode 100644 index 00000000000000..e924ba9fade2fe --- /dev/null +++ b/clean-status-manifest.h @@ -0,0 +1,29 @@ +#ifndef CLEAN_STATUS_MANIFEST_H +#define CLEAN_STATUS_MANIFEST_H + +#include "hash.h" +#include "strbuf.h" + +struct clean_status_manifest_state { + struct strbuf disk; + struct strbuf current; + unsigned char disk_hash[GIT_MAX_RAWSZ]; + unsigned char current_hash[GIT_MAX_RAWSZ]; + uint32_t disk_flags; + uint32_t current_flags; + unsigned disk_valid : 1; + unsigned current_valid : 1; + unsigned checked : 1; +}; + +void clean_status_manifest_init(struct clean_status_manifest_state *state); +void clean_status_manifest_release(struct clean_status_manifest_state *state); +int clean_status_manifest_load(struct clean_status_manifest_state *state, + const void *data, size_t len, uint32_t flags, + const struct git_hash_algo *algo); +void clean_status_manifest_adopt_disk( + struct clean_status_manifest_state *state); +void clean_status_manifest_invalidate( + struct clean_status_manifest_state *state); + +#endif /* CLEAN_STATUS_MANIFEST_H */ diff --git a/meson.build b/meson.build index c81db56fb078b4..abc61a3c8bd6d4 100644 --- a/meson.build +++ b/meson.build @@ -333,6 +333,7 @@ libgit_sources = [ 'checkout.c', 'chunk-format.c', 'clean-status-config.c', + 'clean-status-manifest.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/t/meson.build b/t/meson.build index 812a11c51d18d7..d0ed8bb9461d07 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,6 +1,7 @@ clar_test_suites = [ 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', + 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c new file mode 100644 index 00000000000000..e6d83c564a8a6b --- /dev/null +++ b/t/unit-tests/u-clean-status-manifest.c @@ -0,0 +1,70 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "clean-status-manifest.h" +#include "fsmonitor-clean-proof.h" + +static void make_manifest(struct strbuf *manifest, + const struct git_hash_algo *algo) +{ + struct attr_manifest_writer writer; + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(hash, 1, algo->rawsz); + attr_manifest_writer_init(&writer, manifest, algo); + cl_assert_equal_i(attr_manifest_writer_add( + &writer, ".gitattributes", ATTR_MANIFEST_INDEX, hash), 0); +} + +void test_clean_status_manifest__loads_and_adopts_valid_history(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_manifest_state state; + struct strbuf manifest = STRBUF_INIT; + + clean_status_manifest_init(&state); + make_manifest(&manifest, algo); + cl_assert_equal_i(clean_status_manifest_load( + &state, manifest.buf, manifest.len, + FSMONITOR_CLEAN_PROOF_ALL, algo), 0); + cl_assert(state.disk_valid); + clean_status_manifest_adopt_disk(&state); + cl_assert(state.current_valid); + cl_assert(state.checked); + cl_assert_equal_i(state.current_flags, FSMONITOR_CLEAN_PROOF_ALL); + cl_assert_equal_i(state.current.len, manifest.len); + cl_assert(!memcmp(state.current.buf, manifest.buf, manifest.len)); + clean_status_manifest_invalidate(&state); + cl_assert(!state.current_valid); + cl_assert_equal_i(state.current_flags, 0); + clean_status_manifest_release(&state); + strbuf_release(&manifest); +} + +void test_clean_status_manifest__rejects_invalid_history(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_manifest_state state; + struct strbuf manifest = STRBUF_INIT; + size_t valid_len; + + clean_status_manifest_init(&state); + make_manifest(&manifest, algo); + valid_len = manifest.len; + cl_assert_equal_i(clean_status_manifest_load( + &state, manifest.buf, manifest.len, + FSMONITOR_CLEAN_PROOF_ALL, algo), 0); + cl_assert(state.disk_valid); + cl_assert_equal_i(state.disk_flags, FSMONITOR_CLEAN_PROOF_ALL); + cl_assert_equal_i(state.disk.len, valid_len); + cl_assert(!memcmp(state.disk.buf, manifest.buf, valid_len)); + + strbuf_addch(&manifest, 0); + cl_assert_equal_i(clean_status_manifest_load( + &state, manifest.buf, manifest.len, + FSMONITOR_CLEAN_PROOF_ALL, algo), -1); + cl_assert(!state.disk_valid); + cl_assert_equal_i(state.disk_flags, 0); + cl_assert_equal_i(state.disk.len, 0); + clean_status_manifest_release(&state); + strbuf_release(&manifest); +} From 12d262208d489a9ea606634baaa73e7c7b98085a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:14 -0500 Subject: [PATCH 163/403] fsmonitor: start implicit daemons with the invoking Git executable Implicit fsmonitor startup resolves a Git command through the execution path and invokes its start subcommand. An overridden execution path can therefore select a different Git than the dispatcher that initiated the query, while adding another launcher between the client and daemon. Retain the absolute executable path during dispatcher initialization and expose it only for a real Git dispatcher. Start that executable directly with fsmonitor--daemon run --detach, then wait until its IPC socket is listening before accepting startup. Respect the configured startup timeout, defaulting to 60 seconds, and retain Git-command lookup when an authoritative dispatcher path is unavailable. The canonical worktree and sanitized environment established by S03/P01 remain in place. Update existing startup Trace2 checks for the direct invocation and add a macOS regression with a fake Git on the execution path to verify that the original executable is used. Signed-off-by: Taylor Blau --- exec-cmd.c | 44 +++++++++++++++++++++++++++++--- exec-cmd.h | 2 ++ fsmonitor-ipc.c | 49 +++++++++++++++++++++++++++++++++--- git.c | 3 +++ t/t7527-builtin-fsmonitor.sh | 37 +++++++++++++++++++++++---- 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/exec-cmd.c b/exec-cmd.c index 507e67d528b0dd..dc801d1a6d35d4 100644 --- a/exec-cmd.c +++ b/exec-cmd.c @@ -27,6 +27,14 @@ static const char *system_prefix(void); +/* + * Absolute path to the current executable, when it can be determined. Keep + * this separately from executable_dirname because some callers need to + * re-execute this exact Git rather than resolve "git" through PATH. + */ +static const char *executable_path; +static int executable_is_dispatcher; + #ifdef RUNTIME_PREFIX /** @@ -257,7 +265,8 @@ void git_resolve_executable_dir(const char *argv0) return; } - resolved = strbuf_detach(&buf, NULL); + executable_path = strbuf_detach(&buf, NULL); + resolved = xstrdup(executable_path); slash = find_last_dir_sep(resolved); if (slash) resolved[slash - resolved] = '\0'; @@ -278,15 +287,42 @@ static const char *system_prefix(void) } /* - * This is called during initialization, but No work needs to be done here when - * runtime prefix is not being used. + * A non-runtime-prefix build does not need the executable directory for path + * discovery, but an explicit argv[0] is still useful for exact re-execution. */ -void git_resolve_executable_dir(const char *argv0 UNUSED) +void git_resolve_executable_dir(const char *argv0) { + struct strbuf buf = STRBUF_INIT; + + /* A bare argv[0] would require a PATH lookup and is not authoritative. */ + if (!argv0 || !*argv0 || !find_last_dir_sep(argv0)) + return; + strbuf_add_absolute_path(&buf, argv0); + if (strbuf_normalize_path(&buf)) { + trace_printf("trace: could not normalize executable path: %s\n", + buf.buf); + strbuf_release(&buf); + return; + } + executable_path = strbuf_detach(&buf, NULL); + trace2_cmd_path(executable_path); } #endif /* RUNTIME_PREFIX */ +const char *git_executable_path(void) +{ + /* Helpers have an exact path too, but cannot dispatch Git builtins. */ + if (!executable_path || !executable_is_dispatcher) + return NULL; + return executable_path; +} + +void git_mark_executable_as_dispatcher(void) +{ + executable_is_dispatcher = 1; +} + char *system_path(const char *path) { struct strbuf d = STRBUF_INIT; diff --git a/exec-cmd.h b/exec-cmd.h index 330b41d54dec52..0613765ef7e4ee 100644 --- a/exec-cmd.h +++ b/exec-cmd.h @@ -5,6 +5,8 @@ struct strvec; void git_set_exec_path(const char *exec_path); void git_resolve_executable_dir(const char *path); +const char *git_executable_path(void); +void git_mark_executable_as_dispatcher(void); const char *git_exec_path(void); void setup_path(void); const char **prepare_git_cmd(struct strvec *out, const char **argv); diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 78720fa4aba04c..8957091bfccbb2 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -2,8 +2,11 @@ #include "git-compat-util.h" #include "abspath.h" +#include "config.h" #include "environment.h" +#include "exec-cmd.h" #include "gettext.h" +#include "parse.h" #include "simple-ipc.h" #include "fsmonitor-ipc.h" #include "repository.h" @@ -68,10 +71,44 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) return ipc_get_active_state(fsmonitor_ipc__get_path(the_repository)); } +#define FSMONITOR_START_TIMEOUT_KEY "fsmonitor.starttimeout" +#define FSMONITOR_START_TIMEOUT_DEFAULT 60 + +static unsigned int get_start_timeout(void) +{ + const char *value; + int timeout; + + if (!repo_config_get_value(the_repository, + FSMONITOR_START_TIMEOUT_KEY, &value) && + value && git_parse_int(value, &timeout) && timeout >= 0) + return timeout; + return FSMONITOR_START_TIMEOUT_DEFAULT; +} + +static int spawn_wait_cb(const struct child_process *cmd UNUSED, + void *cb_data UNUSED) +{ + switch (fsmonitor_ipc__get_state()) { + case IPC_STATE__LISTENING: + return 0; + case IPC_STATE__NOT_LISTENING: + case IPC_STATE__PATH_NOT_FOUND: + return 1; + default: + case IPC_STATE__INVALID_PATH: + case IPC_STATE__OTHER_ERROR: + return -1; + } +} + static int spawn_daemon(void) { struct child_process cmd = CHILD_PROCESS_INIT; struct strbuf canonical_worktree = STRBUF_INIT; + enum start_bg_result result; + unsigned int timeout = get_start_timeout(); + const char *git = git_executable_path(); const char *worktree = repo_get_work_tree(the_repository); int ret = -1; @@ -83,15 +120,21 @@ static int spawn_daemon(void) prepare_spawn_env(&cmd.env); cmd.dir = canonical_worktree.buf; - cmd.git_cmd = 1; + if (git) + strvec_push(&cmd.args, git); + else + cmd.git_cmd = 1; cmd.no_stdin = 1; cmd.no_stdout = 1; cmd.no_stderr = 1; cmd.close_fd_above_stderr = 1; cmd.trace2_child_class = "fsmonitor"; - strvec_pushl(&cmd.args, "fsmonitor--daemon", "start", NULL); + strvec_pushl(&cmd.args, "fsmonitor--daemon", "run", "--detach", NULL); - ret = run_command(&cmd); + result = start_bg_command(&cmd, spawn_wait_cb, NULL, timeout); + if (result == SBGR_READY || + fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) + ret = 0; done: strbuf_release(&canonical_worktree); return ret; diff --git a/git.c b/git.c index e5f1811b6bb762..956a0c2e4d8f54 100644 --- a/git.c +++ b/git.c @@ -929,6 +929,9 @@ int cmd_main(int argc, const char **argv) if (slash) cmd = slash + 1; } + /* A renamed dispatcher is still safer to re-exec than a Git from PATH. */ + if (!starts_with(cmd, "git-")) + git_mark_executable_as_dispatcher(); trace_command_performance(argv); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 9f463a3f7a8dbd..9c96c0e3a6aee8 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -384,7 +384,8 @@ test_expect_success 'update-index implicitly starts daemon' ' # Confirm that the trace2 log contains a record of the # daemon starting. - test_subcommand git fsmonitor--daemon start <.git/trace_implicit_1 + test_grep "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/trace_implicit_1 ' test_expect_success 'status implicitly starts daemon' ' @@ -400,7 +401,8 @@ test_expect_success 'status implicitly starts daemon' ' # Confirm that the trace2 log contains a record of the # daemon starting. - test_subcommand git fsmonitor--daemon start <.git/trace_implicit_2 + test_grep "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/trace_implicit_2 ' edit_files () { @@ -978,7 +980,8 @@ test_expect_success "submodule absorbgitdirs implicitly starts daemon" ' # Confirm that the trace2 log contains a record of the # daemon starting. - test_subcommand git fsmonitor--daemon start "$TRASH_DIRECTORY/fake-git-used" + exit 1 + EOF + ( + cd same-executable-spawn && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_EXEC_PATH="$TRASH_DIRECTORY/fake-exec-path" \ + GIT_TRACE2_EVENT="$PWD/.git/spawn.trace" \ + "$GIT_BUILD_DIR/git" status --porcelain=v2 \ + >.git/actual && + test_must_be_empty .git/actual && + test_path_is_missing "$TRASH_DIRECTORY/fake-git-used" && + test_grep -F "\"argv\":[\"$GIT_BUILD_DIR/git\",\"fsmonitor--daemon\",\"run\",\"--detach\"]" \ + .git/spawn.trace && + git fsmonitor--daemon stop + ) +' + test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' test_when_finished " git -C reexec-linked-wt fsmonitor--daemon stop 2>/dev/null || : @@ -1408,8 +1435,8 @@ test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' git -C ../reexec-linked-wt status --porcelain=v2 \ >../reexec-linked.actual && test_must_be_empty ../reexec-linked.actual && - test_subcommand git fsmonitor--daemon start \ - <../reexec-linked.trace && + test_grep "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + ../reexec-linked.trace && test_grep \ "\"child_class\":\"fsmonitor\",\"cd\":\"$linked_worktree\"" \ ../reexec-linked.trace && From 7e4cba4d1e1443d4709491d4ae567ba9d25ec366 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 164/403] dir: verify cached excludes during UNTR preload Matching directory metadata alone cannot prove that its cached ignore rules are unchanged. A rewritten .gitignore with restored timestamps can otherwise leave preload results valid while changing which untracked paths should be visible. Snapshot each cached exclude object ID and validate its per-directory file on the existing preload workers. Open regular files with open_nofollow(), reject files larger than 1 MiB, and compare their raw or trailing-LF blob hash with the cached object ID. Verify the open file's stat identity before and after reading, then reopen its pathname and require the same identity through S01/P08. Publish directory results only when both stat and exclude checks match; otherwise invalidate the cached ignore state and fall back to ordinary traversal. A status test rewrites .gitignore, restores its mtime, and checks the result against uncached status. Signed-off-by: Taylor Blau --- dir.c | 99 +++++++++++++++++++++++++++++-- dir.h | 1 + t/t7063-status-untracked-cache.sh | 31 ++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/dir.c b/dir.c index 8ad94248770a39..1f923c52396830 100644 --- a/dir.c +++ b/dir.c @@ -19,6 +19,7 @@ #include "name-hash.h" #include "object-file.h" #include "path.h" +#include "path-namespace.h" #include "refs.h" #include "repository.h" #include "wildmatch.h" @@ -77,9 +78,11 @@ struct untracked_cache_preload_task { struct untracked_cache_dir *ucd; char *path; struct stat_data stat_data; + struct object_id exclude_oid; unsigned int was_valid : 1; unsigned int stat_checked : 1; unsigned int stat_matches : 1; + unsigned int exclude_matches : 1; unsigned int update_stat_data : 1; }; @@ -99,6 +102,7 @@ struct untracked_cache_preload { struct untracked_cache_preload_task *tasks; struct untracked_cache_preload_data *data; struct cache_time index_timestamp; + char *exclude_per_dir; size_t nr; int threads; unsigned int dir_flags; @@ -107,6 +111,10 @@ struct untracked_cache_preload { #define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 #define UNTRACKED_CACHE_PRELOAD_COST 1000 +#define UNTRACKED_CACHE_MAX_EXCLUDE_SIZE (1024 * 1024) + +static void invalidate_gitignore(struct untracked_cache *uc, + struct untracked_cache_dir *dir); static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat_data *sd, @@ -127,6 +135,7 @@ static void collect_untracked_cache_preload_tasks( (*tasks)[*nr].ucd = ucd; (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); (*tasks)[*nr].stat_data = ucd->stat_data; + oidcpy(&(*tasks)[*nr].exclude_oid, &ucd->exclude_oid); (*tasks)[*nr].was_valid = ucd->valid; (*nr)++; @@ -168,6 +177,63 @@ static int untracked_cache_auto_preload_worthwhile( UNTRACKED_CACHE_AUTO_PRELOAD_MIN_DIRS; } +static int exclude_path_matches_fd(const char *path, + const struct stat *expected) +{ + struct stat st; + int fd = open_nofollow(path, O_RDONLY); + int ret = fd >= 0 && !fstat(fd, &st) && S_ISREG(st.st_mode) && + path_namespace_stat_equal(expected, &st); + + if (fd >= 0) + close(fd); + return ret; +} + +static int cached_exclude_file_matches( + const struct git_hash_algo *algo, + const char *path, const struct object_id *cached_oid) +{ + struct object_id raw_oid, normalized_oid; + struct stat st, st_after; + char *buf; + size_t size; + int fd, ret = 0; + + fd = open_nofollow(path, O_RDONLY); + if (fd < 0) + return 0; + if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode) || st.st_size < 0 || + st.st_size > UNTRACKED_CACHE_MAX_EXCLUDE_SIZE) + goto out_close; + + size = xsize_t(st.st_size); + buf = xmallocz(size + 1); + if (read_in_full(fd, buf, size) != size) + goto out; + /* Prove both the opened file and its pathname stayed unchanged. */ + if (fstat(fd, &st_after) || + !path_namespace_stat_equal(&st, &st_after) || + !exclude_path_matches_fd(path, &st_after)) + goto out; + + /* add_patterns() may record either the blob or its LF-normalized form. */ + hash_object_file(algo, buf, size, OBJ_BLOB, &raw_oid); + if (oideq(&raw_oid, cached_oid)) { + ret = 1; + goto out; + } + buf[size] = '\n'; + hash_object_file(algo, buf, size + 1, OBJ_BLOB, + &normalized_oid); + ret = oideq(&normalized_oid, cached_oid); +out: + free(buf); +out_close: + close(fd); + return ret; +} + static struct untracked_cache_preload *untracked_cache_preload_start_1( struct index_state *istate, unsigned int dir_flags, int automatic) { @@ -187,6 +253,7 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( preload->uc = uc; preload->root = uc->root; preload->index_timestamp = istate->timestamp; + preload->exclude_per_dir = xstrdup_or_null(uc->exclude_per_dir); preload->dir_flags = dir_flags; collect_untracked_cache_preload_tasks( uc->root, &path, &preload->tasks, &preload->nr, &alloc); @@ -260,6 +327,7 @@ static void *preload_untracked_cache_thread(void *_data) for (i = data->offset; i < data->offset + data->nr; i++) { struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct strbuf exclude_path = STRBUF_INIT; struct stat st; if (!task->was_valid) @@ -273,10 +341,26 @@ static void *preload_untracked_cache_thread(void *_data) if (!match_untracked_dir_stat_racy( &preload->index_timestamp, &task->stat_data, &st)) { task->stat_matches = 1; + } else { + fill_stat_data(&task->stat_data, &st); + task->update_stat_data = 1; continue; } - fill_stat_data(&task->stat_data, &st); - task->update_stat_data = 1; + if (is_null_oid(&task->exclude_oid)) { + task->exclude_matches = 1; + continue; + } + if (!preload->exclude_per_dir) + continue; + if (strcmp(task->path, ".")) + strbuf_addstr(&exclude_path, task->path); + if (exclude_path.len) + strbuf_addch(&exclude_path, '/'); + strbuf_addstr(&exclude_path, preload->exclude_per_dir); + task->exclude_matches = cached_exclude_file_matches( + preload->repo->hash_algo, exclude_path.buf, + &task->exclude_oid); + strbuf_release(&exclude_path); } return NULL; } @@ -314,6 +398,7 @@ static void untracked_cache_preload_free( for (i = 0; i < preload->nr; i++) free(preload->tasks[i].path); free(preload->tasks); + free(preload->exclude_per_dir); free(preload); } @@ -340,6 +425,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, ucd->stat_checked = 0; ucd->stat_matches = 0; + ucd->exclude_matches = 0; /* Invalidation performed after the snapshot always wins. */ if (!task->was_valid || !ucd->valid) { valid = 0; @@ -347,10 +433,14 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, } ucd->stat_checked = task->stat_checked; ucd->stat_matches = task->stat_matches; - if (!task->stat_checked || !task->stat_matches) + ucd->exclude_matches = task->exclude_matches; + if (!task->stat_checked || !task->stat_matches || + !task->exclude_matches) valid = 0; if (task->update_stat_data) ucd->stat_data = task->stat_data; + if (task->stat_matches && !task->exclude_matches) + invalidate_gitignore(uc, ucd); } trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/valid", valid); @@ -2873,7 +2963,8 @@ static int valid_cached_dir(struct dir_struct *dir, if (!(dir->untracked->use_fsmonitor && untracked->valid)) { if (dir->internal.untracked_cache_preloaded && untracked->stat_checked) { - if (!untracked->valid || !untracked->stat_matches) + if (!untracked->valid || !untracked->stat_matches || + !untracked->exclude_matches) return 0; } else { if (lstat(path->len ? path->buf : ".", &st)) { diff --git a/dir.h b/dir.h index 631bdad2b2b845..60b63bbf304782 100644 --- a/dir.h +++ b/dir.h @@ -185,6 +185,7 @@ struct untracked_cache_dir { /* transient results from directory-stat preloading */ unsigned int stat_checked : 1; unsigned int stat_matches : 1; + unsigned int exclude_matches : 1; /* null object ID means this directory does not have .gitignore */ struct object_id exclude_oid; char name[FLEX_ARRAY]; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 9f7c5fee79d639..70cd5b7dfdd2d2 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1046,6 +1046,37 @@ test_expect_success 'automatic preload observes its directory threshold' ' .git/at-threshold.trace ) ' + +test_expect_success 'preload verifies cached per-directory excludes' ' + test_create_repo auto-exclude && + ( + cd auto-exclude && + test_write_lines hide-a >.gitignore && + test_write_lines tracked >tracked && + git add .gitignore tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + test_write_lines a >hide-a && + test_write_lines b >hide-b && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + avoid_racy && + mtime=$(test-tool chmtime --get .gitignore) && + test_write_lines hide-b >.gitignore && + test-tool chmtime =$mtime .gitignore && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain >.git/expect && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/actual.trace" \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "preload_untracked_cache/valid.*value.*0" \ + .git/actual.trace + ) +' + test_expect_success 'status preloads cached-directory validation' ' test_create_repo auto-preload && ( From a9f2c3e7b63a47899e1cfb1d9117683219fe2a75 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 10:32:32 -0700 Subject: [PATCH 165/403] path: snapshot external source namespaces An external attributes file can be missing, reached through a symbolic link, or redirected when an ancestor is replaced. Hashing its contents alone cannot distinguish stable absence from a changed containing namespace, or identical bytes reached through a different path. Extend the filesystem-identity primitives from S06/P03 to capture the lstat identity or absence of every component of an absolute path. Compare snapshots component by component and hash their explicit states and canonical identity fields with length-delimited framing. Reject capture with EAGAIN when the platform cannot report reliable object identity instead of hashing fabricated identity fields. Expose whether the final component exists and release all snapshot storage explicitly. Tests verify equal snapshots and hashes, a missing target that subsequently appears, and an ancestor replacement that changes the namespace even when the replacement has identical content. The snapshot is independently testable. It does not read an external attribute source or establish a status speedup. Signed-off-by: Taylor Blau --- path-namespace.c | 155 +++++++++++++++++++++++++++++ path-namespace.h | 11 ++ t/unit-tests/u-path-namespace.c | 171 ++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+) diff --git a/path-namespace.c b/path-namespace.c index 151634b886b663..14cc149c8ef188 100644 --- a/path-namespace.c +++ b/path-namespace.c @@ -1,5 +1,25 @@ #include "git-compat-util.h" +#include "abspath.h" +#include "hash.h" +#include "hash-framing.h" #include "path-namespace.h" +#include "strbuf.h" + +enum namespace_entry_state { + NAMESPACE_ENTRY_MISSING = 0, + NAMESPACE_ENTRY_PRESENT = 1, +}; + +struct stat_fingerprint { + struct path_stat_identity identity; + unsigned int state; +}; + +struct path_namespace_snapshot { + struct stat_fingerprint *entries; + size_t nr; + size_t alloc; +}; void path_stat_identity_init(struct path_stat_identity *identity, const struct stat *st) @@ -37,6 +57,133 @@ int path_stat_identity_equal(const struct path_stat_identity *a, return !memcmp(a, b, sizeof(*a)); } +static void stat_fingerprint_init(struct stat_fingerprint *fingerprint, + const struct stat *st) +{ + memset(fingerprint, 0, sizeof(*fingerprint)); + fingerprint->state = NAMESPACE_ENTRY_PRESENT; + path_stat_identity_init(&fingerprint->identity, st); + if (S_ISDIR(st->st_mode)) { + /* Unrelated children do not change which object a path names. */ + fingerprint->identity.fields[3] = 0; + fingerprint->identity.fields[6] = 0; + for (size_t i = 7; i <= 10; i++) + fingerprint->identity.fields[i] = 0; + } +} + +static int stat_fingerprint_equal(const struct stat_fingerprint *a, + const struct stat_fingerprint *b) +{ + return a->state == b->state && + path_stat_identity_equal(&a->identity, &b->identity); +} + +static int capture_entry(const char *path, + struct path_namespace_snapshot *snapshot) +{ + struct stat st; + struct stat_fingerprint *entry; + + ALLOC_GROW(snapshot->entries, snapshot->nr + 1, snapshot->alloc); + entry = &snapshot->entries[snapshot->nr++]; + memset(entry, 0, sizeof(*entry)); + if (!lstat(path, &st)) { + stat_fingerprint_init(entry, &st); + return 0; + } + if (errno == ENOENT || errno == ENOTDIR) { + entry->state = NAMESPACE_ENTRY_MISSING; + return 0; + } + return -1; +} + +int path_namespace_capture(const char *path, + struct path_namespace_snapshot **snapshot_out) +{ + struct path_namespace_snapshot *snapshot; + struct strbuf prefix = STRBUF_INIT; + size_t root_len, pos; + int ret = -1; + + if (!fstat_is_reliable()) { + errno = EAGAIN; + return -1; + } + + CALLOC_ARRAY(snapshot, 1); + root_len = offset_1st_component(path); + if (!root_len) + goto done; + strbuf_add(&prefix, path, root_len); + if (capture_entry(prefix.buf, snapshot)) + goto done; + pos = root_len; + while (path[pos]) { + size_t start, end; + + while (path[pos] && is_dir_sep(path[pos])) + pos++; + if (!path[pos]) + break; + start = pos; + while (path[pos] && !is_dir_sep(path[pos])) + pos++; + end = pos; + strbuf_complete(&prefix, '/'); + strbuf_add(&prefix, path + start, end - start); + if (capture_entry(prefix.buf, snapshot)) + goto done; + } + *snapshot_out = snapshot; + snapshot = NULL; + ret = 0; +done: + path_namespace_clear(snapshot); + strbuf_release(&prefix); + return ret; +} + +int path_namespace_equal(const struct path_namespace_snapshot *a, + const struct path_namespace_snapshot *b) +{ + if (a->nr != b->nr) + return 0; + for (size_t i = 0; i < a->nr; i++) + if (!stat_fingerprint_equal(&a->entries[i], &b->entries[i])) + return 0; + return 1; +} + +int path_namespace_target_present( + const struct path_namespace_snapshot *snapshot) +{ + return snapshot->nr && + snapshot->entries[snapshot->nr - 1].state == + NAMESPACE_ENTRY_PRESENT; +} + +void path_namespace_hash(struct git_hash_ctx *ctx, + const struct path_namespace_snapshot *snapshot) +{ + uint32_t value; + uint64_t field; + + put_be32(&value, snapshot->nr); + hash_length_delimited(ctx, &value, sizeof(value)); + for (size_t i = 0; i < snapshot->nr; i++) { + put_be32(&value, snapshot->entries[i].state); + hash_length_delimited(ctx, &value, sizeof(value)); + for (size_t j = 0; + j < ARRAY_SIZE(snapshot->entries[i].identity.fields); j++) { + put_be64(&field, + snapshot->entries[i].identity.fields[j]); + hash_length_delimited(ctx, &field, sizeof(field)); + } + } +} + int path_namespace_stat_equal(const struct stat *a, const struct stat *b) { struct path_stat_identity first, second; @@ -87,3 +234,11 @@ int path_namespace_reopen_component( errno = saved_errno; return -1; } + +void path_namespace_clear(struct path_namespace_snapshot *snapshot) +{ + if (!snapshot) + return; + free(snapshot->entries); + free(snapshot); +} diff --git a/path-namespace.h b/path-namespace.h index c26f4f12aebd48..16a607f94118f8 100644 --- a/path-namespace.h +++ b/path-namespace.h @@ -1,6 +1,8 @@ #ifndef PATH_NAMESPACE_H #define PATH_NAMESPACE_H +struct git_hash_ctx; +struct path_namespace_snapshot; struct stat; typedef int (*path_namespace_open_fn)(int dirfd, const char *path, int flags); @@ -15,9 +17,18 @@ void path_stat_identity_init(struct path_stat_identity *identity, const struct stat *st); int path_stat_identity_equal(const struct path_stat_identity *a, const struct path_stat_identity *b); +int path_namespace_capture(const char *path, + struct path_namespace_snapshot **snapshot_out); +int path_namespace_equal(const struct path_namespace_snapshot *a, + const struct path_namespace_snapshot *b); +int path_namespace_target_present( + const struct path_namespace_snapshot *snapshot); +void path_namespace_hash(struct git_hash_ctx *ctx, + const struct path_namespace_snapshot *snapshot); int path_namespace_stat_equal(const struct stat *a, const struct stat *b); int path_namespace_reopen_component( int parent_fd, const char *component, int flags, path_namespace_open_fn open_fn, const struct stat *expected); +void path_namespace_clear(struct path_namespace_snapshot *snapshot); #endif /* PATH_NAMESPACE_H */ diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c index 4e0d9dfab24d5d..3c80140a8bbf72 100644 --- a/t/unit-tests/u-path-namespace.c +++ b/t/unit-tests/u-path-namespace.c @@ -1,7 +1,11 @@ #include "unit-test.h" +#include "dir.h" +#include "hash.h" #include "path-namespace.h" +#include "strbuf.h" #include "tempfile.h" +#include "wrapper.h" #define ASSERT_STAT_FIELD_MATTERS(base, changed, field) do { \ (changed) = (base); \ @@ -115,3 +119,170 @@ void test_path_namespace__reopen_component(void) cl_must_pass(delete_tempfile(&first)); cl_must_pass(delete_tempfile(&second)); } + +static char *create_namespace(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/path-namespace.XXXXXX", tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void remove_namespace(char *path) +{ + struct strbuf root = STRBUF_INIT; + + strbuf_addstr(&root, path); + cl_must_pass(remove_dir_recursively(&root, 0)); + strbuf_release(&root); + free(path); +} + +static void hash_namespace(const struct path_namespace_snapshot *snapshot, + unsigned char *hash) +{ + struct git_hash_ctx ctx; + + git_hash_init(&ctx, &hash_algos[GIT_HASH_SHA1]); + path_namespace_hash(&ctx, snapshot); + git_hash_final(hash, &ctx); + git_hash_discard(&ctx); +} + +void test_path_namespace__equal_snapshots_have_equal_hashes(void) +{ + struct path_namespace_snapshot *first = NULL, *second = NULL; + struct strbuf directory = STRBUF_INIT, target = STRBUF_INIT; + unsigned char first_hash[GIT_MAX_RAWSZ], second_hash[GIT_MAX_RAWSZ]; + char *root; + + if (!fstat_is_reliable()) + cl_skip(); + root = create_namespace(); + + strbuf_addf(&directory, "%s/a", root); + cl_must_pass(mkdir(directory.buf, 0777)); + strbuf_addf(&target, "%s/target", directory.buf); + write_file(target.buf, "contents\n"); + + cl_must_pass(path_namespace_capture(target.buf, &first)); + cl_must_pass(path_namespace_capture(target.buf, &second)); + cl_assert(path_namespace_target_present(first)); + cl_assert(path_namespace_target_present(second)); + cl_assert(path_namespace_equal(first, second)); + hash_namespace(first, first_hash); + hash_namespace(second, second_hash); + cl_assert(!memcmp(first_hash, second_hash, + hash_algos[GIT_HASH_SHA1].rawsz)); + + path_namespace_clear(second); + path_namespace_clear(first); + strbuf_release(&target); + strbuf_release(&directory); + remove_namespace(root); +} + +void test_path_namespace__captures_missing_and_replaced_components(void) +{ + struct path_namespace_snapshot *missing = NULL, *created = NULL; + struct path_namespace_snapshot *replaced = NULL; + struct strbuf directory = STRBUF_INIT, old_directory = STRBUF_INIT; + struct strbuf target = STRBUF_INIT; + unsigned char missing_hash[GIT_MAX_RAWSZ], created_hash[GIT_MAX_RAWSZ]; + unsigned char replaced_hash[GIT_MAX_RAWSZ]; + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *root; + + if (!fstat_is_reliable()) { + cl_assert(path_namespace_capture(".", &missing) < 0); + cl_assert_equal_i(errno, EAGAIN); + return; + } + root = create_namespace(); + + strbuf_addf(&directory, "%s/a", root); + strbuf_addf(&old_directory, "%s/a-old", root); + cl_must_pass(mkdir(directory.buf, 0777)); + strbuf_addf(&target, "%s/target", directory.buf); + + cl_must_pass(path_namespace_capture(target.buf, &missing)); + cl_assert(!path_namespace_target_present(missing)); + hash_namespace(missing, missing_hash); + + write_file(target.buf, "contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &created)); + cl_assert(path_namespace_target_present(created)); + cl_assert(!path_namespace_equal(missing, created)); + hash_namespace(created, created_hash); + cl_assert(memcmp(missing_hash, created_hash, algo->rawsz)); + + cl_must_pass(rename(directory.buf, old_directory.buf)); + cl_must_pass(mkdir(directory.buf, 0777)); + write_file(target.buf, "contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &replaced)); + cl_assert(path_namespace_target_present(replaced)); + cl_assert(!path_namespace_equal(created, replaced)); + hash_namespace(replaced, replaced_hash); + cl_assert(memcmp(created_hash, replaced_hash, algo->rawsz)); + + path_namespace_clear(replaced); + path_namespace_clear(created); + path_namespace_clear(missing); + strbuf_release(&target); + strbuf_release(&old_directory); + strbuf_release(&directory); + remove_namespace(root); +} + +void test_path_namespace__unrelated_ancestor_entries_leave_target_unchanged(void) +{ + struct path_namespace_snapshot *first = NULL, *second = NULL; + struct path_namespace_snapshot *modified = NULL, *permissions = NULL; + struct strbuf directory = STRBUF_INIT, target = STRBUF_INIT; + struct strbuf unrelated = STRBUF_INIT; + unsigned char first_hash[GIT_MAX_RAWSZ], second_hash[GIT_MAX_RAWSZ]; + unsigned char modified_hash[GIT_MAX_RAWSZ]; + struct stat st; + char *root; + + if (!fstat_is_reliable()) + cl_skip(); + root = create_namespace(); + + strbuf_addf(&directory, "%s/a", root); + cl_must_pass(mkdir(directory.buf, 0777)); + strbuf_addf(&target, "%s/target", directory.buf); + write_file(target.buf, "contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &first)); + hash_namespace(first, first_hash); + + strbuf_addf(&unrelated, "%s/unrelated", root); + write_file(unrelated.buf, "unrelated\n"); + cl_must_pass(path_namespace_capture(target.buf, &second)); + hash_namespace(second, second_hash); + cl_assert(path_namespace_equal(first, second)); + cl_assert(!memcmp(first_hash, second_hash, + hash_algos[GIT_HASH_SHA1].rawsz)); + + write_file(target.buf, "changed contents\n"); + cl_must_pass(path_namespace_capture(target.buf, &modified)); + hash_namespace(modified, modified_hash); + cl_assert(!path_namespace_equal(second, modified)); + cl_assert(memcmp(second_hash, modified_hash, + hash_algos[GIT_HASH_SHA1].rawsz)); + + cl_must_pass(stat(directory.buf, &st)); + cl_must_pass(chmod(directory.buf, st.st_mode ^ S_IXGRP)); + cl_must_pass(path_namespace_capture(target.buf, &permissions)); + cl_assert(!path_namespace_equal(modified, permissions)); + + path_namespace_clear(permissions); + path_namespace_clear(modified); + path_namespace_clear(second); + path_namespace_clear(first); + strbuf_release(&unrelated); + strbuf_release(&target); + strbuf_release(&directory); + remove_namespace(root); +} From 02c31ccf10ab8a390554d2e6d2cf624cea3fb2ad Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:41:39 -0500 Subject: [PATCH 166/403] fsmonitor: ignore startup timeout configuration in daemon run The fsmonitor.startTimeout setting controls how long a client waits for daemon startup; the daemon's run subcommand does not consume it. Nevertheless, daemon configuration parsing validates that setting for every subcommand. A malformed value can consequently kill an implicitly started daemon before it opens its IPC socket. Pass a run-specific configuration flag into the callback and skip startup-timeout parsing only for run. Continue parsing other daemon settings normally, and preserve strict timeout validation for the explicit start subcommand. Add a macOS regression that verifies implicit status still starts the daemon with a malformed timeout while explicit daemon start rejects the same configuration. Signed-off-by: Taylor Blau --- builtin/fsmonitor--daemon.c | 20 +++++++++++++++++--- t/t7527-builtin-fsmonitor.sh | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 4161dd82825b4c..659ce0b2621010 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -42,9 +42,15 @@ static int fsmonitor__start_timeout_sec = 60; #define FSMONITOR__ANNOUNCE_STARTUP "fsmonitor.announcestartup" static int fsmonitor__announce_startup = 0; +struct fsmonitor_config_data { + unsigned int ignore_start_timeout : 1; +}; + static int fsmonitor_config(const char *var, const char *value, const struct config_context *ctx, void *cb) { + struct fsmonitor_config_data *data = cb; + if (!strcmp(var, FSMONITOR__IPC_THREADS)) { int i = git_config_int(var, value, ctx->kvi); if (i < 1) @@ -55,7 +61,12 @@ static int fsmonitor_config(const char *var, const char *value, } if (!strcmp(var, FSMONITOR__START_TIMEOUT)) { - int i = git_config_int(var, value, ctx->kvi); + int i; + + /* The run process does not consume this client-only setting. */ + if (data && data->ignore_start_timeout) + return 0; + i = git_config_int(var, value, ctx->kvi); if (i < 0) return error(_("value of '%s' out of range: %d"), FSMONITOR__START_TIMEOUT, i); @@ -73,7 +84,7 @@ static int fsmonitor_config(const char *var, const char *value, return 0; } - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } /* @@ -1570,6 +1581,9 @@ int cmd_fsmonitor__daemon(int argc, const char *prefix, struct repository *repo UNUSED) { + struct fsmonitor_config_data config_data = { + .ignore_start_timeout = argc > 1 && !strcmp(argv[1], "run"), + }; const char *subcmd; enum fsmonitor_reason reason; int detach_console = 0; @@ -1586,7 +1600,7 @@ int cmd_fsmonitor__daemon(int argc, OPT_END() }; - repo_config(the_repository, fsmonitor_config, NULL); + repo_config(the_repository, fsmonitor_config, &config_data); argc = parse_options(argc, argv, prefix, options, builtin_fsmonitor__daemon_usage, 0); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 9c96c0e3a6aee8..b02df8b5ce5cd3 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1445,4 +1445,22 @@ test_expect_success MACOS 'implicit daemon rediscovers a linked worktree' ' ) ' +test_expect_success MACOS 'implicit startup treats a bad timeout as best effort' ' + test_create_repo reexec-timeout && + ( + cd reexec-timeout && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + git config fsmonitor.starttimeout nonsense && + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + git config --unset fsmonitor.starttimeout && + git fsmonitor--daemon stop && + git config fsmonitor.starttimeout nonsense && + test_must_fail git fsmonitor--daemon start 2>.git/err && + test_grep "bad numeric config value" .git/err + ) +' + test_done From d36bd67127d3386544ec1686119974f68f0bd829 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:35:52 -0500 Subject: [PATCH 167/403] dir: rescan invalid collapsed UNTR witnesses In collapsed-directory mode, an untracked-cache parent may represent an entire directory by one descendant witness. If that witness becomes invalid or disappears, removing it without inspecting the directory can also hide another unvisited child that remains untracked. Compute cached validity from descendants upward after preload and invalidate collapsed ancestors when a required child proof fails. Before removing a stale collapsed witness, rescan its directory and retain the parent as untracked whenever another child survives. A focused untracked-cache test removes the cached witness while leaving a sibling present and verifies that status still reports the collapsed directory. Signed-off-by: Taylor Blau --- dir.c | 103 +++++++++++++++++++++++++++--- t/t7063-status-untracked-cache.sh | 31 +++++++++ 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/dir.c b/dir.c index 1f923c52396830..cda3dc208cdfd2 100644 --- a/dir.c +++ b/dir.c @@ -115,6 +115,8 @@ struct untracked_cache_preload { static void invalidate_gitignore(struct untracked_cache *uc, struct untracked_cache_dir *dir); +static void invalidate_directory(struct untracked_cache *uc, + struct untracked_cache_dir *dir); static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat_data *sd, @@ -365,6 +367,53 @@ static void *preload_untracked_cache_thread(void *_data) return NULL; } +static int untracked_cache_has_collapsed_child( + const struct untracked_cache_dir *parent, + const struct untracked_cache_dir *child) +{ + size_t i, len = strlen(child->name); + + for (i = 0; i < parent->untracked_nr; i++) { + const char *name = parent->untracked[i]; + + if (strlen(name) == len + 1 && name[len] == '/' && + !strncmp(name, child->name, len)) + return 1; + } + return 0; +} + +static int compute_untracked_cache_valid_recursive( + struct untracked_cache *uc, + struct untracked_cache_dir *ucd, + int invalidate_ancestors) +{ + size_t i; + int local_valid = ucd->valid && ucd->stat_checked && + ucd->stat_matches && ucd->exclude_matches; + int valid = local_valid; + int invalidate_self = !local_valid; + + for (i = 0; i < ucd->dirs_nr; i++) { + int child_valid = compute_untracked_cache_valid_recursive( + uc, ucd->dirs[i], invalidate_ancestors); + + if (!child_valid) { + valid = 0; + if (untracked_cache_has_collapsed_child(ucd, ucd->dirs[i])) + invalidate_self = 1; + } + } + if (invalidate_self && invalidate_ancestors) { + if (!local_valid && ucd->valid && ucd->stat_checked && + ucd->stat_matches && !ucd->exclude_matches) + invalidate_gitignore(uc, ucd); + else + invalidate_directory(uc, ucd); + } + return valid; +} + static void untracked_cache_preload_join( struct untracked_cache_preload *preload) { @@ -409,7 +458,6 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, struct untracked_cache *uc; size_t i; int applied = 0; - int valid = 1; if (!preload) return 0; @@ -427,23 +475,19 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, ucd->stat_matches = 0; ucd->exclude_matches = 0; /* Invalidation performed after the snapshot always wins. */ - if (!task->was_valid || !ucd->valid) { - valid = 0; + if (!task->was_valid || !ucd->valid) continue; - } ucd->stat_checked = task->stat_checked; ucd->stat_matches = task->stat_matches; ucd->exclude_matches = task->exclude_matches; - if (!task->stat_checked || !task->stat_matches || - !task->exclude_matches) - valid = 0; if (task->update_stat_data) ucd->stat_data = task->stat_data; - if (task->stat_matches && !task->exclude_matches) - invalidate_gitignore(uc, ucd); } trace2_data_intmax("dir", istate->repo, - "preload_untracked_cache/valid", valid); + "preload_untracked_cache/valid", + compute_untracked_cache_valid_recursive( + uc, preload->root, + dir_flags & DIR_SHOW_OTHER_DIRECTORIES)); applied = 1; done: trace2_data_intmax("dir", istate->repo, @@ -3063,6 +3107,28 @@ static int read_cached_dir(struct cached_dir *cdir) return -1; } +static void remove_collapsed_untracked_child( + struct untracked_cache *uc, + struct untracked_cache_dir *parent, + const struct untracked_cache_dir *child) +{ + size_t i, len = strlen(child->name); + + for (i = 0; i < parent->untracked_nr; i++) { + char *name = parent->untracked[i]; + + if (strlen(name) != len + 1 || name[len] != '/' || + strncmp(name, child->name, len)) + continue; + free(name); + MOVE_ARRAY(parent->untracked + i, parent->untracked + i + 1, + parent->untracked_nr - i - 1); + parent->untracked_nr--; + uc->dir_invalidated++; + return; + } +} + static void close_cached_dir(struct cached_dir *cdir) { if (cdir->fdir) @@ -3157,6 +3223,23 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, /* check how the file or directory should be treated */ state = treat_path(dir, untracked, &cdir, istate, &path, baselen, pathspec); + if (!cdir.d_name && cdir.ucd && cdir.ucd->check_only && + state < path_untracked && untracked && + untracked_cache_has_collapsed_child(untracked, cdir.ucd) && + (dir->flags & DIR_SHOW_OTHER_DIRECTORIES)) { + /* + * A collapsed directory retains one descendant as its + * untracked witness. Rescan before dropping a stale witness; + * an unvisited sibling may still make the directory untracked. + */ + invalidate_directory(dir->untracked, cdir.ucd); + state = read_directory_recursive( + dir, istate, path.buf, path.len, cdir.ucd, + 1, 0, pathspec); + if (state < path_untracked) + remove_collapsed_untracked_child( + dir->untracked, untracked, cdir.ucd); + } dir->internal.visited_paths++; if (state > dir_state) diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 70cd5b7dfdd2d2..5948a579b5783b 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1077,6 +1077,37 @@ test_expect_success 'preload verifies cached per-directory excludes' ' ) ' +test_expect_success 'recursive preload rescans a vanished collapsed witness' ' + test_create_repo collapsed-witness && + ( + cd collapsed-witness && + test_write_lines "*.ignored" >.gitignore && + git add .gitignore && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + for i in 00 01 + do + mkdir -p "scratch/d$i" && + test_write_lines "$i" >"scratch/d$i/file" || return 1 + done && + echo "?? scratch/" >.git/expect && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + test-tool dump-untracked-cache >.git/cache && + witness=$(sed -n \ + "s#^/scratch/\\(d[0-9][0-9]*\\)/ .*#\\1#p" \ + .git/cache | sed -n 1p) && + test -n "$witness" && + avoid_racy && + rm "scratch/$witness/file" && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual + ) +' + test_expect_success 'status preloads cached-directory validation' ' test_create_repo auto-preload && ( From 201d54346aefba82a963038816c61e1753117b4c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:47:10 -0700 Subject: [PATCH 168/403] attr: fingerprint external sources in stable namespaces An external attributes file can change conversion without changing a worktree attribute manifest. Content alone is also insufficient: an ancestor or linked target can be replaced, and a missing source is safe to reuse only while its containing namespace remains stable. Capture the normalized absolute-path namespace with S07/P09 before and after observing each enabled source. For a present source, require nonblocking-open support, a regular singly linked file below the attribute-file limit, and matching descriptor, pathname, and target identities. Read the entire file into one allocation. Record source configuration and contents in one framed digest, and component and target identities in a separate namespace digest. Recheck the complete namespace for stable missing sources. Enabled sources inherit the namespace capture's fail-closed identity check; disabled sources remain unobserved and safely digestible. Reject instability rather than publishing an incomplete fingerprint. Register the fingerprint library and Clar suite in both Make and Meson. Tests separate content from metadata changes, detect an altered ancestor of a missing source, preserve disabled-source digests, and exercise both object formats. This does not select repository attribute sources or integrate fingerprints into status. Signed-off-by: Taylor Blau --- Makefile | 2 + attr-fingerprint.c | 132 ++++++++++++++++++++++++++++++ attr-fingerprint.h | 21 +++++ meson.build | 1 + path-namespace.c | 12 +++ path-namespace.h | 2 + t/meson.build | 1 + t/unit-tests/u-attr-fingerprint.c | 127 ++++++++++++++++++++++++++++ 8 files changed, 298 insertions(+) create mode 100644 attr-fingerprint.c create mode 100644 attr-fingerprint.h create mode 100644 t/unit-tests/u-attr-fingerprint.c diff --git a/Makefile b/Makefile index 8bdaab2391f7b2..d3a4ee8b399294 100644 --- a/Makefile +++ b/Makefile @@ -1110,6 +1110,7 @@ LIB_OBJS += archive-tar.o LIB_OBJS += archive-zip.o LIB_OBJS += archive.o LIB_OBJS += attr.o +LIB_OBJS += attr-fingerprint.o LIB_OBJS += attr-manifest.o LIB_OBJS += base85.o LIB_OBJS += bisect.o @@ -1548,6 +1549,7 @@ THIRD_PARTY_SOURCES += sha1dc/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/% THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% +CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-clean-status-manifest diff --git a/attr-fingerprint.c b/attr-fingerprint.c new file mode 100644 index 00000000000000..ce21f0510032e9 --- /dev/null +++ b/attr-fingerprint.c @@ -0,0 +1,132 @@ +#include "git-compat-util.h" +#include "abspath.h" +#include "attr-fingerprint.h" +#include "attr.h" +#include "hash-framing.h" +#include "path-namespace.h" +#include "strbuf.h" +#include "wrapper.h" + +static int open_attr_source(const char *path) +{ +#ifdef O_NONBLOCK + return git_open_cloexec(path, O_RDONLY | O_NONBLOCK); +#else + (void)path; + errno = ENOSYS; + return -1; +#endif +} + +static int hash_source(struct git_hash_ctx *content_ctx, + struct git_hash_ctx *namespace_ctx, + const struct attr_fingerprint_source *source, + int *present) +{ + struct path_namespace_snapshot *before = NULL, *after = NULL; + struct stat opened_before, opened_after, named; + struct strbuf normalized = STRBUF_INIT; + char *absolute = NULL; + char *buf = NULL; + ssize_t got; + size_t size; + uint32_t state; + int fd = -1, ret = -1; + char extra; + + hash_optional_cstring(content_ctx, source->path); + hash_optional_cstring(namespace_ctx, source->path); + put_be32(&state, source->enabled); + hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(namespace_ctx, &state, sizeof(state)); + *present = 0; + if (!source->enabled || !source->path) + return 0; + + absolute = absolute_pathdup(source->path); + strbuf_addstr(&normalized, absolute); + if (strbuf_normalize_path(&normalized) || + path_namespace_capture(normalized.buf, &before)) + goto done; + *present = path_namespace_target_present(before); + if (!*present) { + if (path_namespace_capture(normalized.buf, &after) || + !path_namespace_equal(before, after)) + goto done; + state = 0; + hash_length_delimited(content_ctx, &state, sizeof(state)); + path_namespace_hash(namespace_ctx, before); + ret = 0; + goto done; + } + + fd = open_attr_source(normalized.buf); + if (fd < 0 || fstat(fd, &opened_before) || + !S_ISREG(opened_before.st_mode) || opened_before.st_nlink != 1 || + opened_before.st_size < 0 || + opened_before.st_size >= ATTR_MAX_FILE_SIZE) + goto done; + size = xsize_t(opened_before.st_size); + buf = xmalloc(size ? size : 1); + got = read_in_full(fd, buf, size); + if (got < 0 || (size_t)got != size || read(fd, &extra, 1) != 0 || + fstat(fd, &opened_after) || stat(normalized.buf, &named) || + !path_namespace_stat_equal(&opened_before, &opened_after) || + !path_namespace_stat_equal(&opened_after, &named) || + path_namespace_capture(normalized.buf, &after) || + !path_namespace_equal(before, after)) + goto done; + state = 1; + hash_length_delimited(content_ctx, &state, sizeof(state)); + path_namespace_hash(namespace_ctx, before); + path_namespace_hash_stat(namespace_ctx, &opened_after); + hash_length_delimited(content_ctx, buf, size); + ret = 0; +done: + if (fd >= 0) + close(fd); + free(buf); + free(absolute); + path_namespace_clear(before); + path_namespace_clear(after); + strbuf_release(&normalized); + return ret; +} + +static int fingerprint_sources( + const struct attr_fingerprint_source *sources, size_t nr, + const struct git_hash_algo *algo, struct attr_fingerprint *result) +{ + struct git_hash_ctx content_ctx, namespace_ctx; + uint32_t count; + + memset(result, 0, sizeof(*result)); + git_hash_init(&content_ctx, algo); + git_hash_init(&namespace_ctx, algo); + hash_optional_cstring(&content_ctx, "attribute-source-content-v1"); + hash_optional_cstring(&namespace_ctx, + "attribute-source-namespace-v1"); + if (nr > UINT32_MAX) + return -1; + put_be32(&count, nr); + hash_length_delimited(&content_ctx, &count, sizeof(count)); + hash_length_delimited(&namespace_ctx, &count, sizeof(count)); + for (size_t i = 0; i < nr; i++) { + int present; + + if (hash_source(&content_ctx, &namespace_ctx, &sources[i], + &present)) + return -1; + result->sources_present |= present; + } + git_hash_final(result->content_hash, &content_ctx); + git_hash_final(result->namespace_hash, &namespace_ctx); + return 0; +} + +int attr_fingerprint_sources( + const struct attr_fingerprint_source *sources, size_t nr, + const struct git_hash_algo *algo, struct attr_fingerprint *result) +{ + return fingerprint_sources(sources, nr, algo, result); +} diff --git a/attr-fingerprint.h b/attr-fingerprint.h new file mode 100644 index 00000000000000..7f3d4f0b7c1688 --- /dev/null +++ b/attr-fingerprint.h @@ -0,0 +1,21 @@ +#ifndef ATTR_FINGERPRINT_H +#define ATTR_FINGERPRINT_H + +#include "hash.h" + +struct attr_fingerprint_source { + const char *path; + unsigned int enabled : 1; +}; + +struct attr_fingerprint { + unsigned char content_hash[GIT_MAX_RAWSZ]; + unsigned char namespace_hash[GIT_MAX_RAWSZ]; + unsigned int sources_present : 1; +}; + +int attr_fingerprint_sources( + const struct attr_fingerprint_source *sources, size_t nr, + const struct git_hash_algo *algo, struct attr_fingerprint *result); + +#endif /* ATTR_FINGERPRINT_H */ diff --git a/meson.build b/meson.build index abc61a3c8bd6d4..ee646f1a998466 100644 --- a/meson.build +++ b/meson.build @@ -317,6 +317,7 @@ libgit_sources = [ 'archive-tar.c', 'archive-zip.c', 'archive.c', + 'attr-fingerprint.c', 'attr-manifest.c', 'attr.c', 'base85.c', diff --git a/path-namespace.c b/path-namespace.c index 14cc149c8ef188..533c8b53262899 100644 --- a/path-namespace.c +++ b/path-namespace.c @@ -184,6 +184,18 @@ void path_namespace_hash(struct git_hash_ctx *ctx, } } +void path_namespace_hash_stat(struct git_hash_ctx *ctx, const struct stat *st) +{ + struct path_stat_identity identity; + uint64_t field; + + path_stat_identity_init(&identity, st); + for (size_t i = 0; i < ARRAY_SIZE(identity.fields); i++) { + put_be64(&field, identity.fields[i]); + hash_length_delimited(ctx, &field, sizeof(field)); + } +} + int path_namespace_stat_equal(const struct stat *a, const struct stat *b) { struct path_stat_identity first, second; diff --git a/path-namespace.h b/path-namespace.h index 16a607f94118f8..d702b2570aae73 100644 --- a/path-namespace.h +++ b/path-namespace.h @@ -25,6 +25,8 @@ int path_namespace_target_present( const struct path_namespace_snapshot *snapshot); void path_namespace_hash(struct git_hash_ctx *ctx, const struct path_namespace_snapshot *snapshot); +void path_namespace_hash_stat(struct git_hash_ctx *ctx, + const struct stat *st); int path_namespace_stat_equal(const struct stat *a, const struct stat *b); int path_namespace_reopen_component( int parent_fd, const char *component, int flags, diff --git a/t/meson.build b/t/meson.build index d0ed8bb9461d07..0e733642e2c8a5 100644 --- a/t/meson.build +++ b/t/meson.build @@ -1,4 +1,5 @@ clar_test_suites = [ + 'unit-tests/u-attr-fingerprint.c', 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-clean-status-manifest.c', diff --git a/t/unit-tests/u-attr-fingerprint.c b/t/unit-tests/u-attr-fingerprint.c new file mode 100644 index 00000000000000..e7b61b687c788f --- /dev/null +++ b/t/unit-tests/u-attr-fingerprint.c @@ -0,0 +1,127 @@ +#include "unit-test.h" +#include "attr-fingerprint.h" +#include "dir.h" +#include "strbuf.h" +#include "wrapper.h" + +static char *create_directory(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/attr-fingerprint.XXXXXX", + tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void remove_directory(char *path) +{ + struct strbuf cleanup = STRBUF_INIT; + + strbuf_addstr(&cleanup, path); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + free(path); +} + +static void fingerprint(const char *path, int enabled, + const struct git_hash_algo *algo, + struct attr_fingerprint *result) +{ + struct attr_fingerprint_source source = { + .path = path, + .enabled = enabled, + }; + + cl_assert_equal_i(attr_fingerprint_sources( + &source, 1, algo, result), 0); +} + +void test_attr_fingerprint__separates_contents_from_namespace(void) +{ +#ifndef O_NONBLOCK + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *directory = create_directory(); + struct strbuf path = STRBUF_INIT; + struct attr_fingerprint initial, metadata, changed; + struct stat st; + + strbuf_addf(&path, "%s/attributes", directory); + write_file(path.buf, "*.txt text\n"); + fingerprint(path.buf, 1, algo, &initial); + cl_assert(initial.sources_present); + cl_assert_equal_i(stat(path.buf, &st), 0); + cl_assert_equal_i(chmod(path.buf, st.st_mode ^ S_IXUSR), 0); + fingerprint(path.buf, 1, algo, &metadata); + cl_assert(!memcmp(initial.content_hash, metadata.content_hash, + algo->rawsz)); + cl_assert(memcmp(initial.namespace_hash, metadata.namespace_hash, + algo->rawsz)); + write_file(path.buf, "*.txt -text\n"); + fingerprint(path.buf, 1, algo, &changed); + cl_assert(memcmp(metadata.content_hash, changed.content_hash, + algo->rawsz)); + + strbuf_release(&path); + remove_directory(directory); +#endif +} + +void test_attr_fingerprint__records_missing_parent_namespaces(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + char *directory; + struct strbuf path = STRBUF_INIT; + struct attr_fingerprint before, after; + struct stat st; + + if (!fstat_is_reliable()) { + struct attr_fingerprint_source source = { + .path = "missing/attributes", + .enabled = 1, + }; + + cl_assert(attr_fingerprint_sources( + &source, 1, algo, &before) < 0); + cl_assert_equal_i(errno, EAGAIN); + return; + } + directory = create_directory(); + + strbuf_addf(&path, "%s/missing/attributes", directory); + fingerprint(path.buf, 1, algo, &before); + cl_assert(!before.sources_present); + cl_assert_equal_i(stat(directory, &st), 0); + cl_assert_equal_i(chmod(directory, st.st_mode ^ S_IXGRP), 0); + fingerprint(path.buf, 1, algo, &after); + cl_assert(!after.sources_present); + cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); + cl_assert(memcmp(before.namespace_hash, after.namespace_hash, + algo->rawsz)); + + strbuf_release(&path); + remove_directory(directory); +} + +void test_attr_fingerprint__does_not_observe_disabled_sources(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *directory = create_directory(); + struct strbuf path = STRBUF_INIT; + struct attr_fingerprint before, after; + + strbuf_addf(&path, "%s/attributes", directory); + fingerprint(path.buf, 0, algo, &before); + write_file(path.buf, "*.txt text\n"); + fingerprint(path.buf, 0, algo, &after); + cl_assert(!before.sources_present); + cl_assert(!after.sources_present); + cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); + cl_assert(!memcmp(before.namespace_hash, after.namespace_hash, + algo->rawsz)); + + strbuf_release(&path); + remove_directory(directory); +} From e8acfe7199a3f1a0654f378ab831f38088199b0e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:19:27 -0700 Subject: [PATCH 169/403] fsmonitor: bind daemon queries to the canonical worktree root An fsmonitor socket is selected through the Git directory, so separate worktree paths can reach the same daemon when they share that directory. A client in the second worktree can then consume change history from a daemon that watches the first, incorrectly treating changed files in its own worktree as clean. Hash the canonical worktree path together with its device and inode, plus birth time and generation on Apple platforms. Cache the resulting 64-character SHA-256 identity in the daemon and attach it to every client query. Check the identity before interpreting the requested token; reject missing or mismatched bindings with a cookie-synchronized trivial response that forces the ordinary refresh path. The protocol change must also tolerate a daemon left running by an older Git. Such a daemon treats a bound query as an opaque token and can return a plausible trivial response. After that exact response, query an unbound capability command. If the daemon does not advertise query-v1, serialize replacement through a per-socket restart lock, stop it, and start the invoking Git executable before retrying the bound query. Keep quit, flush, and capability control commands unbound. Bound daemon lifecycle retries, and fail the query instead of trusting history when the root cannot be identified or an incompatible daemon cannot be replaced. Regression tests cover shared-gitdir worktree aliases, replacement of a legacy daemon, and acceptance of a daemon that advertises a capability superset. The replacement test also verifies that the next status neither refreshes tracked entries nor starts another daemon. Signed-off-by: Taylor Blau --- builtin/fsmonitor--daemon.c | 46 ++++++- fsmonitor--daemon.h | 1 + fsmonitor-ipc.c | 251 ++++++++++++++++++++++++++++++++--- fsmonitor-ipc.h | 9 ++ t/helper/test-simple-ipc.c | 54 ++++++++ t/t7527-builtin-fsmonitor.sh | 100 ++++++++++++++ 6 files changed, 437 insertions(+), 24 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 659ce0b2621010..953f68b4fc1185 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -705,18 +705,48 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, int do_trivial = 0; int do_flush = 0; int do_cookie = 0; + int invalid_binding = 0; enum fsmonitor_cookie_item_result cookie_result; + if (strcmp(command, "quit") && + strcmp(command, "flush") && + strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { + const char *identity; + const char *query; + + if (!skip_prefix(command, FSMONITOR_IPC_QUERY_PREFIX, + &identity) || + !(query = strchr(identity, '\n')) || + query - identity != FSMONITOR_IPC_WORKTREE_ID_HEX || + state->worktree_identity.len != FSMONITOR_IPC_WORKTREE_ID_HEX || + memcmp(identity, state->worktree_identity.buf, + FSMONITOR_IPC_WORKTREE_ID_HEX)) { + invalid_binding = 1; + trace2_data_intmax("fsmonitor", the_repository, + "query/worktree-mismatch", 1); + } else { + command = query + 1; + } + } + /* * We expect `command` to be of the form: * - * := quit NUL + * := get-capabilities NUL + * | quit NUL * | flush NUL * | NUL * | NUL */ - if (!strcmp(command, "quit")) { + if (!strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { + static const char capabilities[] = + FSMONITOR_IPC_QUERY_VERSION "\n"; + + return reply(reply_data, capabilities, + sizeof(capabilities) - 1); + + } else if (!strcmp(command, "quit")) { /* * A client has requested over the socket/pipe that the * daemon shutdown. @@ -739,6 +769,11 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, do_flush = 1; do_trivial = 1; + } else if (invalid_binding) { + /* Never trust a token from an unbound or different worktree. */ + do_trivial = 1; + do_cookie = 1; + } else if (!skip_prefix(command, "builtin:", &p)) { /* assume V1 timestamp or garbage */ @@ -1322,6 +1357,12 @@ static int fsmonitor_run_daemon(void) strbuf_init(&state.path_worktree_watch, 0); strbuf_addstr(&state.path_worktree_watch, absolute_path(repo_get_work_tree(the_repository))); + strbuf_init(&state.worktree_identity, 0); + if (fsmonitor_ipc__get_worktree_identity(the_repository, + &state.worktree_identity)) { + err = error(_("could not identify worktree root")); + goto done; + } state.nr_paths_watching = 1; strbuf_init(&state.alias.alias, 0); @@ -1448,6 +1489,7 @@ static int fsmonitor_run_daemon(void) ipc_server_free(state.ipc_server_data); strbuf_release(&state.path_worktree_watch); + strbuf_release(&state.worktree_identity); strbuf_release(&state.path_gitdir_watch); strbuf_release(&state.path_cookie_prefix); strbuf_release(&state.path_ipc); diff --git a/fsmonitor--daemon.h b/fsmonitor--daemon.h index 5cbbec8d940ba7..850188f872b783 100644 --- a/fsmonitor--daemon.h +++ b/fsmonitor--daemon.h @@ -40,6 +40,7 @@ struct fsmonitor_daemon_state { pthread_mutex_t main_lock; struct strbuf path_worktree_watch; + struct strbuf worktree_identity; struct strbuf path_gitdir_watch; struct alias_info alias; int nr_paths_watching; diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 8957091bfccbb2..f6eb03cfd9442f 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -6,6 +6,8 @@ #include "environment.h" #include "exec-cmd.h" #include "gettext.h" +#include "hash.h" +#include "lockfile.h" #include "parse.h" #include "simple-ipc.h" #include "fsmonitor-ipc.h" @@ -14,6 +16,46 @@ #include "strbuf.h" #include "trace2.h" +int fsmonitor_ipc__get_worktree_identity(struct repository *r, + struct strbuf *identity) +{ + static const char hex[] = "0123456789abcdef"; + struct strbuf canonical = STRBUF_INIT; + struct strbuf stable = STRBUF_INIT; + git_SHA256_CTX ctx; + unsigned char hash[GIT_SHA256_RAWSZ]; + struct stat st; + const char *worktree = repo_get_work_tree(r); + int ret = -1; + + if (!worktree || + !strbuf_realpath(&canonical, worktree, 0) || + stat(canonical.buf, &st)) + goto done; + strbuf_addf(&stable, "v1\n%"PRIuMAX":", (uintmax_t)canonical.len); + strbuf_addbuf(&stable, &canonical); + strbuf_addf(&stable, "\n%"PRIuMAX"\n%"PRIuMAX, + (uintmax_t)st.st_dev, (uintmax_t)st.st_ino); +#ifdef __APPLE__ + strbuf_addf(&stable, "\n%"PRIdMAX"\n%ld\n%"PRIu32, + (intmax_t)st.st_birthtimespec.tv_sec, + st.st_birthtimespec.tv_nsec, st.st_gen); +#endif + git_SHA256_Init(&ctx); + git_SHA256_Update(&ctx, stable.buf, stable.len); + git_SHA256_Final(hash, &ctx); + strbuf_reset(identity); + for (size_t i = 0; i < ARRAY_SIZE(hash); i++) { + strbuf_addch(identity, hex[hash[i] >> 4]); + strbuf_addch(identity, hex[hash[i] & 0xf]); + } + ret = 0; +done: + strbuf_release(&stable); + strbuf_release(&canonical); + return ret; +} + #ifndef HAVE_FSMONITOR_DAEMON_BACKEND /* @@ -73,6 +115,7 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) #define FSMONITOR_START_TIMEOUT_KEY "fsmonitor.starttimeout" #define FSMONITOR_START_TIMEOUT_DEFAULT 60 +#define FSMONITOR_RESTART_ATTEMPTS 3 static unsigned int get_start_timeout(void) { @@ -140,44 +183,221 @@ static int spawn_daemon(void) return ret; } +static int try_send_command(const char *command, struct strbuf *answer, + enum ipc_active_state *state_out) +{ + struct ipc_client_connection *connection = NULL; + struct ipc_client_connect_options options + = IPC_CLIENT_CONNECT_OPTIONS_INIT; + enum ipc_active_state state; + int ret = -1; + + strbuf_reset(answer); + options.wait_if_busy = 1; + options.wait_if_not_found = 0; + + state = ipc_client_try_connect(fsmonitor_ipc__get_path(the_repository), + &options, &connection); + if (state == IPC_STATE__LISTENING) { + ret = ipc_client_send_command_to_connection( + connection, command, strlen(command), answer); + ipc_client_close_connection(connection); + } + + if (state_out) + *state_out = state; + return ret; +} + +static int is_trivial_response(const struct strbuf *answer) +{ + const char *nul = memchr(answer->buf, '\0', answer->len); + + return nul && nul != answer->buf && + answer->len == (size_t)(nul - answer->buf) + 3 && + nul[1] == '/' && nul[2] == '\0'; +} + +static int has_capability(const struct strbuf *answer, + const char *capability) +{ + const char *p = answer->buf; + const char *end = answer->buf + answer->len; + size_t capability_len = strlen(capability); + + while (p < end) { + const char *eol = memchr(p, '\n', end - p); + const char *line_end = eol ? eol : end; + + if ((size_t)(line_end - p) == capability_len && + !memcmp(p, capability, capability_len)) + return 1; + if (!eol) + break; + p = eol + 1; + } + return 0; +} + +static int server_supports_bound_queries(void) +{ + struct strbuf answer = STRBUF_INIT; + int ret; + + ret = !try_send_command(FSMONITOR_IPC_CAPABILITY_COMMAND, + &answer, NULL) && + has_capability(&answer, FSMONITOR_IPC_QUERY_VERSION); + strbuf_release(&answer); + return ret; +} + +static int wait_for_daemon_exit(void) +{ + uintmax_t elapsed_ms = 0; + uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; + + while (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (elapsed_ms >= timeout_ms) + return -1; + sleep_millisec(50); + elapsed_ms += 50; + } + return 0; +} + +static int restart_incompatible_daemon(void) +{ + struct strbuf answer = STRBUF_INIT; + struct strbuf lock_path = STRBUF_INIT; + struct lock_file restart_lock = LOCK_INIT; + uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; + long lock_timeout_ms = timeout_ms > LONG_MAX ? + LONG_MAX : (long)timeout_ms; + int have_lock = 0; + int ret = -1; + + /* + * Serialize the re-probe, quit, wait, and spawn sequence. This uses a + * different lock from the one used briefly while binding the socket. + */ + strbuf_addf(&lock_path, "%s.restart", + fsmonitor_ipc__get_path(the_repository)); + if (hold_lock_file_for_update_timeout(&restart_lock, lock_path.buf, + LOCK_NO_DEREF, + lock_timeout_ms) < 0) { + if (server_supports_bound_queries()) + ret = 0; + goto done; + } + have_lock = 1; + + /* Another client may have replaced the daemon while we waited. */ + if (server_supports_bound_queries()) + goto success; + + trace2_data_intmax("fsm_client", NULL, + "query/incompatible-daemon", 1); + if (try_send_command("quit", &answer, NULL)) { + /* + * The connection state describes the failed attempt, not + * necessarily the state after the failure. Re-read it before + * deciding whether there is still a daemon to replace. + */ + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (server_supports_bound_queries()) + ret = 0; + goto done; + } + } + + if (wait_for_daemon_exit()) + goto done; + + /* + * A concurrent client may already have started a replacement. + * The retried bound query will verify its capability if needed. + */ + if (fsmonitor_ipc__get_state() != IPC_STATE__LISTENING && + spawn_daemon()) + goto done; + +success: + ret = 0; + +done: + if (have_lock) + rollback_lock_file(&restart_lock); + strbuf_release(&lock_path); + strbuf_release(&answer); + return ret; +} + int fsmonitor_ipc__send_query(const char *since_token, struct strbuf *answer) { + struct strbuf command = STRBUF_INIT; + struct strbuf identity = STRBUF_INIT; int ret = -1; - int tried_to_spawn = 0; + int lifecycle_attempts = 0; enum ipc_active_state state = IPC_STATE__OTHER_ERROR; struct ipc_client_connection *connection = NULL; struct ipc_client_connect_options options = IPC_CLIENT_CONNECT_OPTIONS_INIT; const char *tok = since_token ? since_token : ""; - size_t tok_len = since_token ? strlen(since_token) : 0; + + trace2_region_enter("fsm_client", "query", NULL); + if (fsmonitor_ipc__get_worktree_identity(the_repository, &identity)) { + trace2_data_intmax("fsm_client", NULL, + "query/worktree-identity-error", 1); + goto done; + } + strbuf_addstr(&command, FSMONITOR_IPC_QUERY_PREFIX); + strbuf_addbuf(&command, &identity); + strbuf_addch(&command, '\n'); + strbuf_addstr(&command, tok); options.wait_if_busy = 1; options.wait_if_not_found = 0; - trace2_region_enter("fsm_client", "query", NULL); trace2_data_string("fsm_client", NULL, "query/command", tok); try_again: + strbuf_reset(answer); state = ipc_client_try_connect(fsmonitor_ipc__get_path(the_repository), &options, &connection); switch (state) { case IPC_STATE__LISTENING: ret = ipc_client_send_command_to_connection( - connection, tok, tok_len, answer); + connection, command.buf, command.len, answer); ipc_client_close_connection(connection); + connection = NULL; trace2_data_intmax("fsm_client", NULL, "query/response-length", answer->len); + if (!ret && is_trivial_response(answer) && + !server_supports_bound_queries()) { + /* + * A daemon predating bound queries treats query-v1 as + * garbage and returns a valid trivial response. Never + * accept that unbound result. Replace the daemon with + * the invoking Git executable and retry instead. + */ + strbuf_reset(answer); + ret = -1; + if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS || + restart_incompatible_daemon()) + goto done; + options.wait_if_not_found = 1; + goto try_again; + } goto done; case IPC_STATE__NOT_LISTENING: case IPC_STATE__PATH_NOT_FOUND: - if (tried_to_spawn) + if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS) goto done; - tried_to_spawn++; if (spawn_daemon()) goto done; @@ -207,6 +427,8 @@ int fsmonitor_ipc__send_query(const char *since_token, done: trace2_region_leave("fsm_client", "query", NULL); + strbuf_release(&identity); + strbuf_release(&command); return ret; } @@ -214,30 +436,15 @@ int fsmonitor_ipc__send_query(const char *since_token, int fsmonitor_ipc__send_command(const char *command, struct strbuf *answer) { - struct ipc_client_connection *connection = NULL; - struct ipc_client_connect_options options - = IPC_CLIENT_CONNECT_OPTIONS_INIT; - int ret; enum ipc_active_state state; const char *c = command ? command : ""; - size_t c_len = command ? strlen(command) : 0; + int ret = try_send_command(c, answer, &state); - strbuf_reset(answer); - - options.wait_if_busy = 1; - options.wait_if_not_found = 0; - - state = ipc_client_try_connect(fsmonitor_ipc__get_path(the_repository), - &options, &connection); if (state != IPC_STATE__LISTENING) { die(_("fsmonitor--daemon is not running")); return -1; } - ret = ipc_client_send_command_to_connection(connection, c, c_len, - answer); - ipc_client_close_connection(connection); - if (ret == -1) { die(_("could not send '%s' command to fsmonitor--daemon"), c); return -1; diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index 8b489da762b047..006ee0750cf134 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -5,6 +5,15 @@ struct repository; +#define FSMONITOR_IPC_QUERY_VERSION "query-v1" +#define FSMONITOR_IPC_QUERY_PREFIX FSMONITOR_IPC_QUERY_VERSION " " +#define FSMONITOR_IPC_CAPABILITY_COMMAND "get-capabilities" +#define FSMONITOR_IPC_WORKTREE_ID_HEX 64 + +/* Hash the canonical worktree root and its stable filesystem identity. */ +int fsmonitor_ipc__get_worktree_identity(struct repository *r, + struct strbuf *identity); + /* * Returns true if built-in file system monitor daemon is defined * for this platform. diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index 442ad6b16f18d8..3be92e4fbd01ca 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -159,9 +159,40 @@ static int app__sendbytes_command(const char *received, size_t received_len, * data is handled properly. */ static int my_app_data = 42; +static int fsmonitor_legacy; +static int fsmonitor_capability_superset; static ipc_server_application_cb test_app_cb; +static int app__fsmonitor_capability_superset( + const char *command, size_t command_len, + ipc_server_reply_cb *reply_cb, + struct ipc_server_reply_data *reply_data) +{ + static const char capability_command[] = "get-capabilities"; + static const char capabilities[] = "query-v1\nquery-v2\n"; + static const char query_prefix[] = "query-v1 "; + static const char token[] = "builtin:test-capable:0"; + const char *query; + size_t query_len; + int ret; + + if (command_len == sizeof(capability_command) - 1 && + !memcmp(command, capability_command, command_len)) + return reply_cb(reply_data, capabilities, + sizeof(capabilities) - 1); + + query = memchr(command, '\n', command_len); + query_len = query ? command_len - (query + 1 - command) : 0; + ret = reply_cb(reply_data, token, sizeof(token)); + if (!ret && + (!starts_with(command, query_prefix) || + query_len != sizeof(token) - 1 || + memcmp(query + 1, token, query_len))) + ret = reply_cb(reply_data, "/", 2); + return ret; +} + /* * This is the "application callback" that sits on top of the * "ipc-server". It completely defines the set of commands supported @@ -201,6 +232,20 @@ static int test_app_cb(void *application_data, return SIMPLE_IPC_QUIT; } + if (fsmonitor_capability_superset) + return app__fsmonitor_capability_superset( + command, command_len, reply_cb, reply_data); + + if (fsmonitor_legacy) { + static const char token[] = "builtin:test-legacy:0"; + int ret; + + ret = reply_cb(reply_data, token, sizeof(token)); + if (!ret && !starts_with(command, "builtin:")) + ret = reply_cb(reply_data, "/", 2); + return ret; + } + if (command_len == 4 && !strncmp(command, "ping", 4)) { const char *answer = "pong"; return reply_cb(reply_data, answer, strlen(answer)); @@ -310,6 +355,10 @@ static int daemon__start_server(void) strvec_push(&cp.args, "run-daemon"); strvec_pushf(&cp.args, "--name=%s", cl_args.path); strvec_pushf(&cp.args, "--threads=%d", cl_args.nr_threads); + if (fsmonitor_legacy) + strvec_push(&cp.args, "--fsmonitor-legacy"); + if (fsmonitor_capability_superset) + strvec_push(&cp.args, "--fsmonitor-capability-superset"); cp.no_stdin = 1; cp.no_stdout = 1; @@ -602,6 +651,11 @@ int cmd__simple_ipc(int argc, const char **argv) OPT_INTEGER(0, "bytecount", &cl_args.bytecount, N_("number of bytes")), OPT_INTEGER(0, "batchsize", &cl_args.batchsize, N_("number of requests per thread")), + OPT_BOOL(0, "fsmonitor-legacy", &fsmonitor_legacy, + N_("emulate the legacy fsmonitor query protocol")), + OPT_BOOL(0, "fsmonitor-capability-superset", + &fsmonitor_capability_superset, + N_("advertise multiple fsmonitor query versions")), /* * The "byte" string here is not marked for translation and diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index b02df8b5ce5cd3..198aca8b1f5e14 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1463,4 +1463,104 @@ test_expect_success MACOS 'implicit startup treats a bad timeout as best effort' ) ' +test_expect_success 'bound query replaces a legacy daemon' ' + test_when_finished \ + "stop_daemon_delete_repo legacy-daemon-upgrade" && + test_create_repo legacy-daemon-upgrade && + ( + cd legacy-daemon-upgrade && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >/dev/null && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 --fsmonitor-legacy && + + GIT_TRACE2_EVENT="$PWD/.git/upgrade.trace" \ + git status >.git/upgrade.out && + test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/upgrade.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep ! "builtin:test-legacy:0" .git/fsmonitor && + test_grep \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/upgrade.trace && + + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git status >.git/warm.out && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <.git/warm.trace && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/warm.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/warm.trace + ) +' + +test_expect_success 'bound query accepts a capability superset' ' + test_when_finished \ + "stop_daemon_delete_repo capability-superset" && + test_create_repo capability-superset && + ( + cd capability-superset && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >/dev/null && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 \ + --fsmonitor-capability-superset && + + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/status.out && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep \ + "^fsmonitor last update builtin:test-capable:0" \ + .git/fsmonitor && + test_grep ! \ + "\"key\":\"query/incompatible-daemon\"" \ + .git/status.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/status.trace + ) +' + +test_expect_success MACOS 'worktree binding rejects same-gitdir aliases' ' + test_when_finished "git -C binding-a fsmonitor--daemon stop 2>/dev/null || :" && + git init --separate-git-dir="$PWD/binding-gitdir" binding-a && + mkdir binding-b && + cp binding-a/.git binding-b/.git && + ( + cd binding-a && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TRACE2_EVENT="$PWD/../binding-daemon.trace" \ + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null + ) && + cp binding-a/tracked binding-b/tracked && + echo changed >>binding-b/tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C binding-b \ + status --porcelain=v2 >binding.expect && + GIT_OPTIONAL_LOCKS=0 git -C binding-b \ + status --porcelain=v2 >binding.actual && + test_cmp binding.expect binding.actual && + test_grep "^1 \.M .* tracked$" binding.actual && + test_grep "\"key\":\"query/worktree-mismatch\",\"value\":\"1\"" \ + binding-daemon.trace && + git -C binding-a fsmonitor--daemon stop +' + test_done From 734087e3a6b8da8a91fffbfc2c5d765ebd2bd215 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:37:23 -0500 Subject: [PATCH 170/403] dir: skip recursively valid empty UNTR subtrees Even after every directory and ignore input has been validated, collapsed-directory traversal still reopens cached subtrees that are known to contain no untracked paths. That walk repeats work the successful preload has already established. Record recursive validation and whether each cached subtree contains untracked output. In collapsed-directory mode, skip reopening a subtree only when its directory, descendants, check-only mode, and ignore inputs remain valid and no cached untracked entry exists. Clear the recursive proof when directory or ignore state is invalidated. The untracked-cache status test verifies that an unchanged empty subtree visits no directories and that a changed descendant still falls back to traversal and reports the new untracked path. Signed-off-by: Taylor Blau --- dir.c | 27 ++++++++++++++++++++++++ dir.h | 3 +++ t/t7063-status-untracked-cache.sh | 34 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/dir.c b/dir.c index cda3dc208cdfd2..6670b5f4ff869b 100644 --- a/dir.c +++ b/dir.c @@ -411,6 +411,7 @@ static int compute_untracked_cache_valid_recursive( else invalidate_directory(uc, ucd); } + ucd->valid_recursive = valid; return valid; } @@ -474,6 +475,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, ucd->stat_checked = 0; ucd->stat_matches = 0; ucd->exclude_matches = 0; + ucd->valid_recursive = 0; /* Invalidation performed after the snapshot always wins. */ if (!task->was_valid || !ucd->valid) continue; @@ -1567,6 +1569,8 @@ static void do_invalidate_gitignore(struct untracked_cache_dir *dir) { int i; dir->valid = 0; + dir->valid_recursive = 0; + dir->has_untracked = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); dir->untracked_nr = 0; @@ -1596,6 +1600,7 @@ static void invalidate_directory(struct untracked_cache *uc, uc->dir_invalidated++; dir->valid = 0; + dir->valid_recursive = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); dir->untracked_nr = 0; @@ -2987,6 +2992,7 @@ static void add_untracked(struct untracked_cache_dir *dir, const char *name) ALLOC_GROW(dir->untracked, dir->untracked_nr + 1, dir->untracked_alloc); dir->untracked[dir->untracked_nr++] = xstrdup(name); + dir->has_untracked = 1; } static int valid_cached_dir(struct dir_struct *dir, @@ -3131,6 +3137,8 @@ static void remove_collapsed_untracked_child( static void close_cached_dir(struct cached_dir *cdir) { + int i; + if (cdir->fdir) closedir(cdir->fdir); /* @@ -3140,6 +3148,12 @@ static void close_cached_dir(struct cached_dir *cdir) if (cdir->untracked) { cdir->untracked->valid = 1; cdir->untracked->recurse = 1; + cdir->untracked->has_untracked = !!cdir->untracked->untracked_nr; + for (i = 0; !cdir->untracked->has_untracked && + i < cdir->untracked->dirs_nr; i++) + cdir->untracked->has_untracked = + cdir->untracked->dirs[i]->recurse && + cdir->untracked->dirs[i]->has_untracked; } } @@ -3211,6 +3225,14 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct strbuf path = STRBUF_INIT; strbuf_add(&path, base, baselen); + if (untracked && dir->internal.untracked_cache_preloaded && + untracked->valid && untracked->valid_recursive && + untracked->check_only == !!check_only && + !untracked->has_untracked && + (dir->flags & DIR_SHOW_OTHER_DIRECTORIES)) { + untracked->recurse = 1; + goto out; + } if (open_cached_dir(&cdir, dir, untracked, istate, &path, check_only)) goto out; @@ -4344,7 +4366,11 @@ static int read_one_dir(struct untracked_cache_dir **untracked_, for (i = 0; i < untracked->dirs_nr; i++) { if (read_one_dir(untracked->dirs + i, rd) < 0) return -1; + if (untracked->dirs[i]->has_untracked) + untracked->has_untracked = 1; } + if (untracked->untracked_nr) + untracked->has_untracked = 1; return 0; } @@ -4486,6 +4512,7 @@ static void invalidate_one_directory(struct untracked_cache *uc, { uc->dir_invalidated++; ucd->valid = 0; + ucd->valid_recursive = 0; for (size_t i = 0; i < ucd->untracked_nr; i++) free(ucd->untracked[i]); ucd->untracked_nr = 0; diff --git a/dir.h b/dir.h index 60b63bbf304782..717e96386ee06c 100644 --- a/dir.h +++ b/dir.h @@ -182,10 +182,13 @@ struct untracked_cache_dir { /* all data except 'dirs' in this struct are good */ unsigned int valid : 1; unsigned int recurse : 1; + /* this subtree contains at least one cached untracked entry */ + unsigned int has_untracked : 1; /* transient results from directory-stat preloading */ unsigned int stat_checked : 1; unsigned int stat_matches : 1; unsigned int exclude_matches : 1; + unsigned int valid_recursive : 1; /* null object ID means this directory does not have .gitignore */ struct object_id exclude_oid; char name[FLEX_ARRAY]; diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh index 5948a579b5783b..1cf25b5088284e 100755 --- a/t/t7063-status-untracked-cache.sh +++ b/t/t7063-status-untracked-cache.sh @@ -1077,6 +1077,40 @@ test_expect_success 'preload verifies cached per-directory excludes' ' ) ' +test_expect_success 'recursive preload checks descendant directory mtimes' ' + test_create_repo recursive-preload && + ( + cd recursive-preload && + mkdir -p a/b && + echo tracked >a/b/tracked && + git add a/b/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor false && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + avoid_racy && + git status --porcelain >/dev/null && + git status --porcelain >/dev/null && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/pruned.trace" \ + git status --porcelain >.git/pruned && + test_must_be_empty .git/pruned && + test_grep \ + "directories-visited.*value.*0" \ + .git/pruned.trace && + avoid_racy && + echo untracked >a/b/new-untracked && + GIT_OPTIONAL_LOCKS=0 git -c core.untrackedCache=false \ + status --porcelain >.git/expect && + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + git status --porcelain >.git/actual && + test_cmp .git/expect .git/actual + ) +' + test_expect_success 'recursive preload rescans a vanished collapsed witness' ' test_create_repo collapsed-witness && ( From e6213ce3fe6ab3d8f3c303cd2bfafcf2660e7c0d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:32 -0500 Subject: [PATCH 171/403] status: bind semantic configuration to its index A clean-status configuration digest cannot establish which index it describes while it remains detached from the repository and index that will consume it. External attribute content and namespace must also be recorded before an index can reuse conversion-dependent history. Attach a finalized, repository-bound digest at the beginning of do_read_index(), fingerprint the system, global, and info attribute sources, and store the resulting state on the index. Ignore an unfinalized digest, another repository's digest, and a second attachment. Release the state with release_index(). Extend the existing clean-status configuration unit suite to exercise repository binding, one-shot attachment, semantic and attribute hashes, unsafe-filter state, and index-lifetime cleanup. Register the new production object with both Make and Meson. Signed-off-by: Taylor Blau --- Makefile | 1 + attr-fingerprint.c | 35 ++++++++ attr-fingerprint.h | 4 + clean-status-internal.h | 25 ++++++ clean-status.c | 82 ++++++++++++++++++ clean-status.h | 17 ++++ meson.build | 1 + read-cache-ll.h | 2 + read-cache.c | 3 + t/unit-tests/u-clean-status-config.c | 123 +++++++++++++++++++++++++++ 10 files changed, 293 insertions(+) create mode 100644 clean-status-internal.h create mode 100644 clean-status.c create mode 100644 clean-status.h diff --git a/Makefile b/Makefile index 9ddaf95ee566e8..b1d8379fdfaa41 100644 --- a/Makefile +++ b/Makefile @@ -1125,6 +1125,7 @@ LIB_OBJS += cbtree.o LIB_OBJS += chdir-notify.o LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o +LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o diff --git a/attr-fingerprint.c b/attr-fingerprint.c index ce21f0510032e9..6a9cde2821614c 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -2,8 +2,11 @@ #include "abspath.h" #include "attr-fingerprint.h" #include "attr.h" +#include "environment.h" #include "hash-framing.h" +#include "path.h" #include "path-namespace.h" +#include "repository.h" #include "strbuf.h" #include "wrapper.h" @@ -130,3 +133,35 @@ int attr_fingerprint_sources( { return fingerprint_sources(sources, nr, algo, result); } + +static int repository_sources(struct repository *repo, + struct attr_fingerprint_source *sources, + char **info_attributes) +{ + if (getenv(GIT_ATTR_SOURCE_ENVIRONMENT)) + return -1; + sources[0].path = git_attr_system_file(); + sources[0].enabled = git_attr_system_is_enabled(); + sources[1].path = git_attr_global_file(); + sources[1].enabled = 1; + *info_attributes = repo_git_path(repo, INFOATTRIBUTES_FILE); + sources[2].path = *info_attributes; + sources[2].enabled = 1; + return 0; +} + +int attr_fingerprint_repository(struct repository *repo, + struct attr_fingerprint *result) +{ + struct attr_fingerprint_source sources[3]; + char *info_attributes = NULL; + int ret; + + memset(result, 0, sizeof(*result)); + if (repository_sources(repo, sources, &info_attributes)) + return -1; + ret = attr_fingerprint_sources(sources, ARRAY_SIZE(sources), + repo->hash_algo, result); + free(info_attributes); + return ret; +} diff --git a/attr-fingerprint.h b/attr-fingerprint.h index 7f3d4f0b7c1688..a159aa0697468c 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -3,6 +3,8 @@ #include "hash.h" +struct repository; + struct attr_fingerprint_source { const char *path; unsigned int enabled : 1; @@ -17,5 +19,7 @@ struct attr_fingerprint { int attr_fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, const struct git_hash_algo *algo, struct attr_fingerprint *result); +int attr_fingerprint_repository(struct repository *repo, + struct attr_fingerprint *result); #endif /* ATTR_FINGERPRINT_H */ diff --git a/clean-status-internal.h b/clean-status-internal.h new file mode 100644 index 00000000000000..4a04e20a18f612 --- /dev/null +++ b/clean-status-internal.h @@ -0,0 +1,25 @@ +#ifndef CLEAN_STATUS_INTERNAL_H +#define CLEAN_STATUS_INTERNAL_H + +#include "hash.h" + +struct index_state; + +struct clean_status_state { + unsigned char current_config_hash[GIT_MAX_RAWSZ]; + unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; + unsigned char current_attr_hash[GIT_MAX_RAWSZ]; + unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned current_config_valid : 1; + unsigned current_semantic_valid : 1; + unsigned current_attr_valid : 1; + unsigned current_semantic_explicit : 1; + unsigned current_attr_sources_present : 1; + unsigned config_enforced : 1; + unsigned filter_configured : 1; + unsigned filter_scope_valid : 1; +}; + +struct clean_status_state *clean_status_get_state(struct index_state *istate); + +#endif /* CLEAN_STATUS_INTERNAL_H */ diff --git a/clean-status.c b/clean-status.c new file mode 100644 index 00000000000000..c0cfb63c407428 --- /dev/null +++ b/clean-status.c @@ -0,0 +1,82 @@ +#include "git-compat-util.h" +#include "attr-fingerprint.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "read-cache-ll.h" +#include "repository.h" + +static struct repository *configured_repo; +static unsigned char configured_hash[GIT_MAX_RAWSZ]; +static unsigned char configured_semantic_hash[GIT_MAX_RAWSZ]; +static int configured_hash_valid; +static int configured_filter_configured; +static int configured_semantic_explicit; + +struct clean_status_state *clean_status_get_state(struct index_state *istate) +{ + if (!istate->clean_status) + CALLOC_ARRAY(istate->clean_status, 1); + return istate->clean_status; +} + +void clean_status_set_config_digest( + struct repository *repo, + const struct clean_status_config_digest *digest) +{ + configured_repo = repo; + configured_hash_valid = digest && digest->finalized; + configured_filter_configured = configured_hash_valid && + digest->filter_configured; + configured_semantic_explicit = configured_hash_valid && + digest->semantic_config_explicit; + if (!configured_hash_valid) + return; + memcpy(configured_hash, digest->hash, repo->hash_algo->rawsz); + memcpy(configured_semantic_hash, digest->semantic_hash, + repo->hash_algo->rawsz); +} + +void clean_status_attach_config(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + struct attr_fingerprint attrs; + + if (state && state->current_config_valid) + return; + if (!configured_hash_valid || configured_repo != istate->repo) + return; + state = clean_status_get_state(istate); + memcpy(state->current_config_hash, configured_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->current_semantic_hash, configured_semantic_hash, + istate->repo->hash_algo->rawsz); + state->current_config_valid = 1; + state->current_semantic_valid = 1; + state->current_semantic_explicit = configured_semantic_explicit; + state->config_enforced = 1; + state->filter_configured = configured_filter_configured; + if (!attr_fingerprint_repository(istate->repo, &attrs)) { + memcpy(state->current_attr_hash, attrs.content_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_namespace_hash, attrs.namespace_hash, + istate->repo->hash_algo->rawsz); + state->current_attr_valid = 1; + state->current_attr_sources_present = attrs.sources_present; + } +} + +int clean_status_filter_scope_needs_validation( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->current_config_valid && state->config_enforced && + state->filter_configured && !state->filter_scope_valid; +} + +void clean_status_release(struct index_state *istate) +{ + if (!istate->clean_status) + return; + FREE_AND_NULL(istate->clean_status); +} diff --git a/clean-status.h b/clean-status.h new file mode 100644 index 00000000000000..7a45d2f51c395e --- /dev/null +++ b/clean-status.h @@ -0,0 +1,17 @@ +#ifndef CLEAN_STATUS_H +#define CLEAN_STATUS_H + +#include "clean-status-config.h" + +struct index_state; +struct repository; + +void clean_status_set_config_digest( + struct repository *repo, + const struct clean_status_config_digest *digest); +void clean_status_attach_config(struct index_state *istate); +int clean_status_filter_scope_needs_validation( + const struct index_state *istate); +void clean_status_release(struct index_state *istate); + +#endif /* CLEAN_STATUS_H */ diff --git a/meson.build b/meson.build index ee646f1a998466..d3b433856cb165 100644 --- a/meson.build +++ b/meson.build @@ -333,6 +333,7 @@ libgit_sources = [ 'chdir-notify.c', 'checkout.c', 'chunk-format.c', + 'clean-status.c', 'clean-status-config.c', 'clean-status-manifest.c', 'color.c', diff --git a/read-cache-ll.h b/read-cache-ll.h index d44a946ed04d36..943ccc296a71d6 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -142,6 +142,7 @@ static inline unsigned create_ce_flags(unsigned stage) #define FSMONITOR_CHANGED (1 << 8) struct split_index; +struct clean_status_state; struct untracked_cache; struct progress; struct pattern_list; @@ -202,6 +203,7 @@ struct index_state { struct progress *progress; struct repository *repo; struct pattern_list *sparse_checkout_patterns; + struct clean_status_state *clean_status; }; /** diff --git a/read-cache.c b/read-cache.c index cf3600f2a337d6..2b2493b45aee97 100644 --- a/read-cache.c +++ b/read-cache.c @@ -16,6 +16,7 @@ #include "tempfile.h" #include "lockfile.h" #include "cache-tree.h" +#include "clean-status.h" #include "refs.h" #include "dir.h" #include "object-file.h" @@ -2241,6 +2242,7 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) int nr_threads, cpus; struct index_entry_offset_table *ieot = NULL; + clean_status_attach_config(istate); if (istate->initialized) return istate->cache_nr; @@ -2473,6 +2475,7 @@ void release_index(struct index_state *istate) free(istate->fsmonitor_last_update); free(istate->fsmonitor_last_update_pending); free(istate->fsmonitor_untracked_token); + clean_status_release(istate); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index cc88bb0680518c..74e40b205d85c0 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -1,6 +1,14 @@ #include "unit-test.h" +#include "attr-fingerprint.h" +#include "clean-status.h" #include "clean-status-config.h" +#include "clean-status-internal.h" #include "config.h" +#include "dir.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strbuf.h" +#include "wrapper.h" static void digest_one(struct clean_status_config_digest *digest, const char *key, const char *value, @@ -111,3 +119,118 @@ void test_clean_status_config__configured_filters_bump_proof_domains(void) cl_assert(hashes_equal(smudge.hash, smudge_full)); cl_assert(hashes_equal(smudge.semantic_hash, smudge_semantic)); } + +#if defined(O_NONBLOCK) && !defined(GIT_WINDOWS_NATIVE) +static char *create_gitdir(int with_attributes) +{ + const char *tmp = getenv("TMPDIR"); + char *gitdir = xstrfmt("%s/clean-status-config.XXXXXX", + tmp ? tmp : "/tmp"); + struct strbuf path = STRBUF_INIT; + + cl_assert(mkdtemp(gitdir) != NULL); + if (with_attributes) { + strbuf_addf(&path, "%s/info", gitdir); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_addstr(&path, "/attributes"); + write_file(path.buf, "*.txt text\n"); + } + strbuf_release(&path); + return gitdir; +} + +static void remove_gitdir(char *gitdir) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addstr(&path, gitdir); + cl_assert_equal_i(remove_dir_recursively(&path, 0), 0); + strbuf_release(&path); + free(gitdir); +} + +static void clear_staged_config(void *unused UNUSED) +{ + clean_status_set_config_digest(NULL, NULL); +} +#endif + +void test_clean_status_config__attaches_only_to_the_staged_repository(void) +{ +#if !defined(O_NONBLOCK) || defined(GIT_WINDOWS_NATIVE) + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *gitdir_a = create_gitdir(1); + char *gitdir_b = create_gitdir(0); + struct repository repo_a = { + .gitdir = gitdir_a, + .commondir = gitdir_a, + .hash_algo = algo, + }; + struct repository repo_b = { + .gitdir = gitdir_b, + .commondir = gitdir_b, + .hash_algo = algo, + }; + struct index_state istate_a = INDEX_STATE_INIT(&repo_a); + struct index_state istate_b = INDEX_STATE_INIT(&repo_b); + struct clean_status_config_digest digest, replacement; + struct clean_status_state *state; + struct attr_fingerprint attrs; + + cl_set_cleanup(clear_staged_config, NULL); + digest_one(&digest, "filter.demo.clean", "cat", NULL); + digest_one(&replacement, "core.autocrlf", "false", NULL); + cl_assert_equal_i(attr_fingerprint_repository(&repo_a, &attrs), 0); + cl_assert(attrs.sources_present); + + clean_status_set_config_digest(&repo_a, &digest); + clean_status_attach_config(&istate_b); + cl_assert_equal_p(istate_b.clean_status, NULL); + clean_status_attach_config(&istate_a); + state = istate_a.clean_status; + cl_assert(state != NULL); + cl_assert(state->current_config_valid); + cl_assert(state->current_semantic_valid); + cl_assert(state->current_attr_valid); + cl_assert(state->config_enforced); + cl_assert(state->filter_configured); + cl_assert(!state->filter_scope_valid); + cl_assert(state->current_semantic_explicit); + cl_assert_equal_i(state->current_attr_sources_present, + attrs.sources_present); + cl_assert(hashes_equal(state->current_config_hash, digest.hash)); + cl_assert(hashes_equal(state->current_semantic_hash, + digest.semantic_hash)); + cl_assert(!memcmp(state->current_attr_hash, attrs.content_hash, + algo->rawsz)); + cl_assert(!memcmp(state->current_attr_namespace_hash, + attrs.namespace_hash, algo->rawsz)); + + clean_status_set_config_digest(&repo_a, &replacement); + clean_status_attach_config(&istate_a); + cl_assert(hashes_equal(state->current_config_hash, digest.hash)); + cl_assert(hashes_equal(state->current_semantic_hash, + digest.semantic_hash)); + cl_assert(state->filter_configured); + cl_assert(!state->filter_scope_valid); + + release_index(&istate_a); + cl_assert_equal_p(istate_a.clean_status, NULL); + release_index(&istate_b); + clear_staged_config(NULL); + if (repo_a.config) { + git_configset_clear(repo_a.config); + FREE_AND_NULL(repo_a.config); + } + if (repo_b.config) { + git_configset_clear(repo_b.config); + FREE_AND_NULL(repo_b.config); + } + repo_settings_clear(&repo_a); + repo_settings_clear(&repo_b); + remove_gitdir(gitdir_b); + remove_gitdir(gitdir_a); +#endif +} From e33973b6f6b8188485f14773a0c99137824a1dd4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:36 -0500 Subject: [PATCH 172/403] commit: stage semantic configuration before reading the index Index attachment cannot recover the configuration seen by git status or git commit if their callbacks finish without recording it. A separate configuration pass could also bind a different stream from the one that established the commands' existing behavior. Wrap each existing status or commit callback so the original callback and clean-status digest consume the same key, value, and context. Finalize and stage the digest after the existing configuration pass and before either command reads its index. Preserve determine_whence(), advice_enabled(), the original callback, configuration order, and option handling. The index-owned attachment and its existing configuration unit coverage are supplied by S08/P01; this patch adds no command-specific regression. Signed-off-by: Taylor Blau --- builtin/commit.c | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index e2f4d08b347707..29f339f89a2254 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -13,6 +13,7 @@ #include "config.h" #include "lockfile.h" #include "cache-tree.h" +#include "clean-status.h" #include "color.h" #include "dir.h" #include "editor.h" @@ -205,11 +206,34 @@ static void determine_whence(struct wt_status *s) s->whence = whence; } -static void status_init_config(struct wt_status *s, config_fn_t fn) +struct status_config_callback_data { + struct wt_status *status; + config_fn_t fn; + struct clean_status_config_digest *clean_digest; +}; + +static int status_config_callback(const char *key, const char *value, + const struct config_context *ctx, void *cb) +{ + struct status_config_callback_data *data = cb; + + clean_status_config_add(data->clean_digest, key, value, ctx); + return data->fn(key, value, ctx, data->status); +} + +static void status_init_config_with_clean_digest( + struct wt_status *s, config_fn_t fn, + struct clean_status_config_digest *clean_digest) { + struct status_config_callback_data data = { + .status = s, + .fn = fn, + .clean_digest = clean_digest, + }; + wt_status_prepare(the_repository, s); init_diff_ui_defaults(); - repo_config(the_repository, fn, s); + repo_config(the_repository, status_config_callback, &data); determine_whence(s); s->hints = advice_enabled(ADVICE_STATUS_HINTS); /* must come after repo_config() */ } @@ -1542,6 +1566,7 @@ struct repository *repo UNUSED) static int no_renames = -1; static const char *rename_score_arg = (const char *)-1; static struct wt_status s; + struct clean_status_config_digest clean_digest; unsigned int progress_flag = 0; int fd; struct object_id oid; @@ -1605,7 +1630,11 @@ struct repository *repo UNUSED) prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; - status_init_config(&s, git_status_config); + clean_status_config_init(&clean_digest, the_repository->hash_algo); + status_init_config_with_clean_digest( + &s, git_status_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); argc = parse_options(argc, argv, prefix, builtin_status_options, builtin_status_usage, 0); @@ -1703,6 +1732,7 @@ int cmd_commit(int argc, struct repository *repo UNUSED) { static struct wt_status s; + struct clean_status_config_digest clean_digest; static const char *cleanup_arg = NULL; static struct option builtin_commit_options[] = { OPT__QUIET(&quiet, N_("suppress summary after successful commit")), @@ -1807,7 +1837,11 @@ int cmd_commit(int argc, prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; - status_init_config(&s, git_commit_config); + clean_status_config_init(&clean_digest, the_repository->hash_algo); + status_init_config_with_clean_digest( + &s, git_commit_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); s.commit_template = 1; status_format = STATUS_FORMAT_NONE; /* Ignore status.short */ s.colopts = 0; From 2ab4b0fb07fd86b43ed015191fe32972ce324db7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:46:34 -0500 Subject: [PATCH 173/403] status: classify durable index source identities An index with a null trailing checksum cannot be bound to the file that was actually read unless the platform supplies a durable file identity. Treating a directory, multiply linked file, or unsupported platform as equivalent would turn identity comparison into an unwarranted correctness guarantee. Add clean_status_identity_from_stat() for single-link regular files and make clean_status_identity_is_durable() return true only on Apple platforms. Keep unsupported platforms explicitly ineligible instead of inferring durability from stat fields alone. Register the identity object and its unit suite with Make and Meson. The tests reject directories and multiply linked files, accept a single-link regular file, and check the appropriate platform result. Actual null-checksum index verification remains a separate change. Signed-off-by: Taylor Blau --- Makefile | 2 ++ clean-status-identity.c | 21 +++++++++++++++++++++ clean-status-identity.h | 16 ++++++++++++++++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-identity.c | 26 ++++++++++++++++++++++++++ 6 files changed, 67 insertions(+) create mode 100644 clean-status-identity.c create mode 100644 clean-status-identity.h create mode 100644 t/unit-tests/u-clean-status-identity.c diff --git a/Makefile b/Makefile index b1d8379fdfaa41..ce5d750fb0dd09 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o LIB_OBJS += column.o @@ -1553,6 +1554,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config +CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir diff --git a/clean-status-identity.c b/clean-status-identity.c new file mode 100644 index 00000000000000..1d4c71116d6600 --- /dev/null +++ b/clean-status-identity.c @@ -0,0 +1,21 @@ +#include "git-compat-util.h" +#include "clean-status-identity.h" + +int clean_status_identity_from_stat(struct clean_status_identity *identity, + const struct stat *st) +{ + memset(identity, 0, sizeof(*identity)); + if (!S_ISREG(st->st_mode) || st->st_nlink != 1) + return -1; + path_stat_identity_init(&identity->stat, st); + return 0; +} + +int clean_status_identity_is_durable(void) +{ +#ifdef __APPLE__ + return 1; +#else + return 0; +#endif +} diff --git a/clean-status-identity.h b/clean-status-identity.h new file mode 100644 index 00000000000000..b0453effcdcb91 --- /dev/null +++ b/clean-status-identity.h @@ -0,0 +1,16 @@ +#ifndef CLEAN_STATUS_IDENTITY_H +#define CLEAN_STATUS_IDENTITY_H + +#include "path-namespace.h" + +struct stat; + +struct clean_status_identity { + struct path_stat_identity stat; +}; + +int clean_status_identity_from_stat(struct clean_status_identity *identity, + const struct stat *st); +int clean_status_identity_is_durable(void); + +#endif /* CLEAN_STATUS_IDENTITY_H */ diff --git a/meson.build b/meson.build index d3b433856cb165..8f34aafa0fe276 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-identity.c', 'clean-status-manifest.c', 'color.c', 'column.c', diff --git a/t/meson.build b/t/meson.build index 7b764958d084d4..832001d876b22a 100644 --- a/t/meson.build +++ b/t/meson.build @@ -2,6 +2,7 @@ clar_test_suites = [ 'unit-tests/u-attr-fingerprint.c', 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', + 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', diff --git a/t/unit-tests/u-clean-status-identity.c b/t/unit-tests/u-clean-status-identity.c new file mode 100644 index 00000000000000..33e7b80fcfc7e7 --- /dev/null +++ b/t/unit-tests/u-clean-status-identity.c @@ -0,0 +1,26 @@ +#include "unit-test.h" +#include "clean-status-identity.h" + +void test_clean_status_identity__requires_a_single_link_regular_file(void) +{ + struct clean_status_identity identity; + struct stat st = { 0 }; + + st.st_mode = S_IFDIR | 0755; + st.st_nlink = 1; + cl_assert_equal_i(clean_status_identity_from_stat(&identity, &st), -1); + st.st_mode = S_IFREG | 0644; + st.st_nlink = 2; + cl_assert_equal_i(clean_status_identity_from_stat(&identity, &st), -1); + st.st_nlink = 1; + cl_assert_equal_i(clean_status_identity_from_stat(&identity, &st), 0); +} + +void test_clean_status_identity__durability_is_platform_specific(void) +{ +#ifdef __APPLE__ + cl_assert(clean_status_identity_is_durable()); +#else + cl_assert(!clean_status_identity_is_durable()); +#endif +} From af4ec9b3bf9ed4c3de200c628c0e614386eff5b4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 23:15:36 -0700 Subject: [PATCH 174/403] read-cache: bind null-checksum indexes to their source file With index.skipHash enabled, a null trailing checksum cannot prove that verify_index_from() reopened the index that do_read_index() parsed. Replacing the pathname between those operations can otherwise make an unread index appear valid. Record the identity from the index reader's existing fstat() result. When verifying a null-checksum index on an Apple platform, compare it with the identity from the verifier's existing file observation. Reject an absent, nonregular, multiply linked, or replaced identity. Leave checksummed indexes and platforms without durable identities on their existing paths. Reuse the identity classification from S08/P03 without adding an index-read system call. Register the new object and unit suite with Make and Meson; the unit test replaces the index pathname and checks the unsupported fallback. Signed-off-by: Taylor Blau --- Makefile | 2 ++ clean-status-identity.c | 6 ++++ clean-status-identity.h | 2 ++ clean-status-index.c | 29 +++++++++++++++++++ clean-status-internal.h | 3 ++ clean-status.h | 5 ++++ meson.build | 1 + read-cache.c | 4 +++ t/meson.build | 1 + t/unit-tests/u-clean-status-index.c | 43 +++++++++++++++++++++++++++++ 10 files changed, 96 insertions(+) create mode 100644 clean-status-index.c create mode 100644 t/unit-tests/u-clean-status-index.c diff --git a/Makefile b/Makefile index ce5d750fb0dd09..400b9168333827 100644 --- a/Makefile +++ b/Makefile @@ -1128,6 +1128,7 @@ LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o LIB_OBJS += clean-status-identity.o +LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += color.o LIB_OBJS += column.o @@ -1555,6 +1556,7 @@ CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-clean-status-identity +CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir diff --git a/clean-status-identity.c b/clean-status-identity.c index 1d4c71116d6600..415ecab19a64f2 100644 --- a/clean-status-identity.c +++ b/clean-status-identity.c @@ -19,3 +19,9 @@ int clean_status_identity_is_durable(void) return 0; #endif } + +int clean_status_identity_equal(const struct clean_status_identity *a, + const struct clean_status_identity *b) +{ + return path_stat_identity_equal(&a->stat, &b->stat); +} diff --git a/clean-status-identity.h b/clean-status-identity.h index b0453effcdcb91..68e459a0349a1f 100644 --- a/clean-status-identity.h +++ b/clean-status-identity.h @@ -12,5 +12,7 @@ struct clean_status_identity { int clean_status_identity_from_stat(struct clean_status_identity *identity, const struct stat *st); int clean_status_identity_is_durable(void); +int clean_status_identity_equal(const struct clean_status_identity *a, + const struct clean_status_identity *b); #endif /* CLEAN_STATUS_IDENTITY_H */ diff --git a/clean-status-index.c b/clean-status-index.c new file mode 100644 index 00000000000000..4733e53e3a9fcc --- /dev/null +++ b/clean-status-index.c @@ -0,0 +1,29 @@ +#include "git-compat-util.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "read-cache-ll.h" + +void clean_status_record_source_identity(struct index_state *istate, + const struct stat *st) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || state->source_identity_valid || + !clean_status_identity_is_durable()) + return; + if (!clean_status_identity_from_stat(&state->source_identity, st)) + state->source_identity_valid = 1; +} + +int clean_status_verify_null_index(const struct index_state *istate, + const struct stat *st) +{ + const struct clean_status_state *state = istate->clean_status; + struct clean_status_identity identity; + + if (!state || !clean_status_identity_is_durable()) + return 1; + return state->source_identity_valid && + !clean_status_identity_from_stat(&identity, st) && + clean_status_identity_equal(&identity, &state->source_identity); +} diff --git a/clean-status-internal.h b/clean-status-internal.h index 4a04e20a18f612..9eed823928f80a 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -1,11 +1,13 @@ #ifndef CLEAN_STATUS_INTERNAL_H #define CLEAN_STATUS_INTERNAL_H +#include "clean-status-identity.h" #include "hash.h" struct index_state; struct clean_status_state { + struct clean_status_identity source_identity; unsigned char current_config_hash[GIT_MAX_RAWSZ]; unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_hash[GIT_MAX_RAWSZ]; @@ -18,6 +20,7 @@ struct clean_status_state { unsigned config_enforced : 1; unsigned filter_configured : 1; unsigned filter_scope_valid : 1; + unsigned source_identity_valid : 1; }; struct clean_status_state *clean_status_get_state(struct index_state *istate); diff --git a/clean-status.h b/clean-status.h index 7a45d2f51c395e..6054c1011da494 100644 --- a/clean-status.h +++ b/clean-status.h @@ -5,6 +5,7 @@ struct index_state; struct repository; +struct stat; void clean_status_set_config_digest( struct repository *repo, @@ -12,6 +13,10 @@ void clean_status_set_config_digest( void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); +void clean_status_record_source_identity(struct index_state *istate, + const struct stat *st); +int clean_status_verify_null_index(const struct index_state *istate, + const struct stat *st); void clean_status_release(struct index_state *istate); #endif /* CLEAN_STATUS_H */ diff --git a/meson.build b/meson.build index 8f34aafa0fe276..ca2c44295b3e5a 100644 --- a/meson.build +++ b/meson.build @@ -336,6 +336,7 @@ libgit_sources = [ 'clean-status.c', 'clean-status-config.c', 'clean-status-identity.c', + 'clean-status-index.c', 'clean-status-manifest.c', 'color.c', 'column.c', diff --git a/read-cache.c b/read-cache.c index 2b2493b45aee97..e58b29bdd142d4 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2260,6 +2260,7 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) if (fstat(fd, &st)) die_errno(_("%s: cannot stat the open index"), path); + clean_status_record_source_identity(istate, &st); mmap_size = xsize_t(st.st_size); if (mmap_size < sizeof(struct cache_header) + the_hash_algo->rawsz) @@ -2749,6 +2750,9 @@ static int verify_index_from(const struct index_state *istate, const char *path) if (st.st_size < sizeof(struct cache_header) + the_hash_algo->rawsz) goto out; + if (is_null_oid(&istate->oid) && + !clean_status_verify_null_index(istate, &st)) + goto out; n = pread_in_full(fd, hash, the_hash_algo->rawsz, st.st_size - the_hash_algo->rawsz); if (n != the_hash_algo->rawsz) diff --git a/t/meson.build b/t/meson.build index 832001d876b22a..45243e9949d383 100644 --- a/t/meson.build +++ b/t/meson.build @@ -3,6 +3,7 @@ clar_test_suites = [ 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-clean-status-identity.c', + 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c new file mode 100644 index 00000000000000..5d769692eea894 --- /dev/null +++ b/t/unit-tests/u-clean-status-index.c @@ -0,0 +1,43 @@ +#include "unit-test.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "dir.h" +#include "read-cache-ll.h" +#include "strbuf.h" +#include "wrapper.h" + +void test_clean_status_index__binds_the_parsed_source(void) +{ + const char *tmp = getenv("TMPDIR"); + char *worktree = xstrfmt("%s/status-source.XXXXXX", + tmp ? tmp : "/tmp"); + struct index_state istate = { 0 }; + struct strbuf path = STRBUF_INIT, replacement = STRBUF_INIT; + struct strbuf cleanup = STRBUF_INIT; + struct stat original, current; + + cl_assert(mkdtemp(worktree) != NULL); + strbuf_addf(&path, "%s/index", worktree); + strbuf_addf(&replacement, "%s/replacement", worktree); + write_file(path.buf, "original"); + write_file(replacement.buf, "replacement"); + cl_assert_equal_i(stat(path.buf, &original), 0); + clean_status_get_state(&istate); + clean_status_record_source_identity(&istate, &original); + cl_assert(clean_status_verify_null_index(&istate, &original)); + + cl_assert_equal_i(rename(replacement.buf, path.buf), 0); + cl_assert_equal_i(stat(path.buf, ¤t), 0); + if (clean_status_identity_is_durable()) + cl_assert(!clean_status_verify_null_index(&istate, ¤t)); + else + cl_assert(clean_status_verify_null_index(&istate, ¤t)); + + clean_status_release(&istate); + strbuf_addstr(&cleanup, worktree); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + strbuf_release(&replacement); + strbuf_release(&path); + free(worktree); +} From a0ec2c815e03c4f41fe52520574e452f2bb99178 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:32 -0500 Subject: [PATCH 175/403] read-cache: validate persisted fsmonitor semantic history A filesystem-monitor token does not establish that saved configuration, conversion rules, attribute inputs, or their complete manifest still describe the current index. Accepting duplicate, stale, or partially bound history could let status trust cached worktree state under different semantics. Recognize the FSCF index extension and delegate malformed-record rejection to the bounded clean-proof parser from S07/P07. Publish its token, configuration and semantic hashes, attribute hash, and manifest only after the complete record validates. Reject duplicate records, and adopt a manifest only when the current token, hashes, complete proof flags, and filter policy all agree. Record stronger semantic mismatches and withhold incoherent history. Integrate validation into post_read_index_from(), release all owned record and manifest storage with the index, and document the extension layout. Register the history object and unit suite with Make and Meson. A SHA-1 fixture rejects duplicate records; a SHA-256 fixture accepts coherent history and detects a changed semantic hash. Signed-off-by: Taylor Blau --- Documentation/gitformat-index.adoc | 30 +++++++ Makefile | 2 + clean-status-history.c | 113 ++++++++++++++++++++++++ clean-status-internal.h | 18 +++- clean-status.c | 9 +- clean-status.h | 5 ++ meson.build | 1 + read-cache.c | 5 ++ t/meson.build | 1 + t/unit-tests/u-clean-status-history.c | 120 ++++++++++++++++++++++++++ 10 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 clean-status-history.c create mode 100644 t/unit-tests/u-clean-status-history.c diff --git a/Documentation/gitformat-index.adoc b/Documentation/gitformat-index.adoc index aaa9c29b4653b8..047310ec26d105 100644 --- a/Documentation/gitformat-index.adoc +++ b/Documentation/gitformat-index.adoc @@ -379,6 +379,36 @@ The remaining data of each directory block is grouped by type: - A NUL-terminated string containing the opaque file system monitor token associated with the untracked-cache data. +== File System Monitor semantic proof + + The file system monitor semantic proof records the configuration and + attribute inputs for a completed worktree-content verification. Its + signature is { 'F', 'S', 'C', 'F' }. + + The extension consists of: + + - 32-bit version number (currently 1). + + - 32-bit magic number identifying version 1 records (`FSC1`). + + - 32-bit flags. The low four bits respectively indicate a complete + attribute manifest, a provider-token binding, a stat-data binding, and + coverage of the full index. All other bits must be zero. + + - 32-bit length of the provider token. + + - 32-bit length of the attribute manifest. + + - The provider token, without a terminating NUL. + + - Three hashes, using the index hash algorithm, over the relevant Git + configuration, semantic-conversion configuration, and attribute state. + + - The attribute manifest described by its length above. + + - A hash over all preceding bytes in this extension, using the index hash + algorithm. + == End of Index Entry The End of Index Entry (EOIE) is used to locate the end of the variable diff --git a/Makefile b/Makefile index 400b9168333827..cde58e8bd4728f 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o @@ -1555,6 +1556,7 @@ THIRD_PARTY_SOURCES += $(UNIT_TEST_DIR)/clar/clar/% CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config +CLAR_TEST_SUITES += u-clean-status-history CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest diff --git a/clean-status-history.c b/clean-status-history.c new file mode 100644 index 00000000000000..f77f4294e74af3 --- /dev/null +++ b/clean-status-history.c @@ -0,0 +1,113 @@ +#include "git-compat-util.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strbuf.h" +#include "trace2.h" + +static void invalidate_disk_history(struct clean_status_state *state) +{ + state->disk_config_seen = 1; + state->disk_config_invalid = 1; + state->disk_config_valid = 0; + state->disk_semantic_valid = 0; + state->disk_attr_valid = 0; + FREE_AND_NULL(state->disk_config_token); + strbuf_reset(&state->disk_config_raw); + state->manifest.disk_valid = 0; + state->manifest.disk_flags = 0; + strbuf_reset(&state->manifest.disk); +} + +int clean_status_read_fsmonitor_config(struct index_state *istate, + const void *data, unsigned long size) +{ + struct clean_status_state *state = clean_status_get_state(istate); + struct fsmonitor_clean_proof proof; + + if (state->disk_config_seen || + fsmonitor_clean_proof_parse(&proof, data, size, + istate->repo->hash_algo) || + clean_status_manifest_load(&state->manifest, + proof.attr_manifest, + proof.attr_manifest_len, + proof.flags, + istate->repo->hash_algo)) { + invalidate_disk_history(state); + trace2_data_intmax("fsmonitor", istate->repo, + "config/invalid-extension", 1); + return 0; + } + + state->disk_config_seen = 1; + state->disk_config_token = xmemdupz(proof.token, proof.token_len); + memcpy(state->disk_config_hash, proof.config_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->disk_semantic_hash, proof.semantic_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->disk_attr_hash, proof.attr_hash, + istate->repo->hash_algo->rawsz); + strbuf_add(&state->disk_config_raw, data, size); + state->disk_config_valid = 1; + state->disk_semantic_valid = 1; + state->disk_attr_valid = 1; + return 0; +} + +void clean_status_prepare_fsmonitor_config(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + int token_coherent, config_coherent, semantic_changed, attr_changed; + int coherent; + + if (!state || !state->current_config_valid) + return; + token_coherent = state->disk_config_valid && + !state->disk_config_invalid && istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && state->disk_config_token && + !strcmp(state->disk_config_token, istate->fsmonitor_last_update); + config_coherent = state->disk_config_valid && + !memcmp(state->disk_config_hash, state->current_config_hash, + algo->rawsz); + semantic_changed = state->disk_semantic_valid && + state->current_semantic_valid && + memcmp(state->disk_semantic_hash, state->current_semantic_hash, + algo->rawsz); + attr_changed = (state->disk_attr_valid && !state->current_attr_valid) || + (state->disk_attr_valid && state->current_attr_valid && + memcmp(state->disk_attr_hash, state->current_attr_hash, + algo->rawsz)); + coherent = token_coherent && config_coherent && + state->disk_semantic_valid && state->current_semantic_valid && + !semantic_changed && state->disk_attr_valid && + state->current_attr_valid && !attr_changed && + state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL; + state->filter_scope_valid = coherent && state->filter_configured; + state->config_revalidated = coherent; + state->initial_coherent = coherent; + FREE_AND_NULL(state->config_revalidated_token); + if (coherent) { + state->config_revalidated_token = + xstrdup(istate->fsmonitor_last_update); + clean_status_manifest_adopt_disk(&state->manifest); + } + state->config_mismatch = state->config_enforced && !coherent; + state->strong_mismatch = state->config_enforced && + (state->disk_config_invalid || + semantic_changed || attr_changed || + (state->disk_config_valid && !state->current_attr_valid) || + (!state->disk_semantic_valid && + state->current_semantic_explicit) || + (!state->disk_attr_valid && + state->current_attr_sources_present) || + clean_status_filter_scope_needs_validation(istate)); + trace2_data_intmax("fsmonitor", istate->repo, + "config/coherent", coherent); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/initial-mismatch", state->strong_mismatch); +} diff --git a/clean-status-internal.h b/clean-status-internal.h index 9eed823928f80a..d4868d989b6cb1 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -2,16 +2,23 @@ #define CLEAN_STATUS_INTERNAL_H #include "clean-status-identity.h" -#include "hash.h" +#include "clean-status-manifest.h" struct index_state; struct clean_status_state { struct clean_status_identity source_identity; + struct clean_status_manifest_state manifest; + struct strbuf disk_config_raw; + char *disk_config_token; + char *config_revalidated_token; unsigned char current_config_hash[GIT_MAX_RAWSZ]; + unsigned char disk_config_hash[GIT_MAX_RAWSZ]; unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; + unsigned char disk_semantic_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned char disk_attr_hash[GIT_MAX_RAWSZ]; unsigned current_config_valid : 1; unsigned current_semantic_valid : 1; unsigned current_attr_valid : 1; @@ -20,7 +27,16 @@ struct clean_status_state { unsigned config_enforced : 1; unsigned filter_configured : 1; unsigned filter_scope_valid : 1; + unsigned config_mismatch : 1; + unsigned strong_mismatch : 1; + unsigned config_revalidated : 1; + unsigned initial_coherent : 1; unsigned source_identity_valid : 1; + unsigned disk_config_valid : 1; + unsigned disk_semantic_valid : 1; + unsigned disk_attr_valid : 1; + unsigned disk_config_seen : 1; + unsigned disk_config_invalid : 1; }; struct clean_status_state *clean_status_get_state(struct index_state *istate); diff --git a/clean-status.c b/clean-status.c index c0cfb63c407428..f1c61c4e345f80 100644 --- a/clean-status.c +++ b/clean-status.c @@ -14,8 +14,11 @@ static int configured_semantic_explicit; struct clean_status_state *clean_status_get_state(struct index_state *istate) { - if (!istate->clean_status) + if (!istate->clean_status) { CALLOC_ARRAY(istate->clean_status, 1); + clean_status_manifest_init(&istate->clean_status->manifest); + strbuf_init(&istate->clean_status->disk_config_raw, 0); + } return istate->clean_status; } @@ -78,5 +81,9 @@ void clean_status_release(struct index_state *istate) { if (!istate->clean_status) return; + clean_status_manifest_release(&istate->clean_status->manifest); + strbuf_release(&istate->clean_status->disk_config_raw); + free(istate->clean_status->disk_config_token); + free(istate->clean_status->config_revalidated_token); FREE_AND_NULL(istate->clean_status); } diff --git a/clean-status.h b/clean-status.h index 6054c1011da494..82ee75f8c52304 100644 --- a/clean-status.h +++ b/clean-status.h @@ -17,6 +17,11 @@ void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st); + +int clean_status_read_fsmonitor_config(struct index_state *istate, + const void *data, unsigned long size); +void clean_status_prepare_fsmonitor_config(struct index_state *istate); + void clean_status_release(struct index_state *istate); #endif /* CLEAN_STATUS_H */ diff --git a/meson.build b/meson.build index ca2c44295b3e5a..59b7097fca049d 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-history.c', 'clean-status-identity.c', 'clean-status-index.c', 'clean-status-manifest.c', diff --git a/read-cache.c b/read-cache.c index e58b29bdd142d4..7e769d3e4ddb5f 100644 --- a/read-cache.c +++ b/read-cache.c @@ -72,6 +72,7 @@ #define CACHE_EXT_LINK 0x6c696e6b /* "link" */ #define CACHE_EXT_UNTRACKED 0x554E5452 /* "UNTR" */ #define CACHE_EXT_FSMONITOR 0x46534D4E /* "FSMN" */ +#define CACHE_EXT_FSMONITOR_CONFIG 0x46534346 /* "FSCF" */ #define CACHE_EXT_FSMONITOR_UNTRACKED 0x46535543 /* "FSUC" */ #define CACHE_EXT_ENDOFINDEXENTRIES 0x454F4945 /* "EOIE" */ #define CACHE_EXT_INDEXENTRYOFFSETTABLE 0x49454F54 /* "IEOT" */ @@ -1779,6 +1780,9 @@ static int read_index_extension(struct index_state *istate, case CACHE_EXT_FSMONITOR: read_fsmonitor_extension(istate, data, sz); break; + case CACHE_EXT_FSMONITOR_CONFIG: + clean_status_read_fsmonitor_config(istate, data, sz); + break; case CACHE_EXT_FSMONITOR_UNTRACKED: read_fsmonitor_untracked_extension(istate, data, sz); break; @@ -1984,6 +1988,7 @@ static void post_read_index_from(struct index_state *istate) tweak_untracked_cache(istate); tweak_split_index(istate); prepare_fsmonitor_untracked(istate); + clean_status_prepare_fsmonitor_config(istate); tweak_fsmonitor(istate); } diff --git a/t/meson.build b/t/meson.build index 45243e9949d383..e4131eac00a60e 100644 --- a/t/meson.build +++ b/t/meson.build @@ -2,6 +2,7 @@ clar_test_suites = [ 'unit-tests/u-attr-fingerprint.c', 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', + 'unit-tests/u-clean-status-history.c', 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c new file mode 100644 index 00000000000000..49795b05545ecf --- /dev/null +++ b/t/unit-tests/u-clean-status-history.c @@ -0,0 +1,120 @@ +#include "unit-test.h" +#include "attr-manifest.h" +#include "clean-status.h" +#include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strbuf.h" + +struct history_fixture { + struct repository repo; + struct index_state istate; + struct strbuf manifest; + struct strbuf encoded; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char attr_hash[GIT_MAX_RAWSZ]; +}; + +static void fixture_init(struct history_fixture *fixture, + const struct git_hash_algo *algo) +{ + static const unsigned char token[] = "builtin:1:2"; + struct attr_manifest_writer writer; + struct fsmonitor_clean_proof proof; + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(fixture, 0, sizeof(*fixture)); + fixture->repo.hash_algo = algo; + index_state_init(&fixture->istate, &fixture->repo); + fixture->manifest = (struct strbuf)STRBUF_INIT; + fixture->encoded = (struct strbuf)STRBUF_INIT; + memset(hash, 1, algo->rawsz); + memset(fixture->config_hash, 2, algo->rawsz); + memset(fixture->semantic_hash, 3, algo->rawsz); + memset(fixture->attr_hash, 4, algo->rawsz); + attr_manifest_writer_init(&writer, &fixture->manifest, algo); + cl_assert_equal_i(attr_manifest_writer_add( + &writer, ".gitattributes", ATTR_MANIFEST_INDEX, hash), 0); + memset(&proof, 0, sizeof(proof)); + proof.flags = FSMONITOR_CLEAN_PROOF_ALL; + proof.token = token; + proof.token_len = sizeof(token) - 1; + proof.config_hash = fixture->config_hash; + proof.semantic_hash = fixture->semantic_hash; + proof.attr_hash = fixture->attr_hash; + proof.attr_manifest = (const unsigned char *)fixture->manifest.buf; + proof.attr_manifest_len = fixture->manifest.len; + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture->encoded, &proof, algo), 0); +} + +static void fixture_release(struct history_fixture *fixture) +{ + clean_status_release(&fixture->istate); + free(fixture->istate.fsmonitor_last_update); + strbuf_release(&fixture->encoded); + strbuf_release(&fixture->manifest); +} + +static struct clean_status_state *install_current( + struct history_fixture *fixture) +{ + struct clean_status_state *state = + clean_status_get_state(&fixture->istate); + const struct git_hash_algo *algo = fixture->repo.hash_algo; + + memcpy(state->current_config_hash, fixture->config_hash, algo->rawsz); + memcpy(state->current_semantic_hash, fixture->semantic_hash, algo->rawsz); + memcpy(state->current_attr_hash, fixture->attr_hash, algo->rawsz); + state->current_config_valid = 1; + state->current_semantic_valid = 1; + state->current_attr_valid = 1; + state->config_enforced = 1; + fixture->istate.fsmonitor_last_update = xstrdup("builtin:1:2"); + fixture->istate.fsmonitor_token_valid = 1; + return state; +} + +void test_clean_status_history__reads_valid_history_once(void) +{ + struct history_fixture fixture; + struct clean_status_state *state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA1]); + cl_assert_equal_i(clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len), 0); + state = fixture.istate.clean_status; + cl_assert(state->disk_config_valid); + cl_assert(state->manifest.disk_valid); + cl_assert_equal_s(state->disk_config_token, "builtin:1:2"); + + cl_assert_equal_i(clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len), 0); + cl_assert(state->disk_config_invalid); + cl_assert(!state->disk_config_valid); + cl_assert(!state->manifest.disk_valid); + fixture_release(&fixture); +} + +void test_clean_status_history__adopts_only_coherent_proofs(void) +{ + struct history_fixture fixture; + struct clean_status_state *state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA256]); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(state->initial_coherent); + cl_assert(state->manifest.current_valid); + cl_assert_equal_i(state->manifest.current.len, fixture.manifest.len); + + state->current_semantic_hash[0] ^= 1; + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(state->strong_mismatch); + cl_assert(!state->initial_coherent); + fixture_release(&fixture); +} From 7ea95101b88162c79a4d44ac1349781205bd03ea Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:44:02 -0700 Subject: [PATCH 176/403] fsmonitor: validate FSMN before publishing it The index reader consumed an optional FSMN token and EWAH bitmap before checking their complete framing. A truncated or duplicate record could publish partial monitor state; an impossible bitmap length could allocate out of bounds or cover nonexistent index entries. Validate both FSMN versions against the extension bounds, cap version-2 tokens at 4 KiB, and check EWAH word counts, run lengths, padding, and the final running-length word. Reject a bitmap wider than a non-split index. Publish the token and bitmap only after every check succeeds, and clear all existing FSMN state on failure. Extend the read-cache helper to exercise valid records, duplicates, truncation, invalid literal and set-bit runs, nonzero padding, and an invalid final running-length-word pointer. Register the helper regression in t/t7519-status-fsmonitor.sh. Malformed optional state falls back without making the worktree appear clean. Signed-off-by: Taylor Blau --- fsmonitor.c | 113 ++++++++++++++++++++++++++++--- read-cache-ll.h | 3 +- t/helper/test-read-cache.c | 130 ++++++++++++++++++++++++++++++++++++ t/t7519-status-fsmonitor.sh | 4 ++ 4 files changed, 238 insertions(+), 12 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index df716a26b85499..ebec5620dbb630 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -7,6 +7,7 @@ #include "dir.h" #include "environment.h" #include "ewah/ewok.h" +#include "ewah/ewok_rlw.h" #include "fsmonitor.h" #include "fsmonitor-ipc.h" #include "name-hash.h" @@ -17,6 +18,7 @@ #define INDEX_EXTENSION_VERSION1 (1) #define INDEX_EXTENSION_VERSION2 (2) +#define FSMONITOR_TOKEN_MAX (4096) #define HOOK_INTERFACE_VERSION1 (1) #define HOOK_INTERFACE_VERSION2 (2) @@ -40,6 +42,46 @@ static void fsmonitor_ewah_callback(size_t pos, void *is) ce->ce_flags &= ~CE_FSMONITOR_VALID; } +static int fsmonitor_ewah_is_valid(struct ewah_bitmap *bitmap) +{ + size_t pointer = 0, expanded_words = 0; + size_t logical_words = bitmap->bit_size / BITS_IN_EWORD + + !!(bitmap->bit_size % BITS_IN_EWORD); + size_t padding = bitmap->bit_size % BITS_IN_EWORD; + eword_t *last_rlw = NULL; + + while (pointer < bitmap->buffer_size) { + eword_t *rlw = &bitmap->buffer[pointer]; + size_t running_words = rlw_get_running_len(rlw); + size_t literal_words = rlw_get_literal_words(rlw); + size_t i; + + last_rlw = rlw; + if (literal_words > bitmap->buffer_size - pointer - 1) + return 0; + if (running_words > logical_words - expanded_words) + return 0; + expanded_words += running_words; + if (rlw_get_run_bit(rlw) && running_words && padding && + expanded_words == logical_words) + return 0; + if (literal_words > logical_words - expanded_words) + return 0; + for (i = 0; i < literal_words; i++) { + eword_t literal = bitmap->buffer[pointer + 1 + i]; + + if (padding && + expanded_words + i + 1 == logical_words && + literal >> padding) + return 0; + } + expanded_words += literal_words; + pointer += 1 + literal_words; + } + + return expanded_words == logical_words && bitmap->rlw == last_rlw; +} + static int fsmonitor_hook_version(void) { int hook_version; @@ -60,44 +102,81 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, unsigned long sz) { const char *index = data; + const char *end = index + sz; + const char *nul; uint32_t hdr_version; uint32_t ewah_size; + uint32_t ewah_words; + uint32_t ewah_rlw; struct ewah_bitmap *fsmonitor_dirty; int ret; uint64_t timestamp; struct strbuf last_update = STRBUF_INIT; - if (sz < sizeof(uint32_t) + 1 + sizeof(uint32_t)) - return error("corrupt fsmonitor extension (too short)"); + if (istate->fsmonitor_extension_seen) + goto invalid; + istate->fsmonitor_extension_seen = 1; + if (end - index < sizeof(uint32_t)) + goto invalid; hdr_version = get_be32(index); index += sizeof(uint32_t); if (hdr_version == INDEX_EXTENSION_VERSION1) { + if (end - index < sizeof(uint64_t)) + goto invalid; timestamp = get_be64(index); strbuf_addf(&last_update, "%"PRIu64"", timestamp); index += sizeof(uint64_t); } else if (hdr_version == INDEX_EXTENSION_VERSION2) { - strbuf_addstr(&last_update, index); - index += last_update.len + 1; + nul = memchr(index, '\0', end - index); + if (!nul || nul == index || nul - index > FSMONITOR_TOKEN_MAX) + goto invalid; + strbuf_add(&last_update, index, nul - index); + index = nul + 1; } else { - return error("bad fsmonitor version %d", hdr_version); + goto invalid; } - istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); - + if (end - index < sizeof(uint32_t)) + goto invalid; ewah_size = get_be32(index); index += sizeof(uint32_t); + if (ewah_size != end - index || ewah_size < 3 * sizeof(uint32_t)) + goto invalid; + + /* Reject impossible EWAH lengths before its parser allocates memory. */ + ewah_words = get_be32(index + sizeof(uint32_t)); + if (ewah_words > (ewah_size - 3 * sizeof(uint32_t)) / + sizeof(eword_t) || + 3 * sizeof(uint32_t) + (size_t)ewah_words * sizeof(eword_t) != + ewah_size) + goto invalid; + ewah_rlw = get_be32(index + ewah_size - sizeof(uint32_t)); + if (ewah_rlw >= ewah_words) + goto invalid; fsmonitor_dirty = ewah_new(); ret = ewah_read_mmap(fsmonitor_dirty, index, ewah_size); if (ret != ewah_size) { ewah_free(fsmonitor_dirty); - return error("failed to parse ewah bitmap reading fsmonitor index extension"); + goto invalid; + } + if (!fsmonitor_ewah_is_valid(fsmonitor_dirty)) { + ewah_free(fsmonitor_dirty); + goto invalid; + } + if (!istate->split_index && + fsmonitor_dirty->bit_size > istate->cache_nr) { + ewah_free(fsmonitor_dirty); + goto invalid; } - istate->fsmonitor_dirty = fsmonitor_dirty; - if (!istate->split_index) - assert_index_minimum(istate, istate->fsmonitor_dirty->bit_size); + /* Publish only after the complete optional extension is validated. */ + FREE_AND_NULL(istate->fsmonitor_last_update); + if (istate->fsmonitor_dirty) + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); + istate->fsmonitor_dirty = fsmonitor_dirty; trace2_data_string("index", NULL, "extension/fsmn/read/token", istate->fsmonitor_last_update); @@ -105,6 +184,18 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, "read fsmonitor extension successful '%s'", istate->fsmonitor_last_update); return 0; + +invalid: + istate->fsmonitor_extension_seen = 1; + FREE_AND_NULL(istate->fsmonitor_last_update); + if (istate->fsmonitor_dirty) { + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; + } + strbuf_release(&last_update); + trace2_data_intmax("fsmonitor", istate->repo, + "extension/invalid", 1); + return 0; } void fill_fsmonitor_bitmap(struct index_state *istate) diff --git a/read-cache-ll.h b/read-cache-ll.h index ed7d7aac38257e..d728e050e5fb23 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -182,7 +182,8 @@ struct index_state { drop_cache_tree : 1, updated_workdir : 1, updated_skipworktree : 1, - fsmonitor_has_run_once : 1; + fsmonitor_has_run_once : 1, + fsmonitor_extension_seen : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index c7631a204c8b2a..7034e30c80d2ac 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -4,11 +4,62 @@ #include "attr.h" #include "config.h" #include "environment.h" +#include "ewah/ewok.h" +#include "ewah/ewok_rlw.h" #include "fsmonitor.h" #include "fsmonitor-ll.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" +#include "strbuf.h" + +static void wrap_fsmn_ewah(struct strbuf *out, const struct strbuf *ewah) +{ + uint32_t value; + + put_be32(&value, 2); + strbuf_add(out, &value, sizeof(value)); + strbuf_addstr(out, "token"); + strbuf_addch(out, '\0'); + put_be32(&value, ewah->len); + strbuf_add(out, &value, sizeof(value)); + strbuf_addbuf(out, ewah); +} + +static void make_valid_fsmn(struct strbuf *out) +{ + struct ewah_bitmap *dirty = ewah_new(); + struct strbuf ewah = STRBUF_INIT; + + ewah_set(dirty, 0); + ewah_serialize_strbuf(dirty, &ewah); + wrap_fsmn_ewah(out, &ewah); + ewah_free(dirty); + strbuf_release(&ewah); +} + +static void make_raw_fsmn(struct strbuf *out, uint32_t bit_size, + const eword_t *words, uint32_t word_count, + uint32_t rlw) +{ + struct strbuf ewah = STRBUF_INIT; + uint32_t value; + uint32_t i; + + put_be32(&value, bit_size); + strbuf_add(&ewah, &value, sizeof(value)); + put_be32(&value, word_count); + strbuf_add(&ewah, &value, sizeof(value)); + for (i = 0; i < word_count; i++) { + eword_t word = htonll(words[i]); + + strbuf_add(&ewah, &word, sizeof(word)); + } + put_be32(&value, rlw); + strbuf_add(&ewah, &value, sizeof(value)); + wrap_fsmn_ewah(out, &ewah); + strbuf_release(&ewah); +} static int test_fsmonitor_content_recovery(const char *path) { @@ -43,6 +94,83 @@ static int test_fsmonitor_content_recovery(const char *path) return 0; } +static int fsmn_failed_closed(const struct index_state *istate) +{ + return istate->fsmonitor_extension_seen && + !istate->fsmonitor_last_update && !istate->fsmonitor_dirty; +} + +static int check_invalid_fsmn(const struct strbuf *encoded, + const char *description) +{ + struct index_state invalid = INDEX_STATE_INIT(the_repository); + + invalid.cache_nr = 1; + invalid.fsmonitor_last_update = xstrdup("old"); + invalid.fsmonitor_dirty = ewah_new(); + read_fsmonitor_extension(&invalid, encoded->buf, encoded->len); + if (!fsmn_failed_closed(&invalid)) + return error("%s FSMN was published", description); + return 0; +} + +static int test_fsmn_parser(void) +{ + struct index_state duplicate = INDEX_STATE_INIT(the_repository); + struct index_state truncated = INDEX_STATE_INIT(the_repository); + struct strbuf encoded = STRBUF_INIT; + struct strbuf malformed = STRBUF_INIT; + eword_t words[2] = { 0 }; + + duplicate.cache_nr = truncated.cache_nr = 1; + make_valid_fsmn(&encoded); + read_fsmonitor_extension(&duplicate, encoded.buf, encoded.len); + if (!duplicate.fsmonitor_last_update || + strcmp(duplicate.fsmonitor_last_update, "token") || + !duplicate.fsmonitor_dirty) + return error("valid FSMN was not published"); + read_fsmonitor_extension(&duplicate, encoded.buf, encoded.len); + if (!fsmn_failed_closed(&duplicate)) + return error("duplicate FSMN did not fail closed"); + + truncated.fsmonitor_last_update = xstrdup("old"); + truncated.fsmonitor_dirty = ewah_new(); + read_fsmonitor_extension(&truncated, encoded.buf, encoded.len - 1); + if (!fsmn_failed_closed(&truncated)) + return error("truncated FSMN was partially published"); + + rlw_set_literal_words(&words[0], 1); + make_raw_fsmn(&malformed, 1, words, 1, 0); + if (check_invalid_fsmn(&malformed, "out-of-bounds literal")) + return 1; + strbuf_reset(&malformed); + + words[0] = 0; + rlw_set_run_bit(&words[0], 1); + rlw_set_running_len(&words[0], 1); + make_raw_fsmn(&malformed, 1, words, 1, 0); + if (check_invalid_fsmn(&malformed, "oversized set-bit run")) + return 1; + strbuf_reset(&malformed); + + words[0] = words[1] = 0; + rlw_set_literal_words(&words[0], 1); + words[1] = 2; + make_raw_fsmn(&malformed, 1, words, 2, 0); + if (check_invalid_fsmn(&malformed, "set padding bit")) + return 1; + strbuf_reset(&malformed); + + words[1] = 1; + make_raw_fsmn(&malformed, 1, words, 2, 1); + if (check_invalid_fsmn(&malformed, "non-final RLW")) + return 1; + + strbuf_release(&malformed); + strbuf_release(&encoded); + return 0; +} + static int test_fsmonitor_directory_attributes(void) { struct attr_check *check; @@ -90,6 +218,8 @@ int cmd__read_cache(int argc, const char **argv) if (argc == 3 && !strcmp(argv[1], "--test-fsmonitor-content-recovery")) return test_fsmonitor_content_recovery(argv[2]); + if (argc == 2 && !strcmp(argv[1], "--test-fsmn-parser")) + return test_fsmn_parser(); if (argc == 2 && !strcmp(argv[1], "--test-fsmonitor-directory-attributes")) return test_fsmonitor_directory_attributes(); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 691148ae677113..f257a05f92930e 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -60,6 +60,10 @@ test_lazy_prereq HARDLINKS ' ln hardlink-a hardlink-b ' +test_expect_success 'FSMN parser fails closed' ' + test-tool read-cache --test-fsmn-parser +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From 436442ada4eb0e81da2fe4e0fb6970f71af04a2d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:47 -0500 Subject: [PATCH 177/403] read-cache: write only token-closed fsmonitor history Reading a validated FSCF record is not enough to preserve it during a generic index rewrite. Writing fresh token or stat bindings before the current provider token is revalidated would claim a semantic proof that the index has not established. Write a newly bound FSCF extension only when configuration, attributes, the complete manifest, the valid provider token, and its revalidated token all agree. Otherwise preserve an existing validated record with its token and stat bindings cleared; never serialize malformed or missing history. Add the extension to the existing index writer. Extend the history unit tests to distinguish closed proofs from preserved unbound manifests. Add a test-tool round trip and t7519 coverage that read, write, and reread a coherent FSCF record through a real index. Signed-off-by: Taylor Blau --- builtin/add.c | 40 ++++++++- builtin/checkout-index.c | 19 ++++- builtin/checkout.c | 79 +++++++++++++++--- builtin/describe.c | 22 ++++- builtin/reset.c | 38 ++++++++- builtin/stash.c | 27 +++++- builtin/update-index.c | 42 +++++++++- clean-status-history.c | 66 +++++++++++++++ clean-status-internal.h | 2 + clean-status.c | 20 +++++ clean-status.h | 8 ++ fsmonitor.c | 23 ++++- read-cache-ll.h | 1 + read-cache.c | 53 ++++++++++-- t/helper/test-read-cache.c | 116 ++++++++++++++++++++++++++ t/t7519-status-fsmonitor.sh | 13 +++ t/t7527-builtin-fsmonitor.sh | 1 + t/unit-tests/u-clean-status-history.c | 81 ++++++++++++++++++ 18 files changed, 621 insertions(+), 30 deletions(-) diff --git a/builtin/add.c b/builtin/add.c index 60ffbede2be58a..b13ba9580cf896 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -6,6 +6,8 @@ #include "builtin.h" #include "advice.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "lockfile.h" @@ -285,18 +287,20 @@ static struct option builtin_add_options[] = { }; static int add_config(const char *var, const char *value, - const struct config_context *ctx, void *cb) + const struct config_context *ctx, void *data) { + clean_status_config_add(data, var, value, ctx); + if (!strcmp(var, "add.ignoreerrors") || !strcmp(var, "add.ignore-errors")) { ignore_add_errors = git_config_bool(var, value); return 0; } - if (git_color_config(var, value, cb) < 0) + if (git_color_config(var, value, NULL) < 0) return -1; - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } static const char embedded_advice[] = N_( @@ -384,18 +388,25 @@ int cmd_add(int argc, const char *prefix, struct repository *repo) { + struct clean_status_config_digest clean_digest; int exit_status = 0; struct pathspec pathspec; struct dir_struct dir = DIR_INIT; int flags; int add_new_files; + int preserve_add_history = 0; int require_pathspec; char *seen = NULL; char *ps_matched = NULL; struct lock_file lock_file = LOCK_INIT; struct odb_transaction *transaction; - repo_config(repo, add_config, NULL); + show_usage_with_options_if_asked(argc, argv, + builtin_add_usage, builtin_add_options); + + clean_status_config_init(&clean_digest, repo->hash_algo); + repo_config(repo, add_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, prefix, builtin_add_options, builtin_add_usage, PARSE_OPT_KEEP_ARGV0); @@ -492,8 +503,27 @@ int cmd_add(int argc, (!(addremove || take_worktree_changes) ? ADD_CACHE_IGNORE_REMOVAL : 0)); + /* + * The refresh-only path below updates stat data and fsmonitor + * validity, but does not change the logical contents of the index. + * Ordinary add can do the same after an mtime-only change. Ask + * ADD_CACHE_TRACK_CLEAN_HISTORY to invalidate on any persistent + * add/remove decision below. + */ + if (refresh_only) { + clean_status_set_config_digest(repo, &clean_digest); + } else if (!show_only && !intent_to_add && !add_renormalize && + !chmod_arg && !include_sparse && !ignore_add_errors) { + preserve_add_history = 1; + flags |= ADD_CACHE_TRACK_CLEAN_HISTORY; + clean_status_set_config_digest(repo, &clean_digest); + } + if (repo_read_index_preload(repo, &pathspec, 0) < 0) die(_("index file corrupt")); + if (preserve_add_history && + (repo->index->split_index || repo->index->sparse_index)) + clean_status_invalidate_current_proof(repo->index); die_in_unpopulated_submodule(repo->index, prefix); die_path_inside_submodule(repo->index, &pathspec); @@ -603,6 +633,8 @@ int cmd_add(int argc, odb_transaction_commit(transaction); finish: + if (preserve_add_history && exit_status) + clean_status_invalidate_current_proof(repo->index); if (write_locked_index(repo->index, &lock_file, COMMIT_LOCK | SKIP_IF_UNCHANGED)) die(_("unable to write new index file")); diff --git a/builtin/checkout-index.c b/builtin/checkout-index.c index 311b94ff3174a6..1807696b1c92c8 100644 --- a/builtin/checkout-index.c +++ b/builtin/checkout-index.c @@ -8,6 +8,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -30,6 +32,13 @@ static char topath[4][TEMPORARY_FILENAME_LENGTH + 1]; static struct checkout state = CHECKOUT_INIT; +static int checkout_index_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + static void write_tempfile_record(const char *name, const char *prefix) { int i; @@ -215,6 +224,7 @@ int cmd_checkout_index(int argc, const char *prefix, struct repository *repo) { + struct clean_status_config_digest clean_digest; int i; struct lock_file lock_file = LOCK_INIT; int all = 0; @@ -253,7 +263,14 @@ int cmd_checkout_index(int argc, show_usage_with_options_if_asked(argc, argv, builtin_checkout_index_usage, builtin_checkout_index_options); - repo_config(repo, git_default_config, NULL); + clean_status_config_init(&clean_digest, repo->hash_algo); + repo_config(repo, checkout_index_config, &clean_digest); + clean_status_config_final(&clean_digest); + /* + * checkout-index never changes index contents. Keep closed semantic + * history attached when -u writes fresh stat data. + */ + clean_status_set_config_digest(repo, &clean_digest); prefix_length = prefix ? strlen(prefix) : 0; prepare_repo_settings(repo); diff --git a/builtin/checkout.c b/builtin/checkout.c index 55e3a89a852712..2992dfe0e99047 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -6,6 +6,8 @@ #include "branch.h" #include "cache-tree.h" #include "checkout.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "commit.h" #include "config.h" #include "diff.h" @@ -46,6 +48,7 @@ #include "add-interactive.h" struct checkout_opts { + struct clean_status_config_digest clean_digest; int patch_mode; int patch_context; int patch_interhunk_context; @@ -142,6 +145,11 @@ static int post_checkout_hook(struct commit *old_commit, struct commit *new_comm return run_hooks_opt(the_repository, "post-checkout", &opt); } +struct tree_checkout_context { + int overlay_mode; + int *index_changed; +}; + /* * Handle a tree object and determine if we need to recurse into the * tree (READ_TREE_RECURSIVE) or skip it (0). @@ -149,11 +157,12 @@ static int post_checkout_hook(struct commit *old_commit, struct commit *new_comm static int try_update_sparse_directory(const struct object_id *oid, struct strbuf *base, const char *pathname, - int overlay_mode) + struct tree_checkout_context *context) { struct strbuf dirpath = STRBUF_INIT; struct cache_entry *old; int pos, result = READ_TREE_RECURSIVE; + int overlay_mode = context ? context->overlay_mode : 1; if (!the_repository->index->sparse_index) return result; @@ -180,6 +189,8 @@ static int try_update_sparse_directory(const struct object_id *oid, * sparse directory OID directly since files not present in * the source tree should be removed anyway. */ + if (context && context->index_changed) + *context->index_changed = 1; oidcpy(&old->oid, oid); old->ce_flags |= CE_UPDATE; result = 0; @@ -196,11 +207,11 @@ static int update_some(const struct object_id *oid, struct strbuf *base, int len; struct cache_entry *ce; int pos; - int overlay_mode = context ? *((int *)context) : 1; + struct tree_checkout_context *checkout_context = context; if (S_ISDIR(mode)) return try_update_sparse_directory(oid, base, pathname, - overlay_mode); + checkout_context); len = base->len + strlen(pathname); ce = make_empty_cache_entry(the_repository->index, len); @@ -228,16 +239,23 @@ static int update_some(const struct object_id *oid, struct strbuf *base, } } + if (checkout_context && checkout_context->index_changed) + *checkout_context->index_changed = 1; add_index_entry(the_repository->index, ce, ADD_CACHE_OK_TO_ADD | ADD_CACHE_OK_TO_REPLACE); return 0; } static int read_tree_some(struct tree *tree, const struct pathspec *pathspec, - int overlay_mode) + int overlay_mode, int *index_changed) { + struct tree_checkout_context context = { + .overlay_mode = overlay_mode, + .index_changed = index_changed, + }; + read_tree(the_repository, tree, - pathspec, update_some, &overlay_mode); + pathspec, update_some, &context); /* update the index with the given tree's info * for all args, expanding wildcards, and exit @@ -420,20 +438,24 @@ static void mark_ce_for_checkout_overlay(struct cache_entry *ce, static void mark_ce_for_checkout_no_overlay(struct cache_entry *ce, char *ps_matched, - const struct checkout_opts *opts) + const struct checkout_opts *opts, + int *index_changed) { ce->ce_flags &= ~CE_MATCHED; if (!opts->ignore_skipworktree && ce_skip_worktree(ce)) return; if (ce_path_match(the_repository->index, ce, &opts->pathspec, ps_matched)) { ce->ce_flags |= CE_MATCHED; - if (opts->source_tree && !(ce->ce_flags & CE_UPDATE)) + if (opts->source_tree && !(ce->ce_flags & CE_UPDATE)) { /* - * In overlay mode, but the path is not in + * In no-overlay mode, but the path is not in * tree-ish, which means we should remove it * from the index and the working tree. */ + if (index_changed) + *index_changed = 1; ce->ce_flags |= CE_REMOVE | CE_WT_REMOVE; + } } } @@ -524,6 +546,8 @@ static int checkout_paths(const struct checkout_opts *opts, int errs = 0; struct lock_file lock_file = LOCK_INIT; int checkout_index; + int preserve_source_tree_history = 0; + int source_tree_index_changed = 0; trace2_cmd_mode(opts->patch_mode ? "patch" : "path"); @@ -628,12 +652,34 @@ static int checkout_paths(const struct checkout_opts *opts, } repo_hold_locked_index(the_repository, &lock_file, LOCK_DIE_ON_ERROR); + /* + * A plain worktree checkout from the index only rewrites stat data. + * A source-tree checkout may do the same when every selected entry + * already matches the index; if not, invalidate its proof below. + * Keep written paths fsmonitor-invalid in either case. Do not do + * this for --merge, which may recreate unmerged index entries from + * resolve undo data. + */ + preserve_source_tree_history = + opts->source_tree && opts->checkout_index && + !opts->merge && !opts->writeout_stage; + if ((opts->checkout_worktree && !opts->source_tree && + !opts->merge && !opts->writeout_stage) || + preserve_source_tree_history) + clean_status_set_config_digest(the_repository, + &opts->clean_digest); if (repo_read_index_preload(the_repository, &opts->pathspec, 0) < 0) return error(_("index file corrupt")); + if (preserve_source_tree_history && + (the_repository->index->split_index || + the_repository->index->sparse_index)) + source_tree_index_changed = 1; if (opts->source_tree) read_tree_some(opts->source_tree, &opts->pathspec, - opts->overlay_mode); + opts->overlay_mode, + preserve_source_tree_history ? + &source_tree_index_changed : NULL); if (opts->merge) unmerge_index(the_repository->index, &opts->pathspec, CE_MATCHED); @@ -651,7 +697,10 @@ static int checkout_paths(const struct checkout_opts *opts, else mark_ce_for_checkout_no_overlay(the_repository->index->cache[pos], ps_matched, - opts); + opts, + preserve_source_tree_history ? + &source_tree_index_changed : + NULL); if (report_path_error(ps_matched, &opts->pathspec)) { free(ps_matched); @@ -698,6 +747,10 @@ static int checkout_paths(const struct checkout_opts *opts, checkout_index = opts->checkout_index; if (checkout_index) { + if (preserve_source_tree_history && + (source_tree_index_changed || errs)) + clean_status_invalidate_current_proof( + the_repository->index); if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); } else { @@ -1282,6 +1335,8 @@ static int git_checkout_config(const char *var, const char *value, { struct checkout_opts *opts = cb; + clean_status_config_add(&opts->clean_digest, var, value, ctx); + if (!strcmp(var, "diff.ignoresubmodules")) { if (!value) return config_error_nonbool(var); @@ -1879,7 +1934,11 @@ static int checkout_main(int argc, const char **argv, const char *prefix, opts->prefix = prefix; opts->show_progress = -1; + show_usage_with_options_if_asked(argc, argv, usagestr, options); + + clean_status_config_init(&opts->clean_digest, the_repository->hash_algo); repo_config(the_repository, git_checkout_config, opts); + clean_status_config_final(&opts->clean_digest); if (the_repository->gitdir) { prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; diff --git a/builtin/describe.c b/builtin/describe.c index c0abc931a5948d..b39df0937ecd14 100644 --- a/builtin/describe.c +++ b/builtin/describe.c @@ -2,6 +2,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -598,11 +600,19 @@ static int option_parse_exact_match(const struct option *opt, const char *arg, return 0; } +static int describe_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + int cmd_describe(int argc, const char **argv, const char *prefix, struct repository *repo UNUSED ) { + struct clean_status_config_digest clean_digest; struct refs_for_each_ref_options for_each_ref_opts = { .flags = REFS_FOR_EACH_INCLUDE_BROKEN, }; @@ -647,7 +657,11 @@ int cmd_describe(int argc, OPT_END(), }; - repo_config(the_repository, git_default_config, NULL); + show_usage_with_options_if_asked(argc, argv, describe_usage, options); + + clean_status_config_init(&clean_digest, the_repository->hash_algo); + repo_config(the_repository, describe_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, prefix, options, describe_usage, 0); if (abbrev < 0) abbrev = DEFAULT_ABBREV; @@ -761,6 +775,12 @@ int cmd_describe(int argc, setup_work_tree(the_repository); prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; + /* + * The in-process dirty check only refreshes stat + * data before comparing the worktree with HEAD. + */ + clean_status_set_config_digest(the_repository, + &clean_digest); repo_read_index(the_repository); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED, NULL, NULL, NULL); diff --git a/builtin/reset.c b/builtin/reset.c index 78e69bd84ba2c3..a9d5183ab848d8 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -12,6 +12,8 @@ #include "builtin.h" #include "advice.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -325,12 +327,14 @@ static int reset_refs(const char *rev, const struct object_id *oid) } static int git_reset_config(const char *var, const char *value, - const struct config_context *ctx, void *cb) + const struct config_context *ctx, void *data) { + clean_status_config_add(data, var, value, ctx); + if (!strcmp(var, "submodule.recurse")) - return git_default_submodule_config(var, value, cb); + return git_default_submodule_config(var, value, NULL); - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } int cmd_reset(int argc, @@ -338,9 +342,11 @@ int cmd_reset(int argc, const char *prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; int reset_type = NONE, update_ref_status = 0, quiet = 0; int no_refresh = 0; int patch_mode = 0, pathspec_file_nul = 0, unborn; + int preserve_mixed_history = 0; const char *rev; char *pathspec_from_file = NULL; struct object_id oid; @@ -382,7 +388,11 @@ int cmd_reset(int argc, OPT_END() }; - repo_config(the_repository, git_reset_config, NULL); + show_usage_with_options_if_asked(argc, argv, git_reset_usage, options); + + clean_status_config_init(&clean_digest, the_repository->hash_algo); + repo_config(the_repository, git_reset_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, prefix, options, git_reset_usage, PARSE_OPT_KEEP_DASHDASH); @@ -477,6 +487,18 @@ int cmd_reset(int argc, if (intent_to_add && reset_type != MIXED) die(_("the option '%s' requires '%s'"), "-N", "--mixed"); + /* + * A no-path mixed reset is a candidate for a stat-only rewrite even + * when its target commit differs from HEAD. Attach history early + * enough for the initial index read, but keep it only if + * read_from_tree() confirms that no logical entries changed. + */ + if (reset_type == MIXED && !pathspec.nr && !intent_to_add && + !unborn) { + preserve_mixed_history = 1; + clean_status_set_config_digest(the_repository, &clean_digest); + } + if (repo_read_index(the_repository) < 0) die(_("index file corrupt")); @@ -496,6 +518,14 @@ int cmd_reset(int argc, update_ref_status = 1; goto cleanup; } + if (preserve_mixed_history && + (the_repository->index->split_index || + the_repository->index->sparse_index || + (the_repository->index->cache_changed & + (CE_ENTRY_CHANGED | CE_ENTRY_REMOVED | + CE_ENTRY_ADDED | RESOLVE_UNDO_CHANGED)))) + clean_status_invalidate_current_proof( + the_repository->index); the_repository->index->updated_skipworktree = 1; if (!no_refresh && repo_get_work_tree(the_repository)) { uint64_t t_begin, t_delta_in_ms; diff --git a/builtin/stash.c b/builtin/stash.c index 72c52571f8c06c..4fd7ec0c6258ad 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -2,6 +2,8 @@ #include "builtin.h" #include "abspath.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -150,6 +152,7 @@ static int show_stat = 1; static int show_patch; static int show_include_untracked; static int use_index; +static struct clean_status_config_digest stash_clean_digest; /* * w_commit is set to the commit containing the working tree @@ -975,6 +978,8 @@ static int list_stash(int argc, const char **argv, const char *prefix, static int git_stash_config(const char *var, const char *value, const struct config_context *ctx, void *cb) { + clean_status_config_add(cb, var, value, ctx); + if (!strcmp(var, "stash.showstat")) { show_stat = git_config_bool(var, value); return 0; @@ -991,7 +996,7 @@ static int git_stash_config(const char *var, const char *value, use_index = git_config_bool(var, value); return 0; } - return git_diff_basic_config(var, value, ctx, cb); + return git_diff_basic_config(var, value, ctx, NULL); } static void diff_include_untracked(const struct stash_info *info, struct diff_options *diff_opt) @@ -1671,6 +1676,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q int include_untracked, int only_staged) { int ret = 0; + int preserve_clean_history = !ps->nr && !include_untracked; struct stash_info info = STASH_INFO_INIT; struct strbuf patch = STRBUF_INIT; struct strbuf stash_msg_buf = STRBUF_INIT; @@ -1698,6 +1704,16 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q goto done; } + /* + * A clean stash push returns after its initial stat refresh. Keep + * that rewrite bound only for whole-worktree forms; paths and + * untracked discovery can change the index or its status inputs. + * If changes are found below, invalidate before the real stash + * machinery mutates the index or worktree. + */ + if (preserve_clean_history) + clean_status_set_config_digest(the_repository, + &stash_clean_digest); repo_read_index_preload(the_repository, NULL, 0); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); @@ -1728,6 +1744,8 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q printf_ln(_("No local changes to save")); goto done; } + if (preserve_clean_history) + clean_status_invalidate_current_proof(the_repository->index); if (!refs_reflog_exists(get_main_ref_store(the_repository), ref_stash) && do_clear_stash()) { ret = -1; @@ -2478,7 +2496,12 @@ int cmd_stash(int argc, const char **args_copy; int ret; - repo_config(the_repository, git_stash_config, NULL); + show_usage_with_options_if_asked(argc, argv, git_stash_usage, options); + + clean_status_config_init(&stash_clean_digest, + the_repository->hash_algo); + repo_config(the_repository, git_stash_config, &stash_clean_digest); + clean_status_config_final(&stash_clean_digest); argc = parse_options(argc, argv, prefix, options, git_stash_usage, PARSE_OPT_SUBCOMMAND_OPTIONAL | diff --git a/builtin/update-index.c b/builtin/update-index.c index 241abd4332dcf9..b8b565f0d6f632 100644 --- a/builtin/update-index.c +++ b/builtin/update-index.c @@ -8,6 +8,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -54,6 +56,28 @@ static int ignore_skip_worktree_entries; #define UNMARK_FLAG 2 static struct strbuf mtime_dir = STRBUF_INIT; +static int update_index_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + +static int is_proof_preserving_rewrite(int argc, const char **argv) +{ + if (argc == 2) + return !strcmp(argv[1], "--refresh") || + !strcmp(argv[1], "--force-write-index"); + + if (argc != 3) + return 0; + + return (!strcmp(argv[1], "--refresh") && + !strcmp(argv[2], "--force-write-index")) || + (!strcmp(argv[1], "--force-write-index") && + !strcmp(argv[2], "--refresh")); +} + /* Untracked cache mode */ enum uc_mode { UC_UNSPECIFIED = -1, @@ -917,6 +941,7 @@ int cmd_update_index(int argc, const char *prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; int newfd, entries, has_errors = 0, nul_term_line = 0; enum uc_mode untracked_cache = UC_UNSPECIFIED; int read_from_stdin = 0; @@ -932,6 +957,8 @@ int cmd_update_index(int argc, struct parse_opt_ctx_t ctx; strbuf_getline_fn getline_fn; int parseopt_state = PARSE_OPT_UNKNOWN; + int preserve_clean_history = + is_proof_preserving_rewrite(argc, argv); struct repository *r = the_repository; struct odb_transaction *transaction; struct option options[] = { @@ -1097,7 +1124,20 @@ int cmd_update_index(int argc, show_usage_with_options_if_asked(argc, argv, update_index_usage, options); - repo_config(the_repository, git_default_config, NULL); + if (preserve_clean_history) { + clean_status_config_init(&clean_digest, + the_repository->hash_algo); + repo_config(the_repository, update_index_config, + &clean_digest); + clean_status_config_final(&clean_digest); + /* + * These exact forms can refresh stat data, or no data at all, + * but cannot change the logical contents of the index. + */ + clean_status_set_config_digest(the_repository, &clean_digest); + } else { + repo_config(the_repository, git_default_config, NULL); + } prepare_repo_settings(r); the_repository->settings.command_requires_full_index = 0; diff --git a/clean-status-history.c b/clean-status-history.c index f77f4294e74af3..e6cdfc8f549826 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -111,3 +111,69 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) trace2_data_intmax("fsmonitor", istate->repo, "semantic/initial-mismatch", state->strong_mismatch); } + +static int current_proof_is_writable(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + state->config_enforced && state->current_config_valid && + state->current_semantic_valid && state->current_attr_valid && + state->manifest.current_valid && + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + !clean_status_filter_scope_needs_validation(istate) && + clean_status_revalidated_token_matches(istate); +} + +void clean_status_advance_fsmonitor_config_token( + struct index_state *istate, const char *next_token) +{ + struct clean_status_state *state = istate->clean_status; + + if (!next_token || !current_proof_is_writable(istate)) + return; + FREE_AND_NULL(state->config_revalidated_token); + state->config_revalidated_token = xstrdup(next_token); + trace2_data_intmax("fsmonitor", istate->repo, + "config/token-advanced", 1); +} + +int clean_status_should_write_fsmonitor_config( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return current_proof_is_writable(istate) || + (state && state->disk_config_valid && + !state->disk_config_invalid && state->disk_config_raw.len); +} + +void clean_status_write_fsmonitor_config(struct strbuf *out, + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + + if (current_proof_is_writable(istate)) { + struct fsmonitor_clean_proof proof = { + .flags = state->manifest.current_flags, + .token = (const unsigned char *)istate->fsmonitor_last_update, + .token_len = strlen(istate->fsmonitor_last_update), + .config_hash = state->current_config_hash, + .semantic_hash = state->current_semantic_hash, + .attr_hash = state->current_attr_hash, + .attr_manifest = + (const unsigned char *)state->manifest.current.buf, + .attr_manifest_len = state->manifest.current.len, + }; + + if (fsmonitor_clean_proof_write(out, &proof, algo)) + BUG("cannot serialize validated fsmonitor clean proof"); + return; + } + if (fsmonitor_clean_proof_copy_without_bindings( + out, state->disk_config_raw.buf, state->disk_config_raw.len, algo)) + BUG("cannot preserve validated fsmonitor clean proof"); +} diff --git a/clean-status-internal.h b/clean-status-internal.h index d4868d989b6cb1..9ea64b13685fdc 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -40,5 +40,7 @@ struct clean_status_state { }; struct clean_status_state *clean_status_get_state(struct index_state *istate); +int clean_status_revalidated_token_matches( + const struct index_state *istate); #endif /* CLEAN_STATUS_INTERNAL_H */ diff --git a/clean-status.c b/clean-status.c index f1c61c4e345f80..797d4730c1681f 100644 --- a/clean-status.c +++ b/clean-status.c @@ -77,6 +77,26 @@ int clean_status_filter_scope_needs_validation( state->filter_configured && !state->filter_scope_valid; } +int clean_status_revalidated_token_matches(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->config_revalidated && + state->config_revalidated_token && + istate->fsmonitor_last_update && + !strcmp(state->config_revalidated_token, + istate->fsmonitor_last_update); +} + +void clean_status_invalidate_current_proof(struct index_state *istate) +{ + if (!istate->clean_status) + return; + istate->clean_status->config_revalidated = 0; + istate->clean_status->initial_coherent = 0; + istate->clean_status->filter_scope_valid = 0; +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index 82ee75f8c52304..6c6db51b86a229 100644 --- a/clean-status.h +++ b/clean-status.h @@ -6,6 +6,7 @@ struct index_state; struct repository; struct stat; +struct strbuf; void clean_status_set_config_digest( struct repository *repo, @@ -21,6 +22,13 @@ int clean_status_verify_null_index(const struct index_state *istate, int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); void clean_status_prepare_fsmonitor_config(struct index_state *istate); +void clean_status_invalidate_current_proof(struct index_state *istate); +void clean_status_advance_fsmonitor_config_token( + struct index_state *istate, const char *next_token); +int clean_status_should_write_fsmonitor_config( + const struct index_state *istate); +void clean_status_write_fsmonitor_config(struct strbuf *out, + const struct index_state *istate); void clean_status_release(struct index_state *istate); diff --git a/fsmonitor.c b/fsmonitor.c index 94ccbceba7c307..9e90d158402fdd 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -3,6 +3,7 @@ #include "git-compat-util.h" #include "attr.h" +#include "clean-status.h" #include "config.h" #include "dir.h" #include "environment.h" @@ -636,6 +637,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) { int len = strlen(name); int pos; + int attributes_may_have_changed; size_t nr_in_cone; trace_printf_key(&trace_fsmonitor, @@ -645,6 +647,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) if (!strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE)) { unsigned int i; + clean_status_invalidate_current_proof(istate); git_attr_invalidate_all(); untracked_cache_invalidate_all(istate); for (i = 0; i < istate->cache_nr; i++) @@ -655,12 +658,15 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) return; } pos = index_name_pos(istate, name, len); - fsmonitor_invalidate_attributes_path(istate, name); + attributes_may_have_changed = + fsmonitor_invalidate_attributes_path(istate, name); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); else nr_in_cone = handle_path_without_trailing_slash(istate, name, pos); + if (pos < 0 && nr_in_cone) + attributes_may_have_changed = 1; /* * If we did not find an exact match for this pathname or any @@ -670,10 +676,15 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) */ if (!nr_in_cone && repo_ignore_case(the_repository)) { nr_in_cone = handle_using_name_hash_icase(istate, name); - if (!nr_in_cone) + if (!nr_in_cone) { nr_in_cone = handle_using_dir_name_hash_icase( istate, name); + if (nr_in_cone) + attributes_may_have_changed = 1; + } } + if (attributes_may_have_changed) + clean_status_invalidate_current_proof(istate); if (nr_in_cone) trace_printf_key(&trace_fsmonitor, @@ -1137,6 +1148,14 @@ void refresh_fsmonitor(struct index_state *istate) (fsm_mode == FSMONITOR_MODE_IPC || !is_trivial); istate->fsmonitor_untracked_valid = 0; } else { + /* + * The applied delta carries an existing proof forward: + * tracked paths are now invalid in FSMN, while semantic + * events have already expired the proof itself. + */ + if (fsm_mode == FSMONITOR_MODE_IPC) + clean_status_advance_fsmonitor_config_token( + istate, last_update_token.buf); FREE_AND_NULL(istate->fsmonitor_last_update); istate->fsmonitor_last_update = strbuf_detach(&last_update_token, NULL); diff --git a/read-cache-ll.h b/read-cache-ll.h index 943ccc296a71d6..53d0d0c0bfc9f6 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -412,6 +412,7 @@ int remove_file_from_index(struct index_state *, const char *path); #define ADD_CACHE_IGNORE_ERRORS 4 #define ADD_CACHE_IGNORE_REMOVAL 8 #define ADD_CACHE_INTENT 16 +#define ADD_CACHE_TRACK_CLEAN_HISTORY 32 /* * These two are used to add the contents of the file at path * to the index, marking the working tree up-to-date by storing diff --git a/read-cache.c b/read-cache.c index 7e769d3e4ddb5f..be047c466ee740 100644 --- a/read-cache.c +++ b/read-cache.c @@ -729,6 +729,20 @@ static struct cache_entry *create_alias_ce(struct index_state *istate, return new_entry; } +static int same_persistent_add_entry(const struct cache_entry *a, + const struct cache_entry *b) +{ + const unsigned int flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + + return a && b && + ce_namelen(a) == ce_namelen(b) && + !memcmp(a->name, b->name, ce_namelen(a)) && + a->ce_mode == b->ce_mode && + oideq(&a->oid, &b->oid) && + ((a->ce_flags ^ b->ce_flags) & flags) == 0; +} + void set_object_name_for_intent_to_add_entry(struct cache_entry *ce) { struct object_id oid; @@ -739,7 +753,8 @@ void set_object_name_for_intent_to_add_entry(struct cache_entry *ce) int add_to_index(struct index_state *istate, const char *path, struct stat *st, int flags) { - int namelen, was_same; + int namelen, was_same, logical_same; + int cache_nr = istate->cache_nr; mode_t st_mode = st->st_mode; struct cache_entry *ce, *alias = NULL; unsigned ce_option = CE_MATCH_IGNORE_VALID|CE_MATCH_IGNORE_SKIP_WORKTREE|CE_MATCH_RACY_IS_DIRTY; @@ -824,12 +839,22 @@ int add_to_index(struct index_state *istate, const char *path, struct stat *st, !ce_stage(alias) && oideq(&alias->oid, &ce->oid) && ce->ce_mode == alias->ce_mode); + logical_same = same_persistent_add_entry(alias, ce); + + if (!pretend && (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && + !logical_same) + clean_status_invalidate_current_proof(istate); if (pretend) discard_cache_entry(ce); - else if (add_index_entry(istate, ce, add_option)) { - discard_cache_entry(ce); - return error(_("unable to add '%s' to index"), path); + else { + if (add_index_entry(istate, ce, add_option)) { + discard_cache_entry(ce); + return error(_("unable to add '%s' to index"), path); + } + if ((flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && + cache_nr != istate->cache_nr) + clean_status_invalidate_current_proof(istate); } if (verbose && !was_same) printf("add '%s'\n", path); @@ -2840,6 +2865,7 @@ enum write_extensions { WRITE_RESOLVE_UNDO_EXTENSION = 1<<2, WRITE_UNTRACKED_CACHE_EXTENSION = 1<<3, WRITE_FSMONITOR_EXTENSION = 1<<4, + WRITE_FSCF_EXTENSION = 1<<5, }; #define WRITE_ALL_EXTENSIONS ((enum write_extensions)-1) @@ -3107,6 +3133,19 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, goto out; } } + if (write_extensions & WRITE_FSCF_EXTENSION && + clean_status_should_write_fsmonitor_config(istate)) { + strbuf_reset(&sb); + clean_status_write_fsmonitor_config(&sb, istate); + err = write_index_ext_header(f, eoie_c, + CACHE_EXT_FSMONITOR_CONFIG, + sb.len) < 0; + hashwrite(f, sb.buf, sb.len); + if (err) { + ret = -1; + goto out; + } + } if (istate->sparse_index) { if (write_index_ext_header(f, eoie_c, CACHE_EXT_SPARSE_DIRECTORIES, 0) < 0) { ret = -1; @@ -4064,8 +4103,12 @@ static void update_callback(struct diff_queue_struct *q, case DIFF_STATUS_DELETED: if (data->flags & ADD_CACHE_IGNORE_REMOVAL) break; - if (!(data->flags & ADD_CACHE_PRETEND)) + if (!(data->flags & ADD_CACHE_PRETEND)) { + if (data->flags & ADD_CACHE_TRACK_CLEAN_HISTORY) + clean_status_invalidate_current_proof( + data->index); remove_file_from_index(data->index, path); + } if (data->flags & (ADD_CACHE_PRETEND|ADD_CACHE_VERBOSE)) printf(_("remove '%s'\n"), path); break; diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 372b55b419d6b4..5228c2065e4404 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -2,13 +2,19 @@ #include "test-tool.h" #include "attr.h" +#include "attr-fingerprint.h" +#include "attr-manifest.h" +#include "clean-status.h" +#include "clean-status-internal.h" #include "config.h" #include "dir.h" #include "environment.h" #include "ewah/ewok.h" #include "ewah/ewok_rlw.h" #include "fsmonitor.h" +#include "fsmonitor-clean-proof.h" #include "fsmonitor-ll.h" +#include "lockfile.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" @@ -237,6 +243,114 @@ static int test_fsmn_parser(void) return 0; } +static int write_test_index(void) +{ + struct lock_file index_lock = LOCK_INIT; + + repo_hold_locked_index(the_repository, &index_lock, LOCK_DIE_ON_ERROR); + if (write_locked_index(the_repository->index, &index_lock, COMMIT_LOCK)) + return error("unable to write test index"); + return 0; +} + +static int test_fscf_history_is_coherent(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->disk_config_valid && + !state->disk_config_invalid && state->disk_semantic_valid && + state->disk_attr_valid && state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + state->disk_config_raw.len && state->initial_coherent; +} + +static int test_fscf_config(const char *key, const char *value, + const struct config_context *ctx, void *cb) +{ + struct clean_status_config_digest *config = cb; + + clean_status_config_add(config, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + +static int test_fscf_history(void) +{ + struct clean_status_config_digest config; + struct attr_fingerprint attrs; + struct attr_manifest_writer writer; + struct strbuf manifest = STRBUF_INIT; + struct strbuf encoded = STRBUF_INIT; + unsigned char index_hash[GIT_MAX_RAWSZ] = { 0 }; + const char *token; + struct fsmonitor_clean_proof proof = { + .flags = FSMONITOR_CLEAN_PROOF_ALL, + }; + const struct git_hash_algo *algo; + int ret = 1; + + setup_git_directory(the_repository); + algo = the_repository->hash_algo; + clean_status_config_init(&config, algo); + repo_config(the_repository, test_fscf_config, &config); + clean_status_config_final(&config); + clean_status_set_config_digest(the_repository, &config); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + token = "fscf-test-token"; + if (attr_fingerprint_repository(the_repository, &attrs)) + return error("unable to fingerprint attribute sources"); + + attr_manifest_writer_init(&writer, &manifest, algo); + if (attr_manifest_writer_add(&writer, ".gitattributes", + ATTR_MANIFEST_INDEX, index_hash)) + return error("unable to write test attribute manifest"); + proof.config_hash = config.hash; + proof.semantic_hash = config.semantic_hash; + proof.attr_hash = attrs.content_hash; + proof.token = (const unsigned char *)token; + proof.token_len = strlen(token); + proof.attr_manifest = (const unsigned char *)manifest.buf; + proof.attr_manifest_len = manifest.len; + if (fsmonitor_clean_proof_write(&encoded, &proof, algo)) + return error("unable to write test clean proof"); + + FREE_AND_NULL(the_repository->index->fsmonitor_last_update); + the_repository->index->fsmonitor_last_update = xstrdup(token); + the_repository->index->fsmonitor_token_valid = 1; + clean_status_read_fsmonitor_config(the_repository->index, + encoded.buf, encoded.len); + clean_status_prepare_fsmonitor_config(the_repository->index); + if (!test_fscf_history_is_coherent(the_repository->index)) + return error("test clean proof was not coherent"); + if (write_test_index()) + goto done; + + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) + return error("unable to reread test index"); + if (!test_fscf_history_is_coherent(the_repository->index)) + return error("FSCF did not survive an index round trip"); + + clean_status_invalidate_current_manifest(the_repository->index); + if (write_test_index()) + goto done; + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) + return error("unable to reread preserved test index"); + if (clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index)) + return error("generic rewrite retained FSCF epoch bindings"); + if (!clean_status_has_worktree_manifest_history(the_repository->index)) + return error("generic rewrite discarded FSCF manifest history"); + ret = 0; + +done: + strbuf_release(&encoded); + strbuf_release(&manifest); + return ret; +} + static int test_fsmonitor_directory_attributes(void) { struct attr_check *check; @@ -288,6 +402,8 @@ int cmd__read_cache(int argc, const char **argv) return test_fsuc_parser(); if (argc == 2 && !strcmp(argv[1], "--test-fsmn-parser")) return test_fsmn_parser(); + if (argc == 2 && !strcmp(argv[1], "--test-fscf-round-trip")) + return test_fscf_history(); if (argc == 2 && !strcmp(argv[1], "--test-fsmonitor-directory-attributes")) return test_fsmonitor_directory_attributes(); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 6b1fdd3bcbbc6f..90228af9d007d2 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3,6 +3,7 @@ test_description='git status with file system watcher' . ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh # Note, after "git reset --hard HEAD" no extensions exist other than 'TREE' # "git update-index --fsmonitor" can be used to get the extension written @@ -68,6 +69,18 @@ test_expect_success 'FSUC parser fails closed' ' test-tool read-cache --test-fsuc-parser ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'FSCF survives index I/O and generic rewrites' ' + test_when_finished "rm -rf fscf-round-trip" && + test_create_repo fscf-round-trip && + ( + cd fscf-round-trip && + test_commit base tracked && + test-tool read-cache --test-fscf-round-trip && + test_grep FSCF .git/index + ) +' + test_expect_success 'hook parser ignores empty path records' ' test_when_finished "rm -rf empty-hook-record" && test_create_repo empty-hook-record && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index dd9badbff281a7..a72cd29af06487 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -3,6 +3,7 @@ test_description='built-in file system watcher' . ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh if ! test_have_prereq FSMONITOR_DAEMON then diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 49795b05545ecf..1f3072b2a239cf 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -118,3 +118,84 @@ void test_clean_status_history__adopts_only_coherent_proofs(void) cl_assert(!state->initial_coherent); fixture_release(&fixture); } + +void test_clean_status_history__preserves_unbound_manifests(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct history_fixture fixture; + struct clean_status_state *state; + struct fsmonitor_clean_proof parsed; + struct strbuf rewritten = STRBUF_INIT; + + fixture_init(&fixture, algo); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(clean_status_should_write_fsmonitor_config(&fixture.istate)); + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, FSMONITOR_CLEAN_PROOF_ALL); + + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:3"); + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:2"); + state->current_config_valid = 0; + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + strbuf_release(&rewritten); + fixture_release(&fixture); +} + +void test_clean_status_history__advances_only_current_proofs(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct history_fixture fixture; + struct clean_status_state *state; + struct fsmonitor_clean_proof parsed; + struct strbuf rewritten = STRBUF_INIT; + + fixture_init(&fixture, algo); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + + clean_status_advance_fsmonitor_config_token( + &fixture.istate, "builtin:1:3"); + cl_assert_equal_s(state->config_revalidated_token, "builtin:1:3"); + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:3"); + cl_assert(clean_status_should_write_fsmonitor_config(&fixture.istate)); + + clean_status_invalidate_current_proof(&fixture.istate); + cl_assert(!state->config_revalidated); + cl_assert(!state->initial_coherent); + clean_status_advance_fsmonitor_config_token( + &fixture.istate, "builtin:1:4"); + cl_assert_equal_s(state->config_revalidated_token, "builtin:1:3"); + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:1:4"); + clean_status_write_fsmonitor_config(&rewritten, &fixture.istate); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, rewritten.buf, rewritten.len, algo), 0); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + + strbuf_release(&rewritten); + fixture_release(&fixture); +} From 163c8358c941710323f23c3689ef46ff6ef5f433 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:46:00 -0700 Subject: [PATCH 178/403] fsmonitor: add an untracked-cache token extension FSMN identifies the token associated with tracked fsmonitor state, but the independently serialized UNTR extension cannot identify the provider boundary associated with its directory snapshot. The mere presence of both extensions cannot prove that their states agree. Define and document FSUC as a versioned optional index extension containing one NUL-terminated provider token. Register its reader with index-extension dispatch; reject empty tokens, tokens longer than 4 KiB, duplicate records, unsupported versions, truncation, and trailing data before publishing state. Provide the matching serializer and release the retained token with the index. Add a read-cache helper regression for a valid record, serializer round trip, duplicate, and truncated record. Register that helper in t/t7519-status-fsmonitor.sh. The format is independently testable; deciding when its token authenticates UNTR is a separate change. Signed-off-by: Taylor Blau --- Documentation/gitformat-index.adoc | 13 ++++ fsmonitor-ll.h | 5 ++ fsmonitor.c | 48 ++++++++++++ read-cache-ll.h | 5 +- read-cache.c | 5 ++ t/helper/test-read-cache.c | 116 +++++++++++++++++++++-------- t/t7519-status-fsmonitor.sh | 4 + 7 files changed, 162 insertions(+), 34 deletions(-) diff --git a/Documentation/gitformat-index.adoc b/Documentation/gitformat-index.adoc index f6a427cb495990..aaa9c29b4653b8 100644 --- a/Documentation/gitformat-index.adoc +++ b/Documentation/gitformat-index.adoc @@ -366,6 +366,19 @@ The remaining data of each directory block is grouped by type: - An ewah bitmap, the n-th bit indicates whether the n-th index entry is not CE_FSMONITOR_VALID. +== File System Monitor untracked-cache token + + The file system monitor untracked-cache token records the provider + token associated with an untracked-cache snapshot. The signature for + this extension is { 'F', 'S', 'U', 'C' }. + + The extension consists of: + + - 32-bit version number: the current version is 1. + + - A NUL-terminated string containing the opaque file system monitor + token associated with the untracked-cache data. + == End of Index Entry The End of Index Entry (EOIE) is used to locate the end of the variable diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 7f78ad21c8d0b0..1028e630e9a912 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -15,6 +15,11 @@ extern struct trace_key trace_fsmonitor; */ int read_fsmonitor_extension(struct index_state *istate, const void *data, unsigned long sz); +int read_fsmonitor_untracked_extension(struct index_state *istate, + const void *data, unsigned long sz); +void write_fsmonitor_untracked_extension(struct strbuf *sb, + struct index_state *istate); + /* * Fill the fsmonitor_dirty ewah bits with their state from the index, * before it is split during writing. diff --git a/fsmonitor.c b/fsmonitor.c index ebec5620dbb630..26d00b5d912dfb 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -198,6 +198,54 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, return 0; } +#define FSMONITOR_UNTRACKED_EXTENSION_VERSION 1 + +int read_fsmonitor_untracked_extension(struct index_state *istate, + const void *data, unsigned long sz) +{ + const char *p = data; + const char *nul; + uint32_t version; + + if (istate->fsmonitor_untracked_extension_seen) + goto invalid; + istate->fsmonitor_untracked_extension_seen = 1; + if (sz < sizeof(version) + 2) + goto invalid; + version = get_be32(p); + p += sizeof(version); + sz -= sizeof(version); + if (version != FSMONITOR_UNTRACKED_EXTENSION_VERSION) + goto invalid; + nul = memchr(p, '\0', sz); + if (!nul || nul == p || (size_t)(nul - p + 1) != sz || + nul - p > FSMONITOR_TOKEN_MAX) + goto invalid; + + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = xstrdup(p); + return 0; + +invalid: + istate->fsmonitor_untracked_extension_seen = 1; + istate->fsmonitor_untracked_extension_invalid = 1; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/invalid-extension", 1); + return 0; +} + +void write_fsmonitor_untracked_extension(struct strbuf *sb, + struct index_state *istate) +{ + uint32_t version; + + put_be32(&version, FSMONITOR_UNTRACKED_EXTENSION_VERSION); + strbuf_add(sb, &version, sizeof(version)); + strbuf_addstr(sb, istate->fsmonitor_last_update); + strbuf_addch(sb, '\0'); +} + void fill_fsmonitor_bitmap(struct index_state *istate) { unsigned int i, skipped = 0; diff --git a/read-cache-ll.h b/read-cache-ll.h index d728e050e5fb23..f71889101e2775 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -183,13 +183,16 @@ struct index_state { updated_workdir : 1, updated_skipworktree : 1, fsmonitor_has_run_once : 1, - fsmonitor_extension_seen : 1; + fsmonitor_extension_seen : 1, + fsmonitor_untracked_extension_seen : 1, + fsmonitor_untracked_extension_invalid : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; struct object_id oid; struct untracked_cache *untracked; char *fsmonitor_last_update; + char *fsmonitor_untracked_token; struct ewah_bitmap *fsmonitor_dirty; struct mem_pool *ce_mem_pool; struct progress *progress; diff --git a/read-cache.c b/read-cache.c index b853d79c72aa5f..43d4b15d54ad7e 100644 --- a/read-cache.c +++ b/read-cache.c @@ -71,6 +71,7 @@ #define CACHE_EXT_LINK 0x6c696e6b /* "link" */ #define CACHE_EXT_UNTRACKED 0x554E5452 /* "UNTR" */ #define CACHE_EXT_FSMONITOR 0x46534D4E /* "FSMN" */ +#define CACHE_EXT_FSMONITOR_UNTRACKED 0x46535543 /* "FSUC" */ #define CACHE_EXT_ENDOFINDEXENTRIES 0x454F4945 /* "EOIE" */ #define CACHE_EXT_INDEXENTRYOFFSETTABLE 0x49454F54 /* "IEOT" */ #define CACHE_EXT_SPARSE_DIRECTORIES 0x73646972 /* "sdir" */ @@ -1777,6 +1778,9 @@ static int read_index_extension(struct index_state *istate, case CACHE_EXT_FSMONITOR: read_fsmonitor_extension(istate, data, sz); break; + case CACHE_EXT_FSMONITOR_UNTRACKED: + read_fsmonitor_untracked_extension(istate, data, sz); + break; case CACHE_EXT_ENDOFINDEXENTRIES: case CACHE_EXT_INDEXENTRYOFFSETTABLE: /* already handled in do_read_index() */ @@ -2466,6 +2470,7 @@ void release_index(struct index_state *istate) free_name_hash(istate); cache_tree_free(&(istate->cache_tree)); free(istate->fsmonitor_last_update); + free(istate->fsmonitor_untracked_token); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 7034e30c80d2ac..4698265f5c090f 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -13,6 +13,87 @@ #include "setup.h" #include "strbuf.h" +static int test_fsmonitor_content_recovery(const char *path) +{ + struct index_state *istate; + struct cache_entry *ce; + struct stat_data empty = { 0 }; + struct stat st; + int pos; + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); + if (repo_read_index(the_repository) < 0) + return error("unable to read test index"); + istate = the_repository->index; + pos = index_name_pos(istate, path, strlen(path)); + if (pos < 0) + return error("path is not indexed: %s", path); + ce = istate->cache[pos]; + if (lstat(path, &st)) + return error_errno("unable to stat indexed path"); + + fsmonitor_invalidate_cache_entry(ce); + if (memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("invalidation did not poison cached stat data"); + if (ie_match_stat_with_content_check(istate, ce, &st, 0)) + return error("clean content did not match"); + if (!memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return error("verified clean entry retained poisoned stat data"); + if (!(ce->ce_flags & CE_UPDATE_IN_BASE) || + !(istate->cache_changed & CE_ENTRY_CHANGED)) + return error("verified stat refresh was not marked for persistence"); + return 0; +} + +static int fsuc_failed_closed(const struct index_state *istate) +{ + return istate->fsmonitor_untracked_extension_seen && + istate->fsmonitor_untracked_extension_invalid && + !istate->fsmonitor_untracked_token; +} + +static int test_fsuc_parser(void) +{ + struct index_state duplicate = INDEX_STATE_INIT(the_repository); + struct index_state truncated = INDEX_STATE_INIT(the_repository); + struct strbuf encoded = STRBUF_INIT; + struct strbuf written = STRBUF_INIT; + uint32_t version; + + put_be32(&version, 1); + strbuf_add(&encoded, &version, sizeof(version)); + strbuf_addstr(&encoded, "token"); + strbuf_addch(&encoded, '\0'); + read_fsmonitor_untracked_extension( + &duplicate, encoded.buf, encoded.len); + if (duplicate.fsmonitor_untracked_extension_invalid || + !duplicate.fsmonitor_untracked_token || + strcmp(duplicate.fsmonitor_untracked_token, "token")) + return error("valid FSUC was not published"); + + duplicate.fsmonitor_last_update = xstrdup("token"); + write_fsmonitor_untracked_extension(&written, &duplicate); + if (written.len != encoded.len || + memcmp(written.buf, encoded.buf, encoded.len)) + return error("FSUC did not round-trip"); + read_fsmonitor_untracked_extension( + &duplicate, encoded.buf, encoded.len); + if (!fsuc_failed_closed(&duplicate)) + return error("duplicate FSUC did not fail closed"); + + truncated.fsmonitor_untracked_token = xstrdup("old"); + read_fsmonitor_untracked_extension( + &truncated, encoded.buf, sizeof(version)); + if (!fsuc_failed_closed(&truncated)) + return error("truncated FSUC was partially published"); + + free(duplicate.fsmonitor_last_update); + strbuf_release(&written); + strbuf_release(&encoded); + return 0; +} + static void wrap_fsmn_ewah(struct strbuf *out, const struct strbuf *ewah) { uint32_t value; @@ -61,39 +142,6 @@ static void make_raw_fsmn(struct strbuf *out, uint32_t bit_size, strbuf_release(&ewah); } -static int test_fsmonitor_content_recovery(const char *path) -{ - struct index_state *istate; - struct cache_entry *ce; - struct stat_data empty = { 0 }; - struct stat st; - int pos; - - setup_git_directory(the_repository); - repo_config(the_repository, git_default_config, NULL); - if (repo_read_index(the_repository) < 0) - return error("unable to read test index"); - istate = the_repository->index; - pos = index_name_pos(istate, path, strlen(path)); - if (pos < 0) - return error("path is not indexed: %s", path); - ce = istate->cache[pos]; - if (lstat(path, &st)) - return error_errno("unable to stat indexed path"); - - fsmonitor_invalidate_cache_entry(ce); - if (memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) - return error("invalidation did not poison cached stat data"); - if (ie_match_stat_with_content_check(istate, ce, &st, 0)) - return error("clean content did not match"); - if (!memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) - return error("verified clean entry retained poisoned stat data"); - if (!(ce->ce_flags & CE_UPDATE_IN_BASE) || - !(istate->cache_changed & CE_ENTRY_CHANGED)) - return error("verified stat refresh was not marked for persistence"); - return 0; -} - static int fsmn_failed_closed(const struct index_state *istate) { return istate->fsmonitor_extension_seen && @@ -218,6 +266,8 @@ int cmd__read_cache(int argc, const char **argv) if (argc == 3 && !strcmp(argv[1], "--test-fsmonitor-content-recovery")) return test_fsmonitor_content_recovery(argv[2]); + if (argc == 2 && !strcmp(argv[1], "--test-fsuc-parser")) + return test_fsuc_parser(); if (argc == 2 && !strcmp(argv[1], "--test-fsmn-parser")) return test_fsmn_parser(); if (argc == 2 && diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index f257a05f92930e..f29bea912efd18 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -64,6 +64,10 @@ test_expect_success 'FSMN parser fails closed' ' test-tool read-cache --test-fsmn-parser ' +test_expect_success 'FSUC parser fails closed' ' + test-tool read-cache --test-fsuc-parser +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From 6541294001cb1c07105d23935c3ad5135b47b31d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:53:53 -0700 Subject: [PATCH 179/403] read-cache: preserve validated fsmonitor history across indexes move_index_extensions() transfers extensions to a replacement index, but index-owned FSCF history would otherwise remain on the old state. A generic rewrite could silently discard a validated manifest, while sharing its storage would create a lifetime hazard. Copy only a parsed, valid serialized record into independently owned destination storage. Reload the saved manifest through its validated parser, copy the existing token and hashes, and leave an absent or invalid source untouched. Invoke the transfer from move_index_extensions() so ordinary index release owns each copy. Extend the existing history unit suite with a real extension transfer. Verify the copied record and manifest, invalidate the source, reject a second transfer from that source, and confirm that the independent first destination remains valid. Signed-off-by: Taylor Blau --- builtin/checkout.c | 8 +++ builtin/read-tree.c | 51 ++++++++++++-- builtin/reset.c | 15 +++-- clean-status-history.c | 95 +++++++++++++++++++++++++++ clean-status.h | 4 ++ read-cache.c | 1 + t/unit-tests/u-clean-status-history.c | 40 ++++++++++- unpack-trees.c | 4 ++ 8 files changed, 203 insertions(+), 15 deletions(-) diff --git a/builtin/checkout.c b/builtin/checkout.c index 2992dfe0e99047..c18b8ce85f2a51 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -899,6 +899,14 @@ static int merge_working_tree(const struct checkout_opts *opts, struct tree *new_tree; repo_hold_locked_index(the_repository, &lock_file, LOCK_DIE_ON_ERROR); + /* + * A discarding switch may rewrite only worktree/stat state when the + * target tree matches the index. Let unpack_trees() transfer the + * proof only after it proves that the rebuilt index is identical. + */ + if (opts->discard_changes) + clean_status_set_config_digest(the_repository, + &opts->clean_digest); if (repo_read_index_preload(the_repository, NULL, 0) < 0) { rollback_lock_file(&lock_file); return error(_("index file corrupt")); diff --git a/builtin/read-tree.c b/builtin/read-tree.c index 999a82ecdfd737..8e3b023271723c 100644 --- a/builtin/read-tree.c +++ b/builtin/read-tree.c @@ -5,6 +5,8 @@ */ #define USE_THE_REPOSITORY_VARIABLE #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -46,10 +48,11 @@ static const char * const read_tree_usage[] = { NULL }; -static int index_output_cb(const struct option *opt UNUSED, const char *arg, +static int index_output_cb(const struct option *opt, const char *arg, int unset) { BUG_ON_OPT_NEG(unset); + *(int *)opt->value = 1; set_alternate_index_output(arg); return 0; } @@ -100,12 +103,14 @@ static int debug_merge(const struct cache_entry * const *stages, } static int git_read_tree_config(const char *var, const char *value, - const struct config_context *ctx, void *cb) + const struct config_context *ctx, void *data) { + clean_status_config_add(data, var, value, ctx); + if (!strcmp(var, "submodule.recurse")) - return git_default_submodule_config(var, value, cb); + return git_default_submodule_config(var, value, NULL); - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } int cmd_read_tree(int argc, @@ -113,7 +118,10 @@ int cmd_read_tree(int argc, const char *cmd_prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; int i, stage = 0; + int index_output = 0; + int preserve_history = 0; struct object_id oid; struct tree_desc t[MAX_UNPACK_TREES]; struct unpack_trees_options opts; @@ -121,7 +129,7 @@ int cmd_read_tree(int argc, struct lock_file lock_file = LOCK_INIT; const struct option read_tree_options[] = { OPT__SUPER_PREFIX(&opts.super_prefix), - OPT_CALLBACK_F(0, "index-output", NULL, N_("file"), + OPT_CALLBACK_F(0, "index-output", &index_output, N_("file"), N_("write resulting index to "), PARSE_OPT_NONEG, index_output_cb), OPT_BOOL(0, "empty", &read_empty, @@ -169,7 +177,12 @@ int cmd_read_tree(int argc, opts.src_index = the_repository->index; opts.dst_index = the_repository->index; - repo_config(the_repository, git_read_tree_config, NULL); + show_usage_with_options_if_asked(argc, argv, + read_tree_usage, read_tree_options); + + clean_status_config_init(&clean_digest, the_repository->hash_algo); + repo_config(the_repository, git_read_tree_config, &clean_digest); + clean_status_config_final(&clean_digest); argc = parse_options(argc, argv, cmd_prefix, read_tree_options, read_tree_usage, 0); @@ -190,6 +203,23 @@ int cmd_read_tree(int argc, repo_hold_locked_index(the_repository, &lock_file, LOCK_DIE_ON_ERROR); + /* + * A one-tree merge or reset can rewrite only stat and fsmonitor + * state when its tree matches the existing index. Attach history + * before reading that index; unpack_trees() will transfer it only + * after proving that the result has the same logical entries. + */ + if (argc == 1 && !read_empty && !opts.prefix && + (opts.reset || opts.merge) && + !opts.dry_run && + !opts.skip_sparse_checkout && !opts.internal.debug_unpack && + !opts.trivial_merges_only && !opts.aggressive && + !opts.super_prefix && !index_output && + !should_update_submodules()) { + preserve_history = 1; + clean_status_set_config_digest(the_repository, &clean_digest); + } + /* * NEEDSWORK * @@ -200,10 +230,17 @@ int cmd_read_tree(int argc, */ if (opts.reset || opts.merge || opts.prefix) { - if (repo_read_index_unmerged(the_repository) && (opts.prefix || opts.merge)) + int unmerged = repo_read_index_unmerged(the_repository); + + if (preserve_history && unmerged) + clean_status_invalidate_current_proof( + the_repository->index); + if (unmerged && (opts.prefix || opts.merge)) die(_("You need to resolve your current index first")); stage = opts.merge = 1; } + if (preserve_history && the_repository->index->resolve_undo) + clean_status_invalidate_current_proof(the_repository->index); resolve_undo_clear_index(the_repository->index); for (i = 0; i < argc; i++) { diff --git a/builtin/reset.c b/builtin/reset.c index a9d5183ab848d8..20a81a249ad472 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -488,14 +488,15 @@ int cmd_reset(int argc, die(_("the option '%s' requires '%s'"), "-N", "--mixed"); /* - * A no-path mixed reset is a candidate for a stat-only rewrite even - * when its target commit differs from HEAD. Attach history early - * enough for the initial index read, but keep it only if - * read_from_tree() confirms that no logical entries changed. + * A no-path mixed or hard reset is a candidate for a stat-only + * rewrite even when its target commit differs from HEAD. Attach + * history early enough for the initial index read. Mixed reset + * checks its in-place result below; hard reset lets unpack_trees() + * transfer only an equal logical index. */ - if (reset_type == MIXED && !pathspec.nr && !intent_to_add && - !unborn) { - preserve_mixed_history = 1; + if ((reset_type == MIXED || reset_type == HARD) && + !pathspec.nr && !intent_to_add && !unborn) { + preserve_mixed_history = reset_type == MIXED; clean_status_set_config_digest(the_repository, &clean_digest); } diff --git a/clean-status-history.c b/clean-status-history.c index e6cdfc8f549826..6369472b287ac8 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -177,3 +177,98 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, out, state->disk_config_raw.buf, state->disk_config_raw.len, algo)) BUG("cannot preserve validated fsmonitor clean proof"); } + +void clean_status_copy_fsmonitor_history(struct index_state *dst, + const struct index_state *src) +{ + const struct clean_status_state *src_state = src->clean_status; + struct clean_status_state *dst_state; + + if (!src_state || !src_state->disk_config_valid || + src_state->disk_config_invalid || !src_state->disk_config_raw.len) + return; + dst_state = clean_status_get_state(dst); + FREE_AND_NULL(dst_state->disk_config_token); + strbuf_reset(&dst_state->disk_config_raw); + dst_state->disk_config_token = + xstrdup_or_null(src_state->disk_config_token); + strbuf_addbuf(&dst_state->disk_config_raw, + &src_state->disk_config_raw); + memcpy(dst_state->disk_config_hash, src_state->disk_config_hash, + dst->repo->hash_algo->rawsz); + memcpy(dst_state->disk_semantic_hash, src_state->disk_semantic_hash, + dst->repo->hash_algo->rawsz); + memcpy(dst_state->disk_attr_hash, src_state->disk_attr_hash, + dst->repo->hash_algo->rawsz); + if (clean_status_manifest_load( + &dst_state->manifest, src_state->manifest.disk.buf, + src_state->manifest.disk.len, src_state->manifest.disk_flags, + dst->repo->hash_algo)) + BUG("cannot copy validated clean-status manifest"); + dst_state->disk_config_seen = 1; + dst_state->disk_config_valid = 1; + dst_state->disk_semantic_valid = src_state->disk_semantic_valid; + dst_state->disk_attr_valid = src_state->disk_attr_valid; + dst_state->disk_config_invalid = 0; +} + +static int same_persistent_index_contents(const struct index_state *a, + const struct index_state *b) +{ + const unsigned int semantic_flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + const unsigned int transient_flags = + CE_UPDATE | CE_REMOVE | CE_ADDED | CE_WT_REMOVE | + CE_CONFLICTED | CE_UNPACKED | CE_NEW_SKIP_WORKTREE | + CE_MATCHED | CE_STRIP_NAME; + unsigned int i; + + if (a->repo != b->repo || a->split_index || b->split_index || + a->sparse_index || b->sparse_index || + a->cache_nr != b->cache_nr) + return 0; + + for (i = 0; i < a->cache_nr; i++) { + const struct cache_entry *ce_a = a->cache[i]; + const struct cache_entry *ce_b = b->cache[i]; + + if (ce_namelen(ce_a) != ce_namelen(ce_b) || + memcmp(ce_a->name, ce_b->name, ce_namelen(ce_a) + 1) || + ce_a->ce_mode != ce_b->ce_mode || + !oideq(&ce_a->oid, &ce_b->oid) || + ((ce_a->ce_flags ^ ce_b->ce_flags) & semantic_flags) || + ((ce_a->ce_flags | ce_b->ce_flags) & transient_flags)) + return 0; + } + + return 1; +} + +int clean_status_transfer_current_proof_if_same_index( + struct index_state *dst, const struct index_state *src) +{ + struct strbuf proof = STRBUF_INIT; + int transferred; + + if (!current_proof_is_writable(src) || + !src->fsmonitor_last_update || + !dst->fsmonitor_last_update || + strcmp(src->fsmonitor_last_update, dst->fsmonitor_last_update) || + !same_persistent_index_contents(dst, src)) + return 0; + + /* + * Reparse the current proof as the destination's disk proof, then + * reattach the current command's digest. This copies only a proof + * which the destination's identical logical entries can support. + */ + clean_status_write_fsmonitor_config(&proof, src); + dst->fsmonitor_token_valid = src->fsmonitor_token_valid; + clean_status_read_fsmonitor_config(dst, proof.buf, proof.len); + clean_status_attach_config(dst); + clean_status_prepare_fsmonitor_config(dst); + transferred = current_proof_is_writable(dst); + strbuf_release(&proof); + + return transferred; +} diff --git a/clean-status.h b/clean-status.h index 6c6db51b86a229..02e3d6022ae2c3 100644 --- a/clean-status.h +++ b/clean-status.h @@ -29,6 +29,10 @@ int clean_status_should_write_fsmonitor_config( const struct index_state *istate); void clean_status_write_fsmonitor_config(struct strbuf *out, const struct index_state *istate); +void clean_status_copy_fsmonitor_history(struct index_state *dst, + const struct index_state *src); +int clean_status_transfer_current_proof_if_same_index( + struct index_state *dst, const struct index_state *src); void clean_status_release(struct index_state *istate); diff --git a/read-cache.c b/read-cache.c index be047c466ee740..e0a5fcfccabd0e 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3600,6 +3600,7 @@ void *read_blob_data_from_index(struct index_state *istate, void move_index_extensions(struct index_state *dst, struct index_state *src) { + clean_status_copy_fsmonitor_history(dst, src); dst->untracked = src->untracked; src->untracked = NULL; dst->cache_tree = src->cache_tree; diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 1f3072b2a239cf..4a39bfa8568811 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -159,7 +159,6 @@ void test_clean_status_history__preserves_unbound_manifests(void) strbuf_release(&rewritten); fixture_release(&fixture); } - void test_clean_status_history__advances_only_current_proofs(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -199,3 +198,42 @@ void test_clean_status_history__advances_only_current_proofs(void) strbuf_release(&rewritten); fixture_release(&fixture); } + +void test_clean_status_history__copies_validated_history(void) +{ + struct history_fixture fixture; + struct repository dst_repo = { + .hash_algo = &hash_algos[GIT_HASH_SHA1], + }; + struct index_state dst = INDEX_STATE_INIT(&dst_repo); + struct index_state invalid_dst = INDEX_STATE_INIT(&dst_repo); + struct clean_status_state *dst_state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA1]); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + move_index_extensions(&dst, &fixture.istate); + dst_state = dst.clean_status; + cl_assert(dst_state != NULL); + cl_assert(dst_state->disk_config_valid); + cl_assert(!dst_state->disk_config_invalid); + cl_assert(dst_state->disk_semantic_valid); + cl_assert(dst_state->disk_attr_valid); + cl_assert(dst_state->manifest.disk_valid); + cl_assert_equal_i(dst_state->manifest.disk_flags, + FSMONITOR_CLEAN_PROOF_ALL); + cl_assert_equal_i(dst_state->disk_config_raw.len, + fixture.encoded.len); + + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + move_index_extensions(&invalid_dst, &fixture.istate); + cl_assert(!invalid_dst.clean_status); + cl_assert(dst_state->disk_config_valid); + cl_assert_equal_i(dst_state->disk_config_raw.len, + fixture.encoded.len); + + release_index(&invalid_dst); + release_index(&dst); + fixture_release(&fixture); +} diff --git a/unpack-trees.c b/unpack-trees.c index 44d3567c83844b..06bcb0ee9bff0e 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -14,6 +14,7 @@ #include "tree.h" #include "tree-walk.h" #include "cache-tree.h" +#include "clean-status.h" #include "unpack-trees.h" #include "progress.h" #include "refs.h" @@ -2076,6 +2077,9 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options ret = check_updates(o, &o->internal.result) ? (-2) : 0; if (o->dst_index) { + if (!ret) + clean_status_transfer_current_proof_if_same_index( + &o->internal.result, o->src_index); move_index_extensions(&o->internal.result, o->src_index); if (!ret) { if (git_env_bool("GIT_TEST_CHECK_CACHE_TREE", 0) && From 7e2ede1ac1b38d02c6bf5bef03fbd75875d388ae Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:47:29 -0700 Subject: [PATCH 180/403] fsmonitor: bind untracked-cache state to its token A well-formed FSMN bitmap and a well-formed FSUC record still do not prove that a populated untracked-cache root and tracked entries were observed at the same provider boundary. Trusting mismatched tokens can suppress the directory validation needed to detect a change. After all index extensions have been read, trust a populated untracked-cache root only when a valid on-disk FSMN token matches its FSUC token. An absent cache or root needs no token pairing. Clear the untracked proof when either extension is invalid, and write FSUC beside FSMN only when an untracked cache, a current FSMN token, and valid untracked state are present. Extend the existing read-cache parser regression to check matching and mismatched tokens and to verify that a rejected FSMN clears tracked-token validity. An invalid pair continues through ordinary untracked-cache validation. Signed-off-by: Taylor Blau --- fsmonitor-ll.h | 1 + fsmonitor.c | 16 ++++++++++++++++ read-cache-ll.h | 2 ++ read-cache.c | 17 +++++++++++++++++ t/helper/test-read-cache.c | 22 ++++++++++++++++++++-- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 1028e630e9a912..8591a166665bd5 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -19,6 +19,7 @@ int read_fsmonitor_untracked_extension(struct index_state *istate, const void *data, unsigned long sz); void write_fsmonitor_untracked_extension(struct strbuf *sb, struct index_state *istate); +void prepare_fsmonitor_untracked(struct index_state *istate); /* * Fill the fsmonitor_dirty ewah bits with their state from the index, diff --git a/fsmonitor.c b/fsmonitor.c index 26d00b5d912dfb..7b90f80405909c 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -177,6 +177,7 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, ewah_free(istate->fsmonitor_dirty); istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); istate->fsmonitor_dirty = fsmonitor_dirty; + istate->fsmonitor_token_valid = 1; trace2_data_string("index", NULL, "extension/fsmn/read/token", istate->fsmonitor_last_update); @@ -187,6 +188,8 @@ int read_fsmonitor_extension(struct index_state *istate, const void *data, invalid: istate->fsmonitor_extension_seen = 1; + istate->fsmonitor_token_valid = 0; + istate->fsmonitor_untracked_valid = 0; FREE_AND_NULL(istate->fsmonitor_last_update); if (istate->fsmonitor_dirty) { ewah_free(istate->fsmonitor_dirty); @@ -229,6 +232,7 @@ int read_fsmonitor_untracked_extension(struct index_state *istate, invalid: istate->fsmonitor_untracked_extension_seen = 1; istate->fsmonitor_untracked_extension_invalid = 1; + istate->fsmonitor_untracked_valid = 0; FREE_AND_NULL(istate->fsmonitor_untracked_token); trace2_data_intmax("fsmonitor", istate->repo, "untracked/invalid-extension", 1); @@ -246,6 +250,18 @@ void write_fsmonitor_untracked_extension(struct strbuf *sb, strbuf_addch(sb, '\0'); } +void prepare_fsmonitor_untracked(struct index_state *istate) +{ + istate->fsmonitor_untracked_valid = + !istate->fsmonitor_untracked_extension_invalid && + (!istate->untracked || !istate->untracked->root || + (istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token))); +} + void fill_fsmonitor_bitmap(struct index_state *istate) { unsigned int i, skipped = 0; diff --git a/read-cache-ll.h b/read-cache-ll.h index f71889101e2775..db5b13d06b946c 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -183,7 +183,9 @@ struct index_state { updated_workdir : 1, updated_skipworktree : 1, fsmonitor_has_run_once : 1, + fsmonitor_token_valid : 1, fsmonitor_extension_seen : 1, + fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1; enum sparse_index_mode sparse_index; diff --git a/read-cache.c b/read-cache.c index 43d4b15d54ad7e..57cc73dec90a0e 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1982,6 +1982,7 @@ static void post_read_index_from(struct index_state *istate) check_ce_order(istate); tweak_untracked_cache(istate); tweak_split_index(istate); + prepare_fsmonitor_untracked(istate); tweak_fsmonitor(istate); } @@ -3077,6 +3078,22 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, goto out; } } + if (write_extensions & WRITE_FSMONITOR_EXTENSION && + istate->untracked && + istate->fsmonitor_last_update && + istate->fsmonitor_untracked_valid) { + strbuf_reset(&sb); + + write_fsmonitor_untracked_extension(&sb, istate); + err = write_index_ext_header(f, eoie_c, + CACHE_EXT_FSMONITOR_UNTRACKED, + sb.len) < 0; + hashwrite(f, sb.buf, sb.len); + if (err) { + ret = -1; + goto out; + } + } if (istate->sparse_index) { if (write_index_ext_header(f, eoie_c, CACHE_EXT_SPARSE_DIRECTORIES, 0) < 0) { ret = -1; diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 4698265f5c090f..372b55b419d6b4 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -3,6 +3,7 @@ #include "test-tool.h" #include "attr.h" #include "config.h" +#include "dir.h" #include "environment.h" #include "ewah/ewok.h" #include "ewah/ewok_rlw.h" @@ -57,6 +58,8 @@ static int test_fsuc_parser(void) { struct index_state duplicate = INDEX_STATE_INIT(the_repository); struct index_state truncated = INDEX_STATE_INIT(the_repository); + struct untracked_cache untracked = { 0 }; + struct untracked_cache_dir root = { 0 }; struct strbuf encoded = STRBUF_INIT; struct strbuf written = STRBUF_INIT; uint32_t version; @@ -77,6 +80,17 @@ static int test_fsuc_parser(void) if (written.len != encoded.len || memcmp(written.buf, encoded.buf, encoded.len)) return error("FSUC did not round-trip"); + duplicate.fsmonitor_token_valid = 1; + duplicate.untracked = &untracked; + untracked.root = &root; + prepare_fsmonitor_untracked(&duplicate); + if (!duplicate.fsmonitor_untracked_valid) + return error("matching FSMN and FSUC tokens were not paired"); + free(duplicate.fsmonitor_last_update); + duplicate.fsmonitor_last_update = xstrdup("other"); + prepare_fsmonitor_untracked(&duplicate); + if (duplicate.fsmonitor_untracked_valid) + return error("mismatched FSMN and FSUC tokens were paired"); read_fsmonitor_untracked_extension( &duplicate, encoded.buf, encoded.len); if (!fsuc_failed_closed(&duplicate)) @@ -145,7 +159,8 @@ static void make_raw_fsmn(struct strbuf *out, uint32_t bit_size, static int fsmn_failed_closed(const struct index_state *istate) { return istate->fsmonitor_extension_seen && - !istate->fsmonitor_last_update && !istate->fsmonitor_dirty; + !istate->fsmonitor_last_update && !istate->fsmonitor_dirty && + !istate->fsmonitor_token_valid; } static int check_invalid_fsmn(const struct strbuf *encoded, @@ -156,6 +171,7 @@ static int check_invalid_fsmn(const struct strbuf *encoded, invalid.cache_nr = 1; invalid.fsmonitor_last_update = xstrdup("old"); invalid.fsmonitor_dirty = ewah_new(); + invalid.fsmonitor_token_valid = 1; read_fsmonitor_extension(&invalid, encoded->buf, encoded->len); if (!fsmn_failed_closed(&invalid)) return error("%s FSMN was published", description); @@ -173,7 +189,8 @@ static int test_fsmn_parser(void) duplicate.cache_nr = truncated.cache_nr = 1; make_valid_fsmn(&encoded); read_fsmonitor_extension(&duplicate, encoded.buf, encoded.len); - if (!duplicate.fsmonitor_last_update || + if (!duplicate.fsmonitor_token_valid || + !duplicate.fsmonitor_last_update || strcmp(duplicate.fsmonitor_last_update, "token") || !duplicate.fsmonitor_dirty) return error("valid FSMN was not published"); @@ -183,6 +200,7 @@ static int test_fsmn_parser(void) truncated.fsmonitor_last_update = xstrdup("old"); truncated.fsmonitor_dirty = ewah_new(); + truncated.fsmonitor_token_valid = 1; read_fsmonitor_extension(&truncated, encoded.buf, encoded.len - 1); if (!fsmn_failed_closed(&truncated)) return error("truncated FSMN was partially published"); From 048aa3971d58723d7f54f39baed937db31fed074 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 20 Jul 2026 20:42:14 -0500 Subject: [PATCH 181/403] status: hold external attributes stable across refresh Fingerprinting an external attribute file while reading the index does not prevent the attribute parser from reopening a replaced file during preload or status collection. Cached stat data could then be evaluated with conversion rules that the original fingerprint did not cover. Capture the system, global, and info attribute bytes and namespace once and keep the immutable snapshot active from untracked-cache preload through collection. Parse snapshot lines with the ordinary attribute rules, including byte-order marks, embedded NULs, and line endings. End the snapshot and release its bounded source buffers with status. Make a failed capture or changed attribute content sticky and invalidate fsmonitor validity and the untracked cache before ordinary refresh. Preserve hook-provider behavior when semantic history is absent or only the namespace changes: hooks have no closing query and retain their reported-path contract. An observed content change still invalidates hook-derived state. Add t7531 integration coverage for file-parser parity, missing attribute history, an observed hook-time attribute change, and the hook missing-history exception. Update the existing history unit test to exercise the public strong-mismatch predicate. The namespace-only hook branch has no dedicated regression in this patch. Signed-off-by: Taylor Blau --- attr-fingerprint.c | 93 ++++++++++++++++++++-- attr-fingerprint.h | 18 +++++ attr.c | 89 +++++++++++++++++++-- attr.h | 12 +++ clean-status.c | 62 +++++++++++++++ clean-status.h | 10 +++ fsmonitor.c | 9 +++ fsmonitor.h | 2 + t/t7531-semantic-verify.sh | 110 ++++++++++++++++++++++++++ t/unit-tests/u-clean-status-history.c | 2 +- wt-status.c | 50 ++++++++++++ wt-status.h | 3 + 12 files changed, 446 insertions(+), 14 deletions(-) diff --git a/attr-fingerprint.c b/attr-fingerprint.c index 6a9cde2821614c..d7fdc1870dd2c3 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -10,6 +10,17 @@ #include "strbuf.h" #include "wrapper.h" +struct attr_source_snapshot_entry { + char *path; + char *buf; + size_t len; +}; + +struct attr_source_snapshot { + struct attr_fingerprint fingerprint; + struct attr_source_snapshot_entry sources[ATTR_SOURCE_SNAPSHOT_NR]; +}; + static int open_attr_source(const char *path) { #ifdef O_NONBLOCK @@ -24,7 +35,8 @@ static int open_attr_source(const char *path) static int hash_source(struct git_hash_ctx *content_ctx, struct git_hash_ctx *namespace_ctx, const struct attr_fingerprint_source *source, - int *present) + int *present, + struct attr_source_snapshot_entry *snapshot) { struct path_namespace_snapshot *before = NULL, *after = NULL; struct stat opened_before, opened_after, named; @@ -84,6 +96,12 @@ static int hash_source(struct git_hash_ctx *content_ctx, path_namespace_hash(namespace_ctx, before); path_namespace_hash_stat(namespace_ctx, &opened_after); hash_length_delimited(content_ctx, buf, size); + if (snapshot) { + snapshot->path = xstrdup(source->path); + snapshot->buf = buf; + snapshot->len = size; + buf = NULL; + } ret = 0; done: if (fd >= 0) @@ -98,11 +116,14 @@ static int hash_source(struct git_hash_ctx *content_ctx, static int fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, - const struct git_hash_algo *algo, struct attr_fingerprint *result) + const struct git_hash_algo *algo, struct attr_fingerprint *result, + struct attr_source_snapshot *snapshot) { struct git_hash_ctx content_ctx, namespace_ctx; uint32_t count; + if (snapshot && nr != ARRAY_SIZE(snapshot->sources)) + BUG("attribute snapshot source count mismatch"); memset(result, 0, sizeof(*result)); git_hash_init(&content_ctx, algo); git_hash_init(&namespace_ctx, algo); @@ -116,9 +137,11 @@ static int fingerprint_sources( hash_length_delimited(&namespace_ctx, &count, sizeof(count)); for (size_t i = 0; i < nr; i++) { int present; + struct attr_source_snapshot_entry *entry = + snapshot ? &snapshot->sources[i] : NULL; if (hash_source(&content_ctx, &namespace_ctx, &sources[i], - &present)) + &present, entry)) return -1; result->sources_present |= present; } @@ -131,7 +154,7 @@ int attr_fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, const struct git_hash_algo *algo, struct attr_fingerprint *result) { - return fingerprint_sources(sources, nr, algo, result); + return fingerprint_sources(sources, nr, algo, result, NULL); } static int repository_sources(struct repository *repo, @@ -153,7 +176,7 @@ static int repository_sources(struct repository *repo, int attr_fingerprint_repository(struct repository *repo, struct attr_fingerprint *result) { - struct attr_fingerprint_source sources[3]; + struct attr_fingerprint_source sources[ATTR_SOURCE_SNAPSHOT_NR]; char *info_attributes = NULL; int ret; @@ -165,3 +188,63 @@ int attr_fingerprint_repository(struct repository *repo, free(info_attributes); return ret; } + +int attr_source_snapshot_repository(struct repository *repo, + struct attr_source_snapshot **result) +{ + struct attr_fingerprint_source sources[ATTR_SOURCE_SNAPSHOT_NR]; + struct attr_source_snapshot *snapshot; + char *info_attributes = NULL; + + if (!result) + BUG("attr_source_snapshot_repository requires an output"); + *result = NULL; + if (repository_sources(repo, sources, &info_attributes)) + return -1; + CALLOC_ARRAY(snapshot, 1); + if (fingerprint_sources(sources, ARRAY_SIZE(sources), repo->hash_algo, + &snapshot->fingerprint, snapshot)) { + attr_source_snapshot_free(snapshot); + free(info_attributes); + return -1; + } + free(info_attributes); + *result = snapshot; + return 0; +} + +const struct attr_fingerprint *attr_source_snapshot_fingerprint( + const struct attr_source_snapshot *snapshot) +{ + return snapshot ? &snapshot->fingerprint : NULL; +} + +int attr_source_snapshot_read( + const struct attr_source_snapshot *snapshot, + enum attr_source_snapshot_kind kind, + const char **path, const char **buf, size_t *len) +{ + const struct attr_source_snapshot_entry *source; + + if (!snapshot || kind >= ATTR_SOURCE_SNAPSHOT_NR || + !path || !buf || !len) + BUG("invalid attribute snapshot read"); + source = &snapshot->sources[kind]; + if (!source->buf) + return 0; + *path = source->path; + *buf = source->buf; + *len = source->len; + return 1; +} + +void attr_source_snapshot_free(struct attr_source_snapshot *snapshot) +{ + if (!snapshot) + return; + for (size_t i = 0; i < ARRAY_SIZE(snapshot->sources); i++) { + free(snapshot->sources[i].path); + free(snapshot->sources[i].buf); + } + free(snapshot); +} diff --git a/attr-fingerprint.h b/attr-fingerprint.h index a159aa0697468c..6d15646fcd1975 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -16,10 +16,28 @@ struct attr_fingerprint { unsigned int sources_present : 1; }; +enum attr_source_snapshot_kind { + ATTR_SOURCE_SNAPSHOT_SYSTEM, + ATTR_SOURCE_SNAPSHOT_GLOBAL, + ATTR_SOURCE_SNAPSHOT_INFO, + ATTR_SOURCE_SNAPSHOT_NR, +}; + +struct attr_source_snapshot; + int attr_fingerprint_sources( const struct attr_fingerprint_source *sources, size_t nr, const struct git_hash_algo *algo, struct attr_fingerprint *result); int attr_fingerprint_repository(struct repository *repo, struct attr_fingerprint *result); +int attr_source_snapshot_repository(struct repository *repo, + struct attr_source_snapshot **result); +const struct attr_fingerprint *attr_source_snapshot_fingerprint( + const struct attr_source_snapshot *snapshot); +int attr_source_snapshot_read( + const struct attr_source_snapshot *snapshot, + enum attr_source_snapshot_kind kind, + const char **path, const char **buf, size_t *len); +void attr_source_snapshot_free(struct attr_source_snapshot *snapshot); #endif /* ATTR_FINGERPRINT_H */ diff --git a/attr.c b/attr.c index 87808ba3755d04..04f28e119f2361 100644 --- a/attr.c +++ b/attr.c @@ -14,6 +14,7 @@ #include "environment.h" #include "exec-cmd.h" #include "attr.h" +#include "attr-fingerprint.h" #include "dir.h" #include "gettext.h" #include "path.h" @@ -477,6 +478,8 @@ static struct check_vector { pthread_mutex_t mutex; } check_vector; +static const struct attr_source_snapshot *source_snapshot; + static inline void vector_lock(void) { pthread_mutex_lock(&check_vector.mutex); @@ -541,6 +544,26 @@ void git_attr_invalidate_all(void) drop_all_attr_stacks(); } +void git_attr_source_snapshot_begin( + const struct attr_source_snapshot *snapshot) +{ + if (!snapshot) + BUG("cannot begin a NULL attribute source snapshot"); + if (source_snapshot) + BUG("attribute source snapshots cannot be nested"); + drop_all_attr_stacks(); + source_snapshot = snapshot; +} + +void git_attr_source_snapshot_end( + const struct attr_source_snapshot *snapshot) +{ + if (!snapshot || source_snapshot != snapshot) + BUG("ending an inactive attribute source snapshot"); + drop_all_attr_stacks(); + source_snapshot = NULL; +} + struct attr_check *attr_check_alloc(void) { struct attr_check *c = xcalloc(1, sizeof(struct attr_check)); @@ -673,6 +696,15 @@ static struct attr_stack *read_attr_from_array(const char **list) return res; } +static void handle_attr_line_buf(struct attr_stack *res, + struct strbuf *line, const char *path, + int *lineno, unsigned flags) +{ + if (!*lineno && starts_with(line->buf, utf8_bom)) + strbuf_remove(line, 0, strlen(utf8_bom)); + handle_attr_line(res, line->buf, path, ++*lineno, flags); +} + /* * Callers into the attribute system assume there is a single, system-wide * global state where attributes are read from and when the state is flipped by @@ -726,17 +758,48 @@ static struct attr_stack *read_attr_from_file(const char *path, unsigned flags) } CALLOC_ARRAY(res, 1); - while (strbuf_getline(&buf, fp) != EOF) { - if (!lineno && starts_with(buf.buf, utf8_bom)) - strbuf_remove(&buf, 0, strlen(utf8_bom)); - handle_attr_line(res, buf.buf, path, ++lineno, flags); - } + while (strbuf_getline(&buf, fp) != EOF) + handle_attr_line_buf(res, &buf, path, &lineno, flags); fclose(fp); strbuf_release(&buf); return res; } +static struct attr_stack *read_attr_from_snapshot( + enum attr_source_snapshot_kind kind, unsigned flags) +{ + struct attr_stack *res; + struct strbuf line = STRBUF_INIT; + const char *path, *buf; + size_t length; + size_t offset = 0; + int lineno = 0; + + if (!source_snapshot) + BUG("attribute source snapshot is not set"); + if (!attr_source_snapshot_read(source_snapshot, kind, + &path, &buf, &length)) + return NULL; + + CALLOC_ARRAY(res, 1); + while (offset < length) { + const char *start = buf + offset; + const char *newline = memchr(start, '\n', length - offset); + size_t len = newline ? (size_t)(newline - start) : + length - offset; + + if (newline && len && start[len - 1] == '\r') + len--; + strbuf_reset(&line); + strbuf_add(&line, start, len); + handle_attr_line_buf(res, &line, path, &lineno, flags); + offset = newline ? (size_t)(newline - buf) + 1 : length; + } + strbuf_release(&line); + return res; +} + static struct attr_stack *read_attr_from_buf(char *buf, size_t length, const char *path, unsigned flags) { @@ -927,13 +990,21 @@ static void bootstrap_attr_stack(struct index_state *istate, push_stack(stack, e, NULL, 0); /* system-wide frame */ - if (git_attr_system_is_enabled()) { + if (source_snapshot) { + e = read_attr_from_snapshot( + ATTR_SOURCE_SNAPSHOT_SYSTEM, flags); + push_stack(stack, e, NULL, 0); + } else if (git_attr_system_is_enabled()) { e = read_attr_from_file(git_attr_system_file(), flags); push_stack(stack, e, NULL, 0); } /* home directory */ - if (git_attr_global_file()) { + if (source_snapshot) { + e = read_attr_from_snapshot( + ATTR_SOURCE_SNAPSHOT_GLOBAL, flags); + push_stack(stack, e, NULL, 0); + } else if (git_attr_global_file()) { e = read_attr_from_file(git_attr_global_file(), flags); push_stack(stack, e, NULL, 0); } @@ -943,7 +1014,9 @@ static void bootstrap_attr_stack(struct index_state *istate, push_stack(stack, e, xstrdup(""), 0); /* info frame */ - if (startup_info->have_repository) + if (source_snapshot) + e = read_attr_from_snapshot(ATTR_SOURCE_SNAPSHOT_INFO, flags); + else if (startup_info->have_repository) e = read_attr_from_file(git_path_info_attributes(), flags); else e = NULL; diff --git a/attr.h b/attr.h index cca94379362f10..af2ae096d6642a 100644 --- a/attr.h +++ b/attr.h @@ -129,6 +129,7 @@ struct index_state; * `git_attr_name()`. */ struct git_attr; +struct attr_source_snapshot; /* opaque structures used internally for attribute collection */ struct all_attrs_item; @@ -230,6 +231,17 @@ void git_attr_set_direction(enum git_attr_direction new_direction); /* Discard cached attributes after a provider-wide invalidation. */ void git_attr_invalidate_all(void); +/* + * Read system, global, and info attributes from an immutable snapshot. + * begin() and end() must be strictly paired, cannot nest, and the caller must + * keep the snapshot alive until end(). Readers may run concurrently while a + * snapshot is active, but begin() and end() require that there are no readers. + */ +void git_attr_source_snapshot_begin( + const struct attr_source_snapshot *snapshot); +void git_attr_source_snapshot_end( + const struct attr_source_snapshot *snapshot); + void attr_start(void); /* Return the system gitattributes file. */ diff --git a/clean-status.c b/clean-status.c index 797d4730c1681f..a8fc917efed021 100644 --- a/clean-status.c +++ b/clean-status.c @@ -4,6 +4,7 @@ #include "clean-status-internal.h" #include "read-cache-ll.h" #include "repository.h" +#include "trace2.h" static struct repository *configured_repo; static unsigned char configured_hash[GIT_MAX_RAWSZ]; @@ -97,6 +98,67 @@ void clean_status_invalidate_current_proof(struct index_state *istate) istate->clean_status->filter_scope_valid = 0; } +int clean_status_capture_attr_snapshot( + struct index_state *istate, + struct attr_source_snapshot **snapshot) +{ + struct clean_status_state *state = istate->clean_status; + struct attr_source_snapshot *captured = NULL; + const struct attr_fingerprint *attrs; + int valid, changed = 0; + + if (!snapshot) + BUG("clean_status_capture_attr_snapshot requires an output"); + *snapshot = NULL; + if (!fstat_is_reliable() || !state || !state->current_config_valid || + !state->config_enforced) + return 0; + valid = !attr_source_snapshot_repository(istate->repo, &captured); + attrs = attr_source_snapshot_fingerprint(captured); + if (!valid || !state->current_attr_valid) { + changed = CLEAN_STATUS_ATTR_CONTENT_CHANGED | + CLEAN_STATUS_ATTR_NAMESPACE_CHANGED; + } else { + if (memcmp(attrs->content_hash, state->current_attr_hash, + istate->repo->hash_algo->rawsz)) + changed |= CLEAN_STATUS_ATTR_CONTENT_CHANGED; + if (memcmp(attrs->namespace_hash, + state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz)) + changed |= CLEAN_STATUS_ATTR_NAMESPACE_CHANGED; + } + if (valid) { + memcpy(state->current_attr_hash, attrs->content_hash, + istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_namespace_hash, attrs->namespace_hash, + istate->repo->hash_algo->rawsz); + state->current_attr_valid = 1; + state->current_attr_sources_present = attrs->sources_present; + } else { + state->current_attr_valid = 0; + } + if (changed) { + clean_status_invalidate_current_proof(istate); + state->config_mismatch = 1; + state->strong_mismatch = 1; + } + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/rechecked", 1); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/mismatch", state->strong_mismatch); + if (!valid) + return -1; + *snapshot = captured; + return changed; +} + +int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) +{ + return istate->clean_status && + istate->clean_status->current_config_valid && + istate->clean_status->strong_mismatch; +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index 02e3d6022ae2c3..f3837e5d9db722 100644 --- a/clean-status.h +++ b/clean-status.h @@ -4,16 +4,26 @@ #include "clean-status-config.h" struct index_state; +struct attr_source_snapshot; struct repository; struct stat; struct strbuf; +enum clean_status_attr_change { + CLEAN_STATUS_ATTR_CONTENT_CHANGED = 1 << 0, + CLEAN_STATUS_ATTR_NAMESPACE_CHANGED = 1 << 1, +}; + void clean_status_set_config_digest( struct repository *repo, const struct clean_status_config_digest *digest); void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); +int clean_status_capture_attr_snapshot( + struct index_state *istate, + struct attr_source_snapshot **snapshot); +int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, diff --git a/fsmonitor.c b/fsmonitor.c index 9e90d158402fdd..02408ba801ad2f 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -905,6 +905,15 @@ static void invalidate_all_fsmonitor_strong(struct index_state *istate) fsmonitor_invalidate_cache_entry(istate->cache[i]); } +void fsmonitor_invalidate_semantics(struct index_state *istate) +{ + git_attr_invalidate_all(); + invalidate_all_fsmonitor_strong(istate); + istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/strong-invalidation", 1); +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; diff --git a/fsmonitor.h b/fsmonitor.h index e20d280e06a220..e6c617bec77f04 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -51,6 +51,8 @@ static inline int fsmonitor_stat_can_be_valid(const struct stat *st) return !S_ISREG(st->st_mode) || st->st_nlink <= 1; } +void fsmonitor_invalidate_semantics(struct index_state *istate); + /* * Check if refresh_fsmonitor has been called at least once. * refresh_fsmonitor is idempotent. Returns true if fsmonitor is diff --git a/t/t7531-semantic-verify.sh b/t/t7531-semantic-verify.sh index a6e7edab9db034..1bfd85b0abac4c 100755 --- a/t/t7531-semantic-verify.sh +++ b/t/t7531-semantic-verify.sh @@ -167,4 +167,114 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "filter_scope_checked=1" actual ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'immutable attribute sources preserve file parsing' ' + attrs="$TRASH_DIRECTORY/attribute-parser-file" && + printf "\357\273\277*.dat text\nignored\0junk\n*.txt text\r\n*.bin text\r" \ + >"$attrs" && + test_create_repo attribute-parser && + git -C attribute-parser config core.attributesFile "$attrs" && + git -C attribute-parser config core.fsmonitor false && + git -C attribute-parser config core.untrackedCache false && + for extension in dat txt bin + do + printf "alpha\r\n" \ + >"attribute-parser/tracked.$extension" || return 1 + done && + git -C attribute-parser add . && + git -C attribute-parser commit -m base && + + GIT_OPTIONAL_LOCKS=0 GIT_TEST_COLD_BULK_STATUS=0 \ + git -C attribute-parser status --porcelain=v2 >actual && + test_must_be_empty actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'missing attribute history invalidates cached stats' ' + attrs="$TRASH_DIRECTORY/external-attributes-file" && + printf "*.txt text eol=crlf\n" >"$attrs" && + test_create_repo external-attributes && + git -C external-attributes config core.attributesFile "$attrs" && + git -C external-attributes config core.fsmonitor false && + git -C external-attributes config core.untrackedCache false && + printf "alpha\r\n" >external-attributes/tracked.txt && + git -C external-attributes add tracked.txt && + git -C external-attributes commit -m base && + + printf "*.txt -text\n" >"$attrs" && + GIT_OPTIONAL_LOCKS=0 GIT_TEST_COLD_BULK_STATUS=0 \ + git -C external-attributes status --porcelain=v2 >actual && + test_grep "^1 \.M .* tracked.txt$" actual +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'hook cannot hide an observed external attribute change' ' + attrs="$TRASH_DIRECTORY/hook-attribute-change.rules" && + marker="$TRASH_DIRECTORY/hook-attribute-change.marker" && + printf "*.txt text eol=crlf\n" >"$attrs" && + test_create_repo hook-attribute-change && + ( + cd hook-attribute-change && + git config core.attributesFile "$attrs" && + git config core.untrackedCache false && + printf "alpha\r\n" >tracked.txt && + git add tracked.txt && + git commit -m base && + test_hook --setup fsmonitor-test <<-\EOF && + if test -n "$GIT_TEST_ATTR_FILE" + then + printf "*.txt -text\n" >"$GIT_TEST_ATTR_FILE" + : >"$GIT_TEST_ATTR_MARKER" + fi + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + + GIT_TEST_ATTR_FILE="$attrs" \ + GIT_TEST_ATTR_MARKER="$marker" \ + git status --porcelain=v2 >actual && + test_path_is_file "$marker" && + test_grep "^1 \.M .* tracked.txt$" actual + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'hook missing-history exception preserves reported paths' ' + attrs="$TRASH_DIRECTORY/hook-missing-history.rules" && + printf "*.txt -text\n" >"$attrs" && + test_create_repo hook-missing-history && + ( + cd hook-missing-history && + git config core.attributesFile "$attrs" && + git config core.untrackedCache false && + git config core.trustctime false && + git config core.checkStat minimal && + printf "aaaa\n" >tracked.txt && + git add tracked.txt && + git commit -m base && + test-tool chmtime =-60 tracked.txt && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked.txt) && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + test_grep ! FSCF .git/index && + + printf "bbbb\n" >tracked.txt && + test-tool chmtime =$mtime tracked.txt && + GIT_OPTIONAL_LOCKS=0 \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual + ) +' + test_done diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 4a39bfa8568811..899e6838d8f02a 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -114,7 +114,7 @@ void test_clean_status_history__adopts_only_coherent_proofs(void) state->current_semantic_hash[0] ^= 1; clean_status_prepare_fsmonitor_config(&fixture.istate); - cl_assert(state->strong_mismatch); + cl_assert(clean_status_fsmonitor_strong_mismatch(&fixture.istate)); cl_assert(!state->initial_coherent); fixture_release(&fixture); } diff --git a/wt-status.c b/wt-status.c index 57e2321275dc07..7146f3e42f1760 100644 --- a/wt-status.c +++ b/wt-status.c @@ -3,10 +3,13 @@ #include "git-compat-util.h" #include "advice.h" +#include "attr.h" +#include "attr-fingerprint.h" #include "wt-status.h" #include "object.h" #include "dir.h" #include "commit.h" +#include "clean-status.h" #include "diff.h" #include "environment.h" #include "gettext.h" @@ -811,6 +814,46 @@ static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; } +static int wt_status_begin_attr_snapshot(struct wt_status *s) +{ + int ret; + int hook_provider = + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_HOOK; + + if (s->attr_source_snapshot) + return 0; + if (s->attr_snapshot_failed) + return -1; + ret = clean_status_capture_attr_snapshot( + s->repo->index, &s->attr_source_snapshot); + if (ret < 0) { + s->attr_snapshot_failed = 1; + untracked_cache_invalidate_all(s->repo->index); + fsmonitor_invalidate_semantics(s->repo->index); + return -1; + } + if (s->attr_source_snapshot) + git_attr_source_snapshot_begin(s->attr_source_snapshot); + if ((ret > 0 && + (!hook_provider || + (ret & CLEAN_STATUS_ATTR_CONTENT_CHANGED))) || + (clean_status_fsmonitor_strong_mismatch(s->repo->index) && + !hook_provider)) { + /* + * Hook providers have no closing query with which to adopt + * missing semantic history, so absence alone must preserve + * their established path-reporting contract. Namespace-only + * churn can arise from an index rewrite and is likewise not + * evidence that the hook missed a semantic change. Changed + * attribute contents are current evidence and invalidate + * cached semantics regardless of provider. + */ + untracked_cache_invalidate_all(s->repo->index); + fsmonitor_invalidate_semantics(s->repo->index); + } + return ret; +} + void wt_status_start_untracked_cache_preload(struct wt_status *s) { struct index_state *istate = s->repo->index; @@ -820,6 +863,7 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); + wt_status_begin_attr_snapshot(s); if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) @@ -1087,6 +1131,7 @@ void wt_status_collect(struct wt_status *s) if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED) wt_status_finish_untracked_cache_preload(s); + wt_status_begin_attr_snapshot(s); wt_status_close_fsmonitor_token( s, REFRESH_QUIET | REFRESH_UNMERGED, s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && @@ -1130,6 +1175,11 @@ void wt_status_collect_free_buffers(struct wt_status *s) { untracked_cache_preload_release(s->untracked_cache_preload); s->untracked_cache_preload = NULL; + if (s->attr_source_snapshot) + git_attr_source_snapshot_end(s->attr_source_snapshot); + attr_source_snapshot_free(s->attr_source_snapshot); + s->attr_source_snapshot = NULL; + s->attr_snapshot_failed = 0; wt_status_state_free_buffers(&s->state); } diff --git a/wt-status.h b/wt-status.h index 34beac22576fc9..74798dd593aacb 100644 --- a/wt-status.h +++ b/wt-status.h @@ -7,6 +7,7 @@ #include "remote.h" struct repository; +struct attr_source_snapshot; struct worktree; struct untracked_cache_preload; @@ -148,7 +149,9 @@ struct wt_status { struct string_list ignored; uint32_t untracked_in_ms; struct untracked_cache_preload *untracked_cache_preload; + struct attr_source_snapshot *attr_source_snapshot; unsigned untracked_cache_preloaded : 1; + unsigned attr_snapshot_failed : 1; }; size_t wt_status_locate_end(const char *s, size_t len); From b2d084d963cacdff0148e50989ae3ab6cb1f58bc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:08:32 -0700 Subject: [PATCH 182/403] fsmonitor: validate builtin daemon responses before applying them The builtin fsmonitor client interpreted an IPC reply as unbounded C strings. A truncated token or pathname could read past the reply; an empty pathname could enter invalidation code expecting at least one byte; and a slash response could be confused with a real path. Parse the complete reply into an explicit error, delta, or trivial outcome before exposing a builtin token or path. Require a bounded builtin-prefixed token and fully terminated, nonempty, worktree- relative path records. Reserve an exact single slash for a trivial reply and retain the separate double-slash global invalidation marker. Route malformed replies through the existing scan fallback. Add unit coverage for valid paths, trivial and global responses, missing delimiters, oversized tokens, empty records, absolute paths, parent traversal, and malformed separators. Register the new unit suite in both the Makefile and t/meson.build. Hook parsing and token adoption remain unchanged. Signed-off-by: Taylor Blau --- Makefile | 1 + fsmonitor.c | 112 ++++++++++++++++++++++++---- fsmonitor.h | 23 ++++++ t/meson.build | 1 + t/unit-tests/u-fsmonitor-response.c | 86 +++++++++++++++++++++ 5 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 t/unit-tests/u-fsmonitor-response.c diff --git a/Makefile b/Makefile index 97a4936b146df3..2d4754b4643417 100644 --- a/Makefile +++ b/Makefile @@ -1540,6 +1540,7 @@ CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate CLAR_TEST_SUITES += u-fsmonitor-attributes +CLAR_TEST_SUITES += u-fsmonitor-response CLAR_TEST_SUITES += u-hash CLAR_TEST_SUITES += u-hashmap CLAR_TEST_SUITES += u-list-objects-filter-options diff --git a/fsmonitor.c b/fsmonitor.c index 7b90f80405909c..b88a5c377894af 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -731,6 +731,90 @@ static int is_trivial_response_at(const struct strbuf *result, size_t offset) return 1; } +void fsmonitor_query_result_release(struct fsmonitor_query_result *result) +{ + strbuf_release(&result->token); + strbuf_release(&result->paths); +} + +static int fsmonitor_valid_worktree_path(const char *path, size_t len) +{ + struct strbuf copy = STRBUF_INIT; + int valid = 0; + + if (!len || is_dir_sep(path[0]) || has_dos_drive_prefix(path)) + return 0; + strbuf_add(©, path, len); + if (is_dir_sep(copy.buf[copy.len - 1])) + strbuf_setlen(©, copy.len - 1); + if (!copy.len || is_dir_sep(copy.buf[copy.len - 1])) + goto done; + valid = verify_path(copy.buf, 0); + +done: + strbuf_release(©); + return valid; +} + +enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( + const struct strbuf *raw, struct fsmonitor_query_result *result) +{ + const char *nul, *p, *end; + + if (!raw->len) + goto malformed; + nul = memchr(raw->buf, '\0', raw->len); + if (!nul || nul == raw->buf || nul - raw->buf > FSMONITOR_TOKEN_MAX) + goto malformed; + strbuf_add(&result->token, raw->buf, nul - raw->buf); + if (!starts_with(result->token.buf, "builtin:")) + goto malformed; + + p = nul + 1; + end = raw->buf + raw->len; + if (p == end) { + result->outcome = FSMONITOR_QUERY_DELTA; + return result->outcome; + } + if (end[-1] != '\0') + goto malformed; + if (end - p == 2 && p[0] == '/' && p[1] == '\0') { + result->outcome = FSMONITOR_QUERY_TRIVIAL; + return result->outcome; + } + + while (p < end) { + nul = memchr(p, '\0', end - p); + if (!nul || nul == p) + goto malformed; + if (strcmp(p, FSMONITOR_PATH_GLOBAL_INVALIDATE) && + !fsmonitor_valid_worktree_path(p, nul - p)) + goto malformed; + p = nul + 1; + } + strbuf_add(&result->paths, raw->buf + result->token.len + 1, + end - (raw->buf + result->token.len + 1)); + result->outcome = FSMONITOR_QUERY_DELTA; + return result->outcome; + +malformed: + strbuf_reset(&result->token); + strbuf_reset(&result->paths); + trace2_data_intmax("fsm_client", NULL, "query/invalid-response", 1); + return FSMONITOR_QUERY_ERROR; +} + +static enum fsmonitor_query_outcome query_builtin_fsmonitor( + const char *since_token, struct fsmonitor_query_result *result) +{ + struct strbuf raw = STRBUF_INIT; + + if (!fsmonitor_ipc__send_query(since_token, &raw)) + fsmonitor_parse_builtin_response(&raw, result); + strbuf_release(&raw); + return result->outcome; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -762,24 +846,19 @@ void refresh_fsmonitor(struct index_state *istate) trace_printf_key(&trace_fsmonitor, "refresh fsmonitor"); if (fsm_mode == FSMONITOR_MODE_IPC) { - query_success = !fsmonitor_ipc__send_query( + struct fsmonitor_query_result result = + FSMONITOR_QUERY_RESULT_INIT; + + query_builtin_fsmonitor( istate->fsmonitor_last_update ? istate->fsmonitor_last_update : "builtin:fake", - &query_result); - if (query_success) { - /* - * The response contains a series of nul terminated - * strings. The first is the new token. - * - * Use `char *buf` as an interlude to trick the CI - * static analysis to let us use `strbuf_addstr()` - * here (and only copy the token) rather than - * `strbuf_addbuf()`. - */ - buf = query_result.buf; - strbuf_addstr(&last_update_token, buf); - bol = last_update_token.len + 1; - is_trivial = is_trivial_response_at(&query_result, bol); + &result); + if (result.outcome != FSMONITOR_QUERY_ERROR) { + query_success = 1; + strbuf_addbuf(&last_update_token, &result.token); + is_trivial = result.outcome == FSMONITOR_QUERY_TRIVIAL; + if (!is_trivial) + strbuf_addbuf(&query_result, &result.paths); if (is_trivial) trace2_data_intmax("fsm_client", NULL, "query/trivial-response", 1); @@ -795,6 +874,7 @@ void refresh_fsmonitor(struct index_state *istate) */ strbuf_addstr(&last_update_token, "builtin:fake"); } + fsmonitor_query_result_release(&result); goto apply_results; } diff --git a/fsmonitor.h b/fsmonitor.h index 47ce78de61c508..e20d280e06a220 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -6,6 +6,7 @@ #include "fsmonitor-settings.h" #include "object.h" #include "read-cache-ll.h" +#include "strbuf.h" #include "trace.h" /* @@ -15,6 +16,28 @@ */ void fsmonitor_invalidate_cache_entry(struct cache_entry *ce); +enum fsmonitor_query_outcome { + FSMONITOR_QUERY_ERROR = 0, + FSMONITOR_QUERY_DELTA, + FSMONITOR_QUERY_TRIVIAL, +}; + +struct fsmonitor_query_result { + enum fsmonitor_query_outcome outcome; + struct strbuf token; + struct strbuf paths; +}; + +#define FSMONITOR_QUERY_RESULT_INIT { \ + .outcome = FSMONITOR_QUERY_ERROR, \ + .token = STRBUF_INIT, \ + .paths = STRBUF_INIT, \ +} + +void fsmonitor_query_result_release(struct fsmonitor_query_result *result); +enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( + const struct strbuf *raw, struct fsmonitor_query_result *result); + /* * A pathname monitor cannot prove that every name for a multiply-linked * inode is inside its watch cone. When the platform reports real link diff --git a/t/meson.build b/t/meson.build index 6d4c7c4bed8fdf..d7dacb4063aff1 100644 --- a/t/meson.build +++ b/t/meson.build @@ -3,6 +3,7 @@ clar_test_suites = [ 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', 'unit-tests/u-fsmonitor-attributes.c', + 'unit-tests/u-fsmonitor-response.c', 'unit-tests/u-hash.c', 'unit-tests/u-hashmap.c', 'unit-tests/u-list-objects-filter-options.c', diff --git a/t/unit-tests/u-fsmonitor-response.c b/t/unit-tests/u-fsmonitor-response.c new file mode 100644 index 00000000000000..dda747aa764097 --- /dev/null +++ b/t/unit-tests/u-fsmonitor-response.c @@ -0,0 +1,86 @@ +#include "unit-test.h" + +#include "fsmonitor.h" + +static void check_response(const void *data, size_t len, + enum fsmonitor_query_outcome expected, + const char *token, const void *paths, + size_t paths_len) +{ + struct fsmonitor_query_result result = FSMONITOR_QUERY_RESULT_INIT; + struct strbuf raw = STRBUF_INIT; + + strbuf_add(&raw, data, len); + cl_assert_equal_i(fsmonitor_parse_builtin_response(&raw, &result), + expected); + cl_assert_equal_i(result.outcome, expected); + cl_assert_equal_s(result.token.buf, token); + cl_assert_equal_i(result.paths.len, paths_len); + cl_assert(!paths_len || !memcmp(result.paths.buf, paths, paths_len)); + + fsmonitor_query_result_release(&result); + strbuf_release(&raw); +} + +static void check_malformed(const void *data, size_t len) +{ + check_response(data, len, FSMONITOR_QUERY_ERROR, "", NULL, 0); +} + +void test_fsmonitor_response__rejects_malformed_framing(void) +{ + static const char missing_nul[] = "builtin:1"; + static const char empty_token[] = "\0"; + static const char non_builtin[] = "other:1\0"; + static const char unterminated_path[] = "builtin:1\0path"; + static const char empty_path[] = "builtin:1\0\0"; + static const char absolute_path[] = "builtin:1\0/absolute\0"; + static const char parent_path[] = "builtin:1\0../outside\0"; + static const char embedded_parent[] = + "builtin:1\0dir/../tracked\0"; + static const char dot_path[] = "builtin:1\0./tracked\0"; + static const char repeated_separator[] = + "builtin:1\0dir//tracked\0"; + static const char drive_path[] = "builtin:1\0C:/absolute\0"; + static const char backslash_path[] = "builtin:1\0\\absolute\0"; + struct strbuf overlong = STRBUF_INIT; + + check_malformed("", 0); + check_malformed(missing_nul, sizeof(missing_nul) - 1); + check_malformed(empty_token, sizeof(empty_token) - 1); + check_malformed(non_builtin, sizeof(non_builtin) - 1); + check_malformed(unterminated_path, sizeof(unterminated_path) - 1); + check_malformed(empty_path, sizeof(empty_path) - 1); + check_malformed(absolute_path, sizeof(absolute_path) - 1); + check_malformed(parent_path, sizeof(parent_path) - 1); + check_malformed(embedded_parent, sizeof(embedded_parent) - 1); + check_malformed(dot_path, sizeof(dot_path) - 1); + check_malformed(repeated_separator, + sizeof(repeated_separator) - 1); + if (has_dos_drive_prefix(drive_path + sizeof("builtin:1"))) + check_malformed(drive_path, sizeof(drive_path) - 1); + if (is_dir_sep('\\')) + check_malformed(backslash_path, sizeof(backslash_path) - 1); + + strbuf_addstr(&overlong, "builtin:"); + strbuf_addchars(&overlong, 'x', 4096); + strbuf_addch(&overlong, '\0'); + check_malformed(overlong.buf, overlong.len); + strbuf_release(&overlong); +} + +void test_fsmonitor_response__accepts_valid_builtin_responses(void) +{ + static const char delta[] = "builtin:2\0a\0dir/file\0dir/\0"; + static const char global[] = "builtin:3\0//\0"; + static const char trivial[] = "builtin:4\0/\0"; + + check_response(delta, sizeof(delta) - 1, FSMONITOR_QUERY_DELTA, + "builtin:2", delta + sizeof("builtin:2"), + sizeof(delta) - 1 - sizeof("builtin:2")); + check_response(global, sizeof(global) - 1, FSMONITOR_QUERY_DELTA, + "builtin:3", global + sizeof("builtin:3"), + sizeof(global) - 1 - sizeof("builtin:3")); + check_response(trivial, sizeof(trivial) - 1, FSMONITOR_QUERY_TRIVIAL, + "builtin:4", NULL, 0); +} From aca949df983604e0b96037d42b92a57f81457e81 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:06:21 -0500 Subject: [PATCH 183/403] fsmonitor: seed missing-history baselines from legacy tokens An fsmonitor token can mark an entry valid even when the index has no coherent history for the configuration and attributes that determine its content. With minimal stat checks, a same-size rewrite can then be reported as clean. Rebuild the attribute manifest for expanded indexes during IPC bootstrap. Compare it with the current in-process or retained on-disk manifest, invalidate only the tracked and untracked scopes whose attribute sources changed, and preserve the last complete manifest when a rebuild fails. A legacy index with no FSCF extension is different from a mismatched proof: it contains no claim about semantic history to disprove. When it also has a valid nontrivial FSMN token with core.trustctime enabled and full core.checkStat, clear FSMN validity and seed a forward baseline through ordinary configured stat checks. This avoids hashing every tracked file solely because the index predates FSCF. The baseline still needs to finish in the bootstrap command. Preserve the freshly-proven FSMN-valid bit on entries replaced by that refresh, so that the accepted token does not defer the same migration work into the next status. Keep strong global invalidation for semantic or attribute mismatches, weak stat settings, a present FSCF without complete manifest history, provider reset or failure, manifest rebuild failure, and fresh indexes without a prior nontrivial FSMN token. Retain ordinary provider handling when reliable file identity is unavailable. The migration exception has ordinary Git stat semantics rather than a content-proof guarantee; same-size changes hidden by the platform's configured stat identity can remain hidden at that boundary. Add coverage for the forward-baseline lane, the weak-stat same-size rewrite, and the refreshed baseline FSMN bits, along with unit coverage for coherent, manifest-only, missing, and present-without-manifest history. Signed-off-by: Taylor Blau --- clean-status-history.c | 86 ++++++++++++++++++++ clean-status-internal.h | 1 + clean-status-manifest.c | 104 ++++++++++++++++++++++++ clean-status-manifest.h | 7 ++ clean-status.c | 21 +++++ clean-status.h | 18 +++++ fsmonitor.c | 79 +++++++++++++++++- read-cache.c | 12 ++- t/unit-tests/u-clean-status-history.c | 78 ++++++++++++++++++ t/unit-tests/u-clean-status-manifest.c | 108 +++++++++++++++++++++++++ 10 files changed, 510 insertions(+), 4 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index 6369472b287ac8..fc917bb1bcbc3f 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -112,6 +112,92 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) "semantic/initial-mismatch", state->strong_mismatch); } +int clean_status_has_persistent_fsmonitor_semantic_history( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->disk_config_valid && + !state->disk_config_invalid && state->disk_semantic_valid && + state->disk_attr_valid && state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + state->disk_config_raw.len; +} + +int clean_status_has_worktree_manifest_history( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + return state && state->disk_config_valid && + !state->disk_config_invalid && state->manifest.disk_valid && + (state->manifest.disk_flags & required) == required; +} + +int clean_status_fsmonitor_semantic_adoption_needed( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + int missing_history; + + if (!state || !state->current_config_valid || !state->config_enforced) + return 0; + if (state->semantic_baseline_pending) + return 0; + missing_history = + !clean_status_has_persistent_fsmonitor_semantic_history(istate) && + !clean_status_has_worktree_manifest_history(istate); + /* + * Keep missing history on the proof path until fsmonitor explicitly + * chooses the narrow forward-baseline lane for a valid legacy token. + */ + return state->strong_mismatch || missing_history || + clean_status_filter_scope_needs_validation(istate); +} + +int clean_status_fsmonitor_semantic_baseline_needed( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + const struct repo_config_values *cfg; + + if (!state || !state->current_config_valid || !state->config_enforced || + state->strong_mismatch || state->disk_config_seen || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + !*istate->fsmonitor_last_update) + return 0; + /* + * This helper is exercised by isolated index-state unit fixtures, + * which are not the_repository. The config values are already + * initialized with the repository and need no lazy parsing here. + */ + cfg = &istate->repo->config_values_private_; + if (!cfg->trust_ctime || !cfg->check_stat) + return 0; + return !clean_status_has_persistent_fsmonitor_semantic_history(istate) && + !clean_status_has_worktree_manifest_history(istate); +} + +int clean_status_fsmonitor_semantic_baseline_pending( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->semantic_baseline_pending; +} + +void clean_status_begin_fsmonitor_semantic_baseline( + struct index_state *istate) +{ + struct clean_status_state *state = clean_status_get_state(istate); + + state->semantic_baseline_pending = 1; +} + static int current_proof_is_writable(const struct index_state *istate) { const struct clean_status_state *state = istate->clean_status; diff --git a/clean-status-internal.h b/clean-status-internal.h index 9ea64b13685fdc..1cf565c46ca6af 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -37,6 +37,7 @@ struct clean_status_state { unsigned disk_attr_valid : 1; unsigned disk_config_seen : 1; unsigned disk_config_invalid : 1; + unsigned semantic_baseline_pending : 1; }; struct clean_status_state *clean_status_get_state(struct index_state *istate); diff --git a/clean-status-manifest.c b/clean-status-manifest.c index 713d8bd4d5e104..b13066a23c2c47 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -1,8 +1,30 @@ #include "git-compat-util.h" #include "attr-manifest.h" #include "clean-status-manifest.h" +#include "dir.h" #include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" #include "hash-framing.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" +#include "worktree-attr-manifest.h" + +struct invalidate_manifest_data { + struct index_state *istate; + int invalidated; +}; + +static int build_manifest(struct index_state *istate, + struct strbuf *manifest, + unsigned char *manifest_hash, + struct worktree_attr_manifest_stats *stats) +{ + if (istate->sparse_index != INDEX_EXPANDED) + return -1; + return worktree_attr_manifest_build( + istate, manifest, manifest_hash, stats); +} void clean_status_manifest_init(struct clean_status_manifest_state *state) { @@ -48,6 +70,88 @@ void clean_status_manifest_adopt_disk( state->checked = 1; } +static int invalidate_manifest_path(const struct attr_manifest_entry *entry, + void *cb_data) +{ + struct invalidate_manifest_data *data = cb_data; + char *path = xmemdupz(entry->path, entry->path_len); + + untracked_cache_invalidate_trimmed_path(data->istate, path, 0); + data->invalidated += + fsmonitor_invalidate_attributes_path(data->istate, path); + free(path); + return 0; +} + +int clean_status_manifest_refresh(struct index_state *istate, + struct clean_status_manifest_state *state) +{ + struct worktree_attr_manifest_stats stats; + struct invalidate_manifest_data invalidation = { .istate = istate }; + const struct git_hash_algo *algo = istate->repo->hash_algo; + const struct strbuf *baseline = NULL; + struct strbuf next = STRBUF_INIT; + unsigned char next_hash[GIT_MAX_RAWSZ]; + + state->scan_count++; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-scan-count", state->scan_count); + if (attr_manifest_valid(state->current.buf, state->current.len, algo)) + baseline = &state->current; + else if (state->disk_valid) + baseline = &state->disk; + state->checked = 1; + state->changed = 0; + state->global_fallback = 0; + state->current_valid = 0; + state->current_flags = 0; + if (build_manifest(istate, &next, next_hash, &stats)) { + state->global_fallback = !!baseline; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-scan-failed", 1); + strbuf_release(&next); + return -1; + } + if (baseline) { + if (attr_manifest_for_each_changed( + baseline->buf, baseline->len, + next.buf, next.len, algo, + invalidate_manifest_path, &invalidation)) { + state->global_fallback = 1; + strbuf_release(&next); + return -1; + } + state->changed = baseline->len != next.len || + memcmp(baseline->buf, next.buf, next.len); + } + strbuf_swap(&state->current, &next); + strbuf_release(&next); + memcpy(state->current_hash, next_hash, algo->rawsz); + state->current_valid = 1; + state->current_flags = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-candidates", stats.candidates); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-threads", stats.threads); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-thread-failures", + stats.thread_failures); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-worktree-sources", + stats.worktree_sources); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-index-sources", stats.index_sources); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-bytes", state->current.len); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-changed", state->changed); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-invalidated", + invalidation.invalidated); + return invalidation.invalidated; +} + void clean_status_manifest_invalidate( struct clean_status_manifest_state *state) { diff --git a/clean-status-manifest.h b/clean-status-manifest.h index e924ba9fade2fe..04a56d56abe65c 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -4,6 +4,8 @@ #include "hash.h" #include "strbuf.h" +struct index_state; + struct clean_status_manifest_state { struct strbuf disk; struct strbuf current; @@ -11,9 +13,12 @@ struct clean_status_manifest_state { unsigned char current_hash[GIT_MAX_RAWSZ]; uint32_t disk_flags; uint32_t current_flags; + uint32_t scan_count; unsigned disk_valid : 1; unsigned current_valid : 1; unsigned checked : 1; + unsigned changed : 1; + unsigned global_fallback : 1; }; void clean_status_manifest_init(struct clean_status_manifest_state *state); @@ -23,6 +28,8 @@ int clean_status_manifest_load(struct clean_status_manifest_state *state, const struct git_hash_algo *algo); void clean_status_manifest_adopt_disk( struct clean_status_manifest_state *state); +int clean_status_manifest_refresh(struct index_state *istate, + struct clean_status_manifest_state *state); void clean_status_manifest_invalidate( struct clean_status_manifest_state *state); diff --git a/clean-status.c b/clean-status.c index a8fc917efed021..96ea9511ac2a2b 100644 --- a/clean-status.c +++ b/clean-status.c @@ -96,6 +96,7 @@ void clean_status_invalidate_current_proof(struct index_state *istate) istate->clean_status->config_revalidated = 0; istate->clean_status->initial_coherent = 0; istate->clean_status->filter_scope_valid = 0; + istate->clean_status->semantic_baseline_pending = 0; } int clean_status_capture_attr_snapshot( @@ -152,6 +153,13 @@ int clean_status_capture_attr_snapshot( return changed; } +int clean_status_fsmonitor_config_mismatch(const struct index_state *istate) +{ + return istate->clean_status && + istate->clean_status->current_config_valid && + istate->clean_status->config_mismatch; +} + int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) { return istate->clean_status && @@ -159,6 +167,19 @@ int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) istate->clean_status->strong_mismatch; } +int clean_status_refresh_worktree_manifest(struct index_state *istate) +{ + struct clean_status_state *state = clean_status_get_state(istate); + + return clean_status_manifest_refresh(istate, &state->manifest); +} + +int clean_status_manifest_global_fallback(const struct index_state *istate) +{ + return istate->clean_status && + istate->clean_status->manifest.global_fallback; +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index f3837e5d9db722..a609769f9c56ea 100644 --- a/clean-status.h +++ b/clean-status.h @@ -23,7 +23,25 @@ int clean_status_filter_scope_needs_validation( int clean_status_capture_attr_snapshot( struct index_state *istate, struct attr_source_snapshot **snapshot); + +int clean_status_fsmonitor_config_mismatch(const struct index_state *istate); int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); + +int clean_status_has_persistent_fsmonitor_semantic_history( + const struct index_state *istate); +int clean_status_has_worktree_manifest_history( + const struct index_state *istate); +int clean_status_fsmonitor_semantic_adoption_needed( + const struct index_state *istate); +int clean_status_fsmonitor_semantic_baseline_needed( + const struct index_state *istate); +int clean_status_fsmonitor_semantic_baseline_pending( + const struct index_state *istate); +void clean_status_begin_fsmonitor_semantic_baseline( + struct index_state *istate); + +int clean_status_refresh_worktree_manifest(struct index_state *istate); +int clean_status_manifest_global_fallback(const struct index_state *istate); void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, diff --git a/fsmonitor.c b/fsmonitor.c index 02408ba801ad2f..c90bfebc2e4712 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -896,6 +896,22 @@ static void invalidate_all_fsmonitor(struct index_state *istate) istate->cache_changed |= FSMONITOR_CHANGED; } +/* + * A forward baseline still needs one ordinary stat refresh before its + * provider token can certify the index. Clear only process-local + * uptodate state so that refresh_index() performs those stats without + * escalating to content checks. + */ +static void invalidate_all_fsmonitor_for_baseline( + struct index_state *istate) +{ + unsigned int i; + + invalidate_all_fsmonitor(istate); + for (i = 0; i < istate->cache_nr; i++) + istate->cache[i]->ce_flags &= ~CE_UPTODATE; +} + static void invalidate_all_fsmonitor_strong(struct index_state *istate) { unsigned int i; @@ -914,6 +930,45 @@ void fsmonitor_invalidate_semantics(struct index_state *istate) "semantic/strong-invalidation", 1); } +static void invalidate_fsmonitor_for_bootstrap( + struct index_state *istate, enum fsmonitor_mode mode, + int semantic_adoption_needed, int semantic_baseline_needed, + int physical_history_unavailable) +{ + int manifest_refresh_failed; + + if (!fstat_is_reliable() || mode != FSMONITOR_MODE_IPC || + istate->split_index) { + invalidate_all_fsmonitor(istate); + return; + } + + if (physical_history_unavailable) { + if (semantic_adoption_needed) + clean_status_refresh_worktree_manifest(istate); + fsmonitor_invalidate_semantics(istate); + untracked_cache_invalidate_all(istate); + return; + } + + manifest_refresh_failed = + clean_status_refresh_worktree_manifest(istate) < 0; + if (manifest_refresh_failed || + clean_status_manifest_global_fallback(istate) || + (semantic_adoption_needed && !semantic_baseline_needed)) { + fsmonitor_invalidate_semantics(istate); + } else { + if (semantic_baseline_needed) { + clean_status_begin_fsmonitor_semantic_baseline(istate); + invalidate_all_fsmonitor_for_baseline(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/adoption-baseline", 1); + } else { + invalidate_all_fsmonitor(istate); + } + } +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -927,6 +982,8 @@ void refresh_fsmonitor(struct index_state *istate) int is_trivial = 0; int tracked_requires_bootstrap; int untracked_requires_bootstrap; + int semantic_adoption_needed; + int semantic_baseline_needed; struct repository *r = istate->repo; enum fsmonitor_mode fsm_mode = fsm_settings__get_mode(r); enum fsmonitor_reason reason = fsm_settings__get_reason(r); @@ -943,6 +1000,14 @@ void refresh_fsmonitor(struct index_state *istate) return; istate->fsmonitor_has_run_once = 1; + semantic_adoption_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_adoption_needed(istate); + semantic_baseline_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_baseline_needed(istate); trace_printf_key(&trace_fsmonitor, "refresh fsmonitor"); @@ -1068,7 +1133,10 @@ void refresh_fsmonitor(struct index_state *istate) trace2_region_enter("fsmonitor", "apply_results", istate->repo); tracked_requires_bootstrap = !query_success || is_trivial || - !istate->fsmonitor_token_valid; + !istate->fsmonitor_token_valid || + (fstat_is_reliable() && !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_config_mismatch(istate)); untracked_requires_bootstrap = !istate->fsmonitor_untracked_valid; if (query_success && !is_trivial) { @@ -1099,7 +1167,10 @@ void refresh_fsmonitor(struct index_state *istate) } if (tracked_requires_bootstrap) - invalidate_all_fsmonitor(istate); + invalidate_fsmonitor_for_bootstrap( + istate, fsm_mode, semantic_adoption_needed, + semantic_baseline_needed, + !istate->fsmonitor_token_valid); /* Now mark the untracked cache for fsmonitor usage */ if (istate->untracked) @@ -1122,7 +1193,9 @@ void refresh_fsmonitor(struct index_state *istate) * we've actually changed entries, so keep track if we * actually changed entries or not. */ - invalidate_all_fsmonitor(istate); + invalidate_fsmonitor_for_bootstrap( + istate, fsm_mode, semantic_adoption_needed, + semantic_baseline_needed, 1); } trace2_region_leave("fsmonitor", "apply_results", istate->repo); diff --git a/read-cache.c b/read-cache.c index e0a5fcfccabd0e..40f2fffbf37890 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1658,7 +1658,17 @@ int refresh_index(struct index_state *istate, unsigned int flags, continue; } - replace_index_entry(istate, i, new_entry); + { + int baseline_valid = + clean_status_fsmonitor_semantic_baseline_pending( + istate) && + (new_entry->ce_flags & CE_FSMONITOR_VALID); + + replace_index_entry(istate, i, new_entry); + if (baseline_valid) + mark_fsmonitor_valid(istate, + istate->cache[i]); + } } trace2_data_intmax("index", NULL, "refresh/sum_lstat", t2_sum_lstat); trace2_data_intmax("index", NULL, "refresh/sum_scan", t2_sum_scan); diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 899e6838d8f02a..feb813c185c624 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -27,6 +27,7 @@ static void fixture_init(struct history_fixture *fixture, memset(fixture, 0, sizeof(*fixture)); fixture->repo.hash_algo = algo; + repo_config_values_init(&fixture->repo.config_values_private_); index_state_init(&fixture->istate, &fixture->repo); fixture->manifest = (struct strbuf)STRBUF_INIT; fixture->encoded = (struct strbuf)STRBUF_INIT; @@ -54,6 +55,7 @@ static void fixture_release(struct history_fixture *fixture) { clean_status_release(&fixture->istate); free(fixture->istate.fsmonitor_last_update); + repo_config_values_clear(&fixture->repo.config_values_private_); strbuf_release(&fixture->encoded); strbuf_release(&fixture->manifest); } @@ -72,6 +74,7 @@ static struct clean_status_state *install_current( state->current_semantic_valid = 1; state->current_attr_valid = 1; state->config_enforced = 1; + FREE_AND_NULL(fixture->istate.fsmonitor_last_update); fixture->istate.fsmonitor_last_update = xstrdup("builtin:1:2"); fixture->istate.fsmonitor_token_valid = 1; return state; @@ -119,6 +122,81 @@ void test_clean_status_history__adopts_only_coherent_proofs(void) fixture_release(&fixture); } +void test_clean_status_history__distinguishes_available_history(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct history_fixture fixture; + struct clean_status_state *state; + struct strbuf manifest_only = STRBUF_INIT; + + fixture_init(&fixture, algo); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + cl_assert(clean_status_has_persistent_fsmonitor_semantic_history( + &fixture.istate)); + cl_assert(clean_status_has_worktree_manifest_history(&fixture.istate)); + state = install_current(&fixture); + cl_assert(!clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + state->strong_mismatch = 1; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture_release(&fixture); + + fixture_init(&fixture, algo); + cl_assert_equal_i(fsmonitor_clean_proof_copy_without_bindings( + &manifest_only, fixture.encoded.buf, fixture.encoded.len, + algo), 0); + clean_status_read_fsmonitor_config( + &fixture.istate, manifest_only.buf, manifest_only.len); + cl_assert(!clean_status_has_persistent_fsmonitor_semantic_history( + &fixture.istate)); + cl_assert(clean_status_has_worktree_manifest_history(&fixture.istate)); + install_current(&fixture); + cl_assert(!clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture_release(&fixture); + + fixture_init(&fixture, algo); + state = install_current(&fixture); + fixture.istate.fsmonitor_token_valid = 1; + FREE_AND_NULL(fixture.istate.fsmonitor_last_update); + fixture.istate.fsmonitor_last_update = xstrdup("builtin:test:1"); + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + clean_status_begin_fsmonitor_semantic_baseline(&fixture.istate); + cl_assert(!clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + state->semantic_baseline_pending = 0; + state->disk_config_seen = 1; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + state->disk_config_seen = 0; + fixture.repo.config_values_private_.trust_ctime = 0; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture.repo.config_values_private_.trust_ctime = 1; + state->strong_mismatch = 1; + cl_assert(clean_status_fsmonitor_semantic_adoption_needed( + &fixture.istate)); + cl_assert(!clean_status_fsmonitor_semantic_baseline_needed( + &fixture.istate)); + fixture_release(&fixture); + strbuf_release(&manifest_only); +} + void test_clean_status_history__preserves_unbound_manifests(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c index e6d83c564a8a6b..fc17fd8ec0dbb8 100644 --- a/t/unit-tests/u-clean-status-manifest.c +++ b/t/unit-tests/u-clean-status-manifest.c @@ -1,7 +1,12 @@ #include "unit-test.h" #include "attr-manifest.h" #include "clean-status-manifest.h" +#include "dir.h" #include "fsmonitor-clean-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify-internal.h" +#include "wrapper.h" static void make_manifest(struct strbuf *manifest, const struct git_hash_algo *algo) @@ -68,3 +73,106 @@ void test_clean_status_manifest__rejects_invalid_history(void) clean_status_manifest_release(&state); strbuf_release(&manifest); } +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static char *create_worktree(void) +{ + const char *tmp = getenv("TMPDIR"); + char *path = xstrfmt("%s/status-manifest.XXXXXX", + tmp ? tmp : "/tmp"); + + cl_assert(mkdtemp(path) != NULL); + return path; +} + +static void add_index_path(struct index_state *istate, size_t pos, + const char *path) +{ + size_t len = strlen(path); + struct cache_entry *ce = make_empty_cache_entry(istate, len); + + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = len; + memcpy(ce->name, path, len + 1); + istate->cache[pos] = ce; +} +#endif + +void test_clean_status_manifest__invalidates_only_changed_scopes(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct clean_status_manifest_state state; + struct strbuf path = STRBUF_INIT, old = STRBUF_INIT, cleanup = STRBUF_INIT; + + strbuf_addf(&path, "%s/a", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/b", worktree); + cl_assert_equal_i(mkdir(path.buf, 0777), 0); + strbuf_reset(&path); + strbuf_addf(&path, "%s/a/.gitattributes", worktree); + write_file(path.buf, "*.txt text\n"); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_index_path(&istate, 0, "a/file"); + add_index_path(&istate, 1, "b/file"); + clean_status_manifest_init(&state); + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 0); + strbuf_addbuf(&old, &state.current); + cl_assert_equal_i(clean_status_manifest_load( + &state, old.buf, old.len, FSMONITOR_CLEAN_PROOF_ALL, algo), 0); + + write_file(path.buf, "*.txt -text\n"); + for (size_t i = 0; i < istate.cache_nr; i++) { + istate.cache[i]->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE; + memset(&istate.cache[i]->ce_stat_data, 1, + sizeof(istate.cache[i]->ce_stat_data)); + } + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); + cl_assert(state.changed); + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + + /* + * Preserve the last complete in-process value when a rebuild fails, + * then return to the on-disk value. The final comparison must use + * the preserved value, not the matching on-disk history. + */ + strbuf_reset(&old); + strbuf_addbuf(&old, &state.current); + clean_status_manifest_invalidate(&state); + istate.cache[0]->ce_flags = create_ce_flags(1); + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), -1); + cl_assert(!state.current_valid); + cl_assert(state.global_fallback); + cl_assert_equal_i(strbuf_cmp(&state.current, &old), 0); + + write_file(path.buf, "*.txt text\n"); + for (size_t i = 0; i < istate.cache_nr; i++) { + istate.cache[i]->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE; + memset(&istate.cache[i]->ce_stat_data, 1, + sizeof(istate.cache[i]->ce_stat_data)); + } + cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); + cl_assert(state.changed); + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + + clean_status_manifest_release(&state); + strbuf_release(&old); + strbuf_release(&path); + release_index(&istate); + strbuf_addstr(&cleanup, worktree); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + free(worktree); +#endif +} From c318a56c816c8ad93daaa2cf2dfe41c15442900a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:49 -0500 Subject: [PATCH 184/403] fsmonitor: apply validated builtin path records directly A validated builtin daemon response already contains complete, nonempty, NUL-terminated path records. Sending those records through the hook-oriented byte-by-byte offset scanner repeats framing work and obscures the distinction between builtin and hook protocols. Introduce apply_fsmonitor_paths() and call it immediately from the builtin branch of refresh_fsmonitor(). Walk the already validated path buffer, invalidate each reported path exactly once, and retain the resulting path count. Preserve hook token offsets, malformed-response handling, and global invalidation. The parser and unit tests from S05/P04 provide the bounded input; this refactor adds no separate benchmark or test execution claim. Signed-off-by: Taylor Blau --- fsmonitor.c | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index b88a5c377894af..d2d369e6a1d2e5 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -815,6 +815,23 @@ static enum fsmonitor_query_outcome query_builtin_fsmonitor( return result->outcome; } +static int apply_fsmonitor_paths(struct index_state *istate, + const struct strbuf *paths) +{ + const char *p = paths->buf; + const char *end = paths->buf + paths->len; + int count = 0; + + while (p < end) { + size_t len = strlen(p); + + fsmonitor_refresh_callback(istate, (char *)p); + count++; + p += len + 1; + } + return count; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -974,17 +991,21 @@ void refresh_fsmonitor(struct index_state *istate) */ int count = 0; - buf = query_result.buf; - for (i = bol; i < query_result.len; i++) { - if (buf[i] != '\0') - continue; - fsmonitor_refresh_callback(istate, buf + bol); - bol = i + 1; - count++; - } - if (bol < query_result.len) { - fsmonitor_refresh_callback(istate, buf + bol); - count++; + if (fsm_mode == FSMONITOR_MODE_IPC) { + count = apply_fsmonitor_paths(istate, &query_result); + } else { + buf = query_result.buf; + for (i = bol; i < query_result.len; i++) { + if (buf[i] != '\0') + continue; + fsmonitor_refresh_callback(istate, buf + bol); + bol = i + 1; + count++; + } + if (bol < query_result.len) { + fsmonitor_refresh_callback(istate, buf + bol); + count++; + } } /* Now mark the untracked cache for fsmonitor usage */ From c07bbdb27748166873996fe2e1165451d5af0d46 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:51:39 -0500 Subject: [PATCH 185/403] status: prepare logical-index history checkpoints An exact clean-result sidecar must remain bound to one physical index, but resumable fsmonitor history needs to survive a format-only rewrite by another Git implementation. It cannot use the index checksum or file identity as its cross-implementation key. Promote the checksummed path snapshot operations needed by an external store. They open the named index without following its final symlink and retain the descriptor, then require the descriptor and current pathname to identify the same valid index. Null checksums remain ineligible for durable snapshot pins. When fstat identity is reliable, retain the validated reader descriptor for process-local proof epochs only; generic certification and persisted CSHS still require a non-null checksum. Define a canonical digest of the ordered logical entries. Include the entry count and each path, stage, object ID, mode, CE_VALID, skip-worktree, and intent-to-add state, while excluding index encoding, cached stat data, and acceleration-only flags. Unsupported transient state rejects the digest rather than disappearing with the process. Add the checksummed CSHS codec and a local-APFS-only, nofollow, atomically-replaced store bounded to eight 16-MiB namespace slots. This commit has no status caller; the following history patch restores and saves complete checkpoints through this persistence layer. Cover both object formats, malformed and null-checksum snapshots, pathname replacement, logical-entry bindings, malformed and independent checkpoint namespaces, bounded retention, and idempotent writes. A checkpoint may contain only the required FSMN and FSCF payloads. Skip absent optional payloads rather than handing a NULL source and zero length to memcpy(). Extend the malformed-checkpoint unit test to round-trip that minimal valid form before its rejection cases. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-history-store.c | 462 ++++++++++++++++++++ clean-status-history-store.h | 50 +++ clean-status-index.c | 231 ++++++++++ clean-status-index.h | 34 ++ clean-status-internal.h | 3 + clean-status.c | 3 + clean-status.h | 3 + meson.build | 1 + read-cache.c | 14 +- t/meson.build | 1 + t/unit-tests/u-clean-status-history-store.c | 363 +++++++++++++++ t/unit-tests/u-clean-status-index.c | 349 +++++++++++++++ 13 files changed, 1513 insertions(+), 3 deletions(-) create mode 100644 clean-status-history-store.c create mode 100644 clean-status-history-store.h create mode 100644 clean-status-index.h create mode 100644 t/unit-tests/u-clean-status-history-store.c diff --git a/Makefile b/Makefile index cde58e8bd4728f..5da7272e3e5e67 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-history-store.o LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o @@ -1557,6 +1558,7 @@ CLAR_TEST_SUITES += u-attr-fingerprint CLAR_TEST_SUITES += u-attr-manifest CLAR_TEST_SUITES += u-clean-status-config CLAR_TEST_SUITES += u-clean-status-history +CLAR_TEST_SUITES += u-clean-status-history-store CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest diff --git a/clean-status-history-store.c b/clean-status-history-store.c new file mode 100644 index 00000000000000..572264ffafb4b2 --- /dev/null +++ b/clean-status-history-store.c @@ -0,0 +1,462 @@ +#include "git-compat-util.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-history-store.h" +#include "clean-status-identity.h" +#include "clean-status-index.h" +#include "hash-framing.h" +#include "hex.h" +#include "lockfile.h" +#include "path.h" +#include "strbuf.h" +#include "wrapper.h" + +#define CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC "CSHS" +#define CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION 1 +#define CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE (16 * 1024 * 1024) +#define CLEAN_STATUS_HISTORY_STORE_MAX_FILES 8 +#define CLEAN_STATUS_HISTORY_HAS_FSMN (1U << 0) +#define CLEAN_STATUS_HISTORY_HAS_UNTR (1U << 1) +#define CLEAN_STATUS_HISTORY_HAS_FSCF (1U << 2) +#define CLEAN_STATUS_HISTORY_HAS_FSUC (1U << 3) +#define CLEAN_STATUS_FILESYSTEM_ID_SIZE 16 + +struct clean_status_filesystem_id { + unsigned char value[CLEAN_STATUS_FILESYSTEM_ID_SIZE]; +}; + +static int checksum_valid(const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *bytes = data; + unsigned char actual[GIT_MAX_RAWSZ]; + + if (len < algo->rawsz) + return 0; + hash_buffer_digest(algo, data, len - algo->rawsz, actual); + return !memcmp(actual, bytes + len - algo->rawsz, algo->rawsz); +} + +static void proof_namespace_hash(const char *proof_namespace, + const struct git_hash_algo *algo, + unsigned char *out) +{ + static const char domain[] = "git-clean-status-history-namespace-v1"; + struct git_hash_ctx ctx; + + git_hash_init(&ctx, algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, proof_namespace, strlen(proof_namespace)); + git_hash_final(out, &ctx); +} + +static char *history_store_path(const char *index_path, + const char *proof_namespace, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + char hex[GIT_MAX_HEXSZ + 1]; + + proof_namespace_hash(proof_namespace, algo, hash); + hash_to_hex_algop_r(hex, hash, algo); + return xstrfmt("%s.csh1.%s", index_path, hex); +} + +struct history_store_file { + char *path; + timestamp_t mtime; + unsigned int mtime_nsec; + unsigned retained : 1; +}; + +static int history_store_file_cmp(const void *va, const void *vb) +{ + const struct history_store_file *a = va; + const struct history_store_file *b = vb; + + if (a->mtime != b->mtime) + return a->mtime < b->mtime ? -1 : 1; + if (a->mtime_nsec != b->mtime_nsec) + return a->mtime_nsec < b->mtime_nsec ? -1 : 1; + return strcmp(a->path, b->path); +} + +/* + * The status caller holds index.lock while publishing a checkpoint. That + * serializes this directory-level retention step with every supported + * publisher, while per-slot lockfiles still make each replacement atomic. + */ +static int prune_history_store(const char *index_path, + const char *retained_path, + const struct git_hash_algo *algo, + size_t limit) +{ + struct history_store_file *files = NULL; + struct strbuf directory = STRBUF_INIT; + struct strbuf prefix = STRBUF_INIT; + struct strbuf candidate = STRBUF_INIT; + const char *slash = find_last_dir_sep(index_path); + const char *base = slash ? slash + 1 : index_path; + const char *retained_slash = find_last_dir_sep(retained_path); + const char *retained_base = retained_slash ? + retained_slash + 1 : retained_path; + DIR *dir = NULL; + struct dirent *de; + size_t nr = 0, alloc = 0, remove_nr; + int ret = -1; + + if (slash) { + if (slash == index_path) + strbuf_addch(&directory, '/'); + else + strbuf_add(&directory, index_path, slash - index_path); + } else { + strbuf_addch(&directory, '.'); + } + strbuf_addf(&prefix, "%s.csh1.", base); + dir = opendir(directory.buf); + if (!dir) + goto done; + while ((de = readdir(dir))) { + const char *suffix; + struct stat st; + + if (!starts_with(de->d_name, prefix.buf)) + continue; + suffix = de->d_name + prefix.len; + if (strlen(suffix) != algo->hexsz || + strspn(suffix, "0123456789abcdef") != algo->hexsz) + continue; + strbuf_reset(&candidate); + strbuf_addf(&candidate, "%s/%s", directory.buf, de->d_name); + if (lstat(candidate.buf, &st) || !S_ISREG(st.st_mode)) + continue; + ALLOC_GROW(files, nr + 1, alloc); + files[nr].path = xstrdup(candidate.buf); + files[nr].mtime = st.st_mtime; + files[nr].mtime_nsec = ST_MTIME_NSEC(st); + files[nr].retained = !strcmp(de->d_name, retained_base); + nr++; + } + if (limit >= nr) { + ret = 0; + goto done; + } + QSORT(files, nr, history_store_file_cmp); + remove_nr = nr - limit; + for (size_t i = 0; i < nr && remove_nr; i++) { + struct stat st; + + if (files[i].retained) + continue; + /* Recheck without following links immediately before removal. */ + if (lstat(files[i].path, &st) || !S_ISREG(st.st_mode) || + unlink(files[i].path)) + goto done; + remove_nr--; + } + ret = remove_nr ? -1 : 0; + +done: + if (dir) + closedir(dir); + for (size_t i = 0; i < nr; i++) + free(files[i].path); + free(files); + strbuf_release(&candidate); + strbuf_release(&prefix); + strbuf_release(&directory); + return ret; +} + +static int open_nofollow_nonblocking(const char *path, int flags) +{ +#ifdef O_NONBLOCK + return open_nofollow(path, flags | O_NONBLOCK); +#else + (void)path; + (void)flags; + errno = ENOSYS; + return -1; +#endif +} + +int clean_status_history_checkpoint_parse( + struct clean_status_history_checkpoint *checkpoint, + const char *proof_namespace, const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *p = data; + const unsigned char *end; + const unsigned char *payload; + unsigned char expected_namespace[GIT_MAX_RAWSZ]; + size_t minimum = 4 + 2 * sizeof(uint32_t) + 2 * algo->rawsz + + 4 * sizeof(uint32_t) + algo->rawsz; + uint32_t flags, lengths[4]; + + memset(checkpoint, 0, sizeof(*checkpoint)); + if (!proof_namespace || !*proof_namespace || len < minimum || + len > CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE || + memcmp(p, CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC, 4) || + !checksum_valid(data, len, algo)) + return -1; + end = p + len - algo->rawsz; + p += 4; + if (get_be32(p) != CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) + return -1; + p += sizeof(uint32_t); + flags = get_be32(p); + p += sizeof(uint32_t); + if ((flags & (CLEAN_STATUS_HISTORY_HAS_FSMN | + CLEAN_STATUS_HISTORY_HAS_FSCF)) != + (CLEAN_STATUS_HISTORY_HAS_FSMN | + CLEAN_STATUS_HISTORY_HAS_FSCF) || + !!(flags & CLEAN_STATUS_HISTORY_HAS_UNTR) != + !!(flags & CLEAN_STATUS_HISTORY_HAS_FSUC) || + flags & ~(CLEAN_STATUS_HISTORY_HAS_FSMN | + CLEAN_STATUS_HISTORY_HAS_UNTR | + CLEAN_STATUS_HISTORY_HAS_FSCF | + CLEAN_STATUS_HISTORY_HAS_FSUC)) + return -1; + proof_namespace_hash(proof_namespace, algo, expected_namespace); + if (memcmp(p, expected_namespace, algo->rawsz)) + return -1; + p += algo->rawsz; + memcpy(checkpoint->index_hash, p, algo->rawsz); + p += algo->rawsz; + for (size_t i = 0; i < ARRAY_SIZE(lengths); i++) { + lengths[i] = get_be32(p); + p += sizeof(uint32_t); + } + payload = p; + if (!!lengths[0] != !!(flags & CLEAN_STATUS_HISTORY_HAS_FSMN) || + !!lengths[1] != !!(flags & CLEAN_STATUS_HISTORY_HAS_UNTR) || + !!lengths[2] != !!(flags & CLEAN_STATUS_HISTORY_HAS_FSCF) || + !!lengths[3] != !!(flags & CLEAN_STATUS_HISTORY_HAS_FSUC)) + return -1; + for (size_t i = 0; i < ARRAY_SIZE(lengths); i++) { + if ((size_t)(end - p) < lengths[i]) + return -1; + p += lengths[i]; + } + if (p != end) + return -1; + p = payload; + if (lengths[0]) { + checkpoint->fsmonitor = p; + checkpoint->fsmonitor_len = lengths[0]; + p += lengths[0]; + } + if (lengths[1]) { + checkpoint->untracked_cache = p; + checkpoint->untracked_cache_len = lengths[1]; + p += lengths[1]; + } + if (lengths[2]) { + checkpoint->fsmonitor_config = p; + checkpoint->fsmonitor_config_len = lengths[2]; + p += lengths[2]; + } + if (lengths[3]) { + checkpoint->fsmonitor_untracked = p; + checkpoint->fsmonitor_untracked_len = lengths[3]; + } + return 0; +} + +int clean_status_history_checkpoint_write( + struct strbuf *out, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct git_hash_algo *algo) +{ + unsigned char namespace_hash[GIT_MAX_RAWSZ]; + uint32_t value, flags = 0; + + strbuf_reset(out); + if (!proof_namespace || !*proof_namespace || + checkpoint->fsmonitor_len > UINT32_MAX || + checkpoint->untracked_cache_len > UINT32_MAX || + checkpoint->fsmonitor_config_len > UINT32_MAX || + checkpoint->fsmonitor_untracked_len > UINT32_MAX || + (!!checkpoint->fsmonitor != !!checkpoint->fsmonitor_len) || + (!!checkpoint->untracked_cache != + !!checkpoint->untracked_cache_len) || + (!!checkpoint->fsmonitor_config != + !!checkpoint->fsmonitor_config_len) || + (!!checkpoint->fsmonitor_untracked != + !!checkpoint->fsmonitor_untracked_len) || + !checkpoint->fsmonitor_len || !checkpoint->fsmonitor_config_len || + (!!checkpoint->untracked_cache_len != + !!checkpoint->fsmonitor_untracked_len)) + return -1; + flags |= CLEAN_STATUS_HISTORY_HAS_FSMN; + if (checkpoint->untracked_cache_len) + flags |= CLEAN_STATUS_HISTORY_HAS_UNTR; + if (checkpoint->fsmonitor_config_len) + flags |= CLEAN_STATUS_HISTORY_HAS_FSCF; + if (checkpoint->fsmonitor_untracked_len) + flags |= CLEAN_STATUS_HISTORY_HAS_FSUC; + proof_namespace_hash(proof_namespace, algo, namespace_hash); + strbuf_add(out, CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC, 4); + put_be32(&value, CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, flags); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, namespace_hash, algo->rawsz); + strbuf_add(out, checkpoint->index_hash, algo->rawsz); + put_be32(&value, checkpoint->fsmonitor_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->untracked_cache_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->fsmonitor_config_len); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->fsmonitor_untracked_len); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, checkpoint->fsmonitor, checkpoint->fsmonitor_len); + if (checkpoint->untracked_cache_len) + strbuf_add(out, checkpoint->untracked_cache, + checkpoint->untracked_cache_len); + strbuf_add(out, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len); + if (checkpoint->fsmonitor_untracked_len) + strbuf_add(out, checkpoint->fsmonitor_untracked, + checkpoint->fsmonitor_untracked_len); + hash_append_checksum(out, algo); + if (out->len > CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE) { + strbuf_reset(out); + return -1; + } + return 0; +} + +int clean_status_history_store_load( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo, + struct clean_status_history_store_record *record) +{ + struct stat st; + char extra; + char *path = history_store_path(index_path, proof_namespace, algo); + int fd = -1, ret = -1; + size_t size; + + memset(&record->checkpoint, 0, sizeof(record->checkpoint)); + strbuf_reset(&record->storage); + fd = open_nofollow_nonblocking(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || fstat(fd, &st) || !S_ISREG(st.st_mode) || + st.st_size < 0 || + st.st_size > CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE) + goto done; + size = xsize_t(st.st_size); + strbuf_grow(&record->storage, size); + strbuf_setlen(&record->storage, size); + if ((size_t)read_in_full(fd, record->storage.buf, size) != size || + read(fd, &extra, 1) != 0 || + clean_status_history_checkpoint_parse( + &record->checkpoint, proof_namespace, record->storage.buf, + record->storage.len, algo)) + goto done; + ret = 0; + +done: + if (ret) + strbuf_reset(&record->storage); + if (fd >= 0) + close(fd); + free(path); + return ret; +} + +void clean_status_history_store_record_release( + struct clean_status_history_store_record *record) +{ + strbuf_release(&record->storage); + memset(&record->checkpoint, 0, sizeof(record->checkpoint)); +} + +static int local_apfs_id(int fd MAYBE_UNUSED, + struct clean_status_filesystem_id *id) +{ +#ifdef __APPLE__ + struct statfs fs; +#endif + + memset(id, 0, sizeof(*id)); +#ifdef __APPLE__ + if (fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs") || + sizeof(fs.f_fsid) > sizeof(id->value)) + return -1; + memcpy(id->value, &fs.f_fsid, sizeof(fs.f_fsid)); + return 0; +#else + return -1; +#endif +} + +int clean_status_history_store_install( + const char *index_path, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct clean_status_filesystem_id fsid; + struct clean_status_history_store_record current = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct strbuf encoded = STRBUF_INIT; + struct lock_file lock = LOCK_INIT; + char *path = history_store_path(index_path, proof_namespace, algo); + struct stat st; + int current_is_regular, encoded_matches = 0; + int checkpoint_fd = -1, ret = -1; + + if (!clean_status_identity_is_durable() || !snapshot || + snapshot->fd < 0 || local_apfs_id(snapshot->fd, &fsid) || + !clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo) || + clean_status_history_checkpoint_write( + &encoded, proof_namespace, checkpoint, algo)) + goto done; + current_is_regular = !lstat(path, &st) && S_ISREG(st.st_mode); + if (!clean_status_history_store_load( + index_path, proof_namespace, algo, ¤t)) + encoded_matches = current.storage.len == encoded.len && + !memcmp(current.storage.buf, encoded.buf, encoded.len); + clean_status_history_store_record_release(¤t); + + /* + * If this namespace is new, make room before the atomic install so a + * successful publication never takes the bounded store above eight + * regular schema-v1 slots. No other checkpoint schema is considered. + */ + if (prune_history_store( + index_path, path, algo, + current_is_regular ? CLEAN_STATUS_HISTORY_STORE_MAX_FILES : + CLEAN_STATUS_HISTORY_STORE_MAX_FILES - 1) || + !clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo)) + goto done; + if (encoded_matches) { + ret = 0; + goto done; + } + checkpoint_fd = hold_lock_file_for_update(&lock, path, 0); + if (checkpoint_fd < 0 || + (size_t)write_in_full(checkpoint_fd, encoded.buf, encoded.len) != + encoded.len || + !clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo) || + commit_lock_file(&lock)) + goto done; + ret = 0; + +done: + if (ret) + rollback_lock_file(&lock); + free(path); + strbuf_release(&encoded); + return ret; +} diff --git a/clean-status-history-store.h b/clean-status-history-store.h new file mode 100644 index 00000000000000..22f3f6d5a085fd --- /dev/null +++ b/clean-status-history-store.h @@ -0,0 +1,50 @@ +#ifndef CLEAN_STATUS_HISTORY_STORE_H +#define CLEAN_STATUS_HISTORY_STORE_H + +#include "hash.h" +#include "strbuf.h" + +struct clean_status_index_snapshot; + +struct clean_status_history_checkpoint { + unsigned char index_hash[GIT_MAX_RAWSZ]; + const unsigned char *fsmonitor; + size_t fsmonitor_len; + const unsigned char *untracked_cache; + size_t untracked_cache_len; + const unsigned char *fsmonitor_config; + size_t fsmonitor_config_len; + const unsigned char *fsmonitor_untracked; + size_t fsmonitor_untracked_len; +}; + +struct clean_status_history_store_record { + struct clean_status_history_checkpoint checkpoint; + struct strbuf storage; +}; + +#define CLEAN_STATUS_HISTORY_STORE_RECORD_INIT { \ + .storage = STRBUF_INIT, \ +} + +int clean_status_history_checkpoint_parse( + struct clean_status_history_checkpoint *checkpoint, + const char *proof_namespace, const void *data, size_t len, + const struct git_hash_algo *algo); +int clean_status_history_checkpoint_write( + struct strbuf *out, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct git_hash_algo *algo); +int clean_status_history_store_load( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo, + struct clean_status_history_store_record *record); +void clean_status_history_store_record_release( + struct clean_status_history_store_record *record); +int clean_status_history_store_install( + const char *index_path, const char *proof_namespace, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo); + +#endif /* CLEAN_STATUS_HISTORY_STORE_H */ diff --git a/clean-status-index.c b/clean-status-index.c index 4733e53e3a9fcc..51399c0f24f720 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -1,7 +1,215 @@ #include "git-compat-util.h" #include "clean-status.h" +#include "clean-status-index.h" #include "clean-status-internal.h" +#include "hash-framing.h" #include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" +#include "wrapper.h" + +static int snapshot_read( + int fd, const struct stat *st, const struct git_hash_algo *algo, + uint32_t *version, uint32_t *cache_nr, struct object_id *checksum) +{ + unsigned char header[12]; + unsigned char trailer[GIT_MAX_RAWSZ]; + + if (st->st_size < 0 || + (uintmax_t)st->st_size < sizeof(header) + algo->rawsz || + (size_t)pread_in_full(fd, header, sizeof(header), 0) != + sizeof(header) || + memcmp(header, "DIRC", 4) || + (size_t)pread_in_full(fd, trailer, algo->rawsz, + st->st_size - (off_t)algo->rawsz) != + algo->rawsz) + return -1; + *version = get_be32(header + 4); + *cache_nr = get_be32(header + 8); + if (*version < 2 || *version > 4) + return -1; + oidread(checksum, trailer, algo); + return 0; +} + +static int snapshot_matches( + int fd, const struct stat *st, uint32_t expected_version, + uint32_t expected_cache_nr, const struct object_id *expected_checksum, + const struct git_hash_algo *algo) +{ + struct object_id checksum; + uint32_t version, cache_nr; + + return !snapshot_read(fd, st, algo, &version, &cache_nr, &checksum) && + version == expected_version && cache_nr == expected_cache_nr && + oideq(&checksum, expected_checksum); +} + +static int snapshot_open( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo, int allow_null_checksum) +{ + struct clean_status_identity named; + struct stat fd_st, named_st; + int fd; + + memset(snapshot, 0, sizeof(*snapshot)); + snapshot->fd = -1; + fd = open_nofollow(path, O_RDONLY); + if (fd < 0 || + fstat(fd, &fd_st) || + lstat(path, &named_st) || + clean_status_identity_from_stat(&snapshot->identity, &fd_st) || + clean_status_identity_from_stat(&named, &named_st) || + !clean_status_identity_equal(&snapshot->identity, &named) || + snapshot_read(fd, &fd_st, algo, &snapshot->version, + &snapshot->cache_nr, &snapshot->checksum) || + (!allow_null_checksum && is_null_oid(&snapshot->checksum))) + goto fail; + snapshot->fd = fd; + return 0; + +fail: + if (fd >= 0) + close(fd); + return -1; +} + +int clean_status_index_snapshot_open( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo) +{ + return snapshot_open(snapshot, path, algo, 0); +} + +int clean_status_index_snapshot_still_matches_path( + const struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo) +{ + struct clean_status_identity fd_identity, named_identity; + struct stat fd_st, named_st; + + return snapshot->fd >= 0 && + !fstat(snapshot->fd, &fd_st) && + !lstat(path, &named_st) && + !clean_status_identity_from_stat(&fd_identity, &fd_st) && + !clean_status_identity_from_stat(&named_identity, &named_st) && + clean_status_identity_equal(&fd_identity, &snapshot->identity) && + clean_status_identity_equal(&named_identity, + &snapshot->identity) && + snapshot_matches(snapshot->fd, &fd_st, snapshot->version, + snapshot->cache_nr, &snapshot->checksum, algo); +} + +static int snapshot_matches_index_state( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return istate->version == snapshot->version && + istate->cache_nr == snapshot->cache_nr && + oideq(&istate->oid, &snapshot->checksum) && + (!is_null_oid(&snapshot->checksum) || + (clean_status_identity_is_durable() && state && + state->source_identity_valid && + clean_status_identity_equal(&snapshot->identity, + &state->source_identity))); +} + +int clean_status_index_snapshot_pin( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate) +{ + if (snapshot_open(snapshot, istate->repo->index_file, + istate->repo->hash_algo, 1)) + return -1; + if (snapshot_matches_index_state(snapshot, istate)) + return 0; + clean_status_index_snapshot_release(snapshot); + return -1; +} + +int clean_status_index_snapshot_still_matches( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + return snapshot_matches_index_state(snapshot, istate) && + clean_status_index_snapshot_still_matches_path( + snapshot, istate->repo->index_file, + istate->repo->hash_algo); +} + +void clean_status_index_snapshot_release( + struct clean_status_index_snapshot *snapshot) +{ + if (snapshot->fd >= 0) + close(snapshot->fd); + snapshot->fd = -1; +} + +static int index_logical_digest(const struct index_state *istate, + unsigned int extra_benign_flags, + unsigned char *out) +{ + static const char domain[] = "git-clean-status-logical-index-v1"; + const unsigned int persistent_flags = + CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; + const unsigned int benign_flags = + CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID; + struct git_hash_ctx ctx; + uint32_t value; + int initialized = 0, ret = -1; + + if (!istate->repo || !istate->repo->hash_algo || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + istate->cache_nr > UINT32_MAX) + return -1; + trace2_region_enter("fsmonitor", "history_logical_digest", + istate->repo); + git_hash_init(&ctx, istate->repo->hash_algo); + initialized = 1; + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + put_be32(&value, istate->cache_nr); + hash_length_delimited(&ctx, &value, sizeof(value)); + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + /* + * Every in-memory flag not explicitly known to be an + * acceleration hint may describe work which must be completed + * before the entry is safe to externalize. In particular, + * CE_CONTENT_CHECK_REQUIRED must not disappear with the process + * which raised it. + */ + if (ce->ce_flags & ~(persistent_flags | benign_flags | + extra_benign_flags)) + goto done; + put_be32(&value, ce->ce_mode); + hash_length_delimited(&ctx, &value, sizeof(value)); + put_be32(&value, ce->ce_flags & persistent_flags); + hash_length_delimited(&ctx, &value, sizeof(value)); + hash_length_delimited(&ctx, ce->oid.hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, ce->name, ce_namelen(ce)); + } + git_hash_final(out, &ctx); + initialized = 0; + ret = 0; + +done: + if (initialized) + git_hash_discard(&ctx); + trace2_region_leave("fsmonitor", "history_logical_digest", + istate->repo); + return ret; +} + +int clean_status_index_logical_digest(const struct index_state *istate, + unsigned char *out) +{ + return index_logical_digest(istate, 0, out); +} void clean_status_record_source_identity(struct index_state *istate, const struct stat *st) @@ -15,6 +223,29 @@ void clean_status_record_source_identity(struct index_state *istate, state->source_identity_valid = 1; } +int clean_status_retain_source_index_fd(struct index_state *istate, int fd, + const struct stat *st) +{ + struct clean_status_state *state = istate->clean_status; + struct clean_status_identity identity, current_identity; + struct stat current; + + if (!fstat_is_reliable() || fd < 0 || !state || + !state->config_enforced || istate->split_index || + !is_null_oid(&istate->oid) || + state->source_index_fd >= 0 || + clean_status_identity_from_stat(&identity, st) || + fstat(fd, ¤t) || + clean_status_identity_from_stat(¤t_identity, ¤t) || + !clean_status_identity_equal(&identity, ¤t_identity)) + return 0; + state->source_index_fd = fd; + state->source_index_identity = identity; + state->source_index_identity_valid = 1; + /* Ownership transfers only after every fail-closed check succeeds. */ + return 1; +} + int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st) { diff --git a/clean-status-index.h b/clean-status-index.h new file mode 100644 index 00000000000000..b8c7dbb78487f0 --- /dev/null +++ b/clean-status-index.h @@ -0,0 +1,34 @@ +#ifndef CLEAN_STATUS_INDEX_H +#define CLEAN_STATUS_INDEX_H + +#include "clean-status-identity.h" +#include "hash.h" + +struct index_state; + +struct clean_status_index_snapshot { + struct clean_status_identity identity; + uint32_t version; + uint32_t cache_nr; + struct object_id checksum; + int fd; +}; + +int clean_status_index_snapshot_open( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo); +int clean_status_index_snapshot_still_matches_path( + const struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo); +int clean_status_index_snapshot_pin( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate); +int clean_status_index_snapshot_still_matches( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate); +void clean_status_index_snapshot_release( + struct clean_status_index_snapshot *snapshot); +int clean_status_index_logical_digest(const struct index_state *istate, + unsigned char *out); + +#endif /* CLEAN_STATUS_INDEX_H */ diff --git a/clean-status-internal.h b/clean-status-internal.h index 1cf565c46ca6af..65dd4da0ae4019 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -8,10 +8,12 @@ struct index_state; struct clean_status_state { struct clean_status_identity source_identity; + struct clean_status_identity source_index_identity; struct clean_status_manifest_state manifest; struct strbuf disk_config_raw; char *disk_config_token; char *config_revalidated_token; + int source_index_fd; unsigned char current_config_hash[GIT_MAX_RAWSZ]; unsigned char disk_config_hash[GIT_MAX_RAWSZ]; unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; @@ -32,6 +34,7 @@ struct clean_status_state { unsigned config_revalidated : 1; unsigned initial_coherent : 1; unsigned source_identity_valid : 1; + unsigned source_index_identity_valid : 1; unsigned disk_config_valid : 1; unsigned disk_semantic_valid : 1; unsigned disk_attr_valid : 1; diff --git a/clean-status.c b/clean-status.c index 96ea9511ac2a2b..ea1d02e00ae09f 100644 --- a/clean-status.c +++ b/clean-status.c @@ -17,6 +17,7 @@ struct clean_status_state *clean_status_get_state(struct index_state *istate) { if (!istate->clean_status) { CALLOC_ARRAY(istate->clean_status, 1); + istate->clean_status->source_index_fd = -1; clean_status_manifest_init(&istate->clean_status->manifest); strbuf_init(&istate->clean_status->disk_config_raw, 0); } @@ -184,6 +185,8 @@ void clean_status_release(struct index_state *istate) { if (!istate->clean_status) return; + if (istate->clean_status->source_index_fd >= 0) + close(istate->clean_status->source_index_fd); clean_status_manifest_release(&istate->clean_status->manifest); strbuf_release(&istate->clean_status->disk_config_raw); free(istate->clean_status->disk_config_token); diff --git a/clean-status.h b/clean-status.h index a609769f9c56ea..f6c001a6e834f4 100644 --- a/clean-status.h +++ b/clean-status.h @@ -44,6 +44,9 @@ int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); +/* Takes ownership of fd only when it returns 1. */ +int clean_status_retain_source_index_fd(struct index_state *istate, int fd, + const struct stat *st); int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st); diff --git a/meson.build b/meson.build index 59b7097fca049d..1c52ff8fb71826 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-history-store.c', 'clean-status-history.c', 'clean-status-identity.c', 'clean-status-index.c', diff --git a/read-cache.c b/read-cache.c index 40f2fffbf37890..0e8be0ab995058 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2288,7 +2288,7 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) istate->timestamp.sec = 0; istate->timestamp.nsec = 0; - fd = open(path, O_RDONLY); + fd = git_open_cloexec(path, O_RDONLY); if (fd < 0) { if (!must_exist && errno == ENOENT) { set_new_index_sparsity(istate); @@ -2307,10 +2307,14 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) die(_("%s: index file smaller than expected"), path); mmap = xmmap_gently(NULL, mmap_size, PROT_READ, MAP_PRIVATE, fd, 0); - if (mmap == MAP_FAILED) + if (mmap == MAP_FAILED) { + int mmap_errno = errno; + + close(fd); + errno = mmap_errno; die_errno(_("%s: unable to map index file%s"), path, mmap_os_err()); - close(fd); + } hdr = (const struct cache_header *)mmap; if (verify_hdr(hdr, mmap_size) < 0) @@ -2402,9 +2406,13 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) else ensure_correct_sparsity(istate); + if (!clean_status_retain_source_index_fd(istate, fd, &st)) + close(fd); + return istate->cache_nr; unmap: + close(fd); munmap((void *)mmap, mmap_size); die(_("index file corrupt")); } diff --git a/t/meson.build b/t/meson.build index e4131eac00a60e..a250fa109d3f4c 100644 --- a/t/meson.build +++ b/t/meson.build @@ -3,6 +3,7 @@ clar_test_suites = [ 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', 'unit-tests/u-clean-status-history.c', + 'unit-tests/u-clean-status-history-store.c', 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', diff --git a/t/unit-tests/u-clean-status-history-store.c b/t/unit-tests/u-clean-status-history-store.c new file mode 100644 index 00000000000000..53fdf0c92d3628 --- /dev/null +++ b/t/unit-tests/u-clean-status-history-store.c @@ -0,0 +1,363 @@ +#include "unit-test.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-history-store.h" +#include "clean-status-index.h" +#include "dir.h" +#include "hash-framing.h" +#include "hex.h" +#include "strbuf.h" + +struct history_store_fixture { + char *directory; + struct strbuf index_path; +}; + +static void fixture_init(struct history_store_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct strbuf index = STRBUF_INIT; + const char *tmp = getenv("TMPDIR"); + uint32_t value; + + memset(fixture, 0, sizeof(*fixture)); + fixture->index_path = (struct strbuf)STRBUF_INIT; + fixture->directory = xstrfmt("%s/status-history-store.XXXXXX", + tmp ? tmp : "/tmp"); + cl_assert(mkdtemp(fixture->directory) != NULL); + strbuf_addf(&fixture->index_path, "%s/index", fixture->directory); + strbuf_addstr(&index, "DIRC"); + put_be32(&value, 4); + strbuf_add(&index, &value, sizeof(value)); + put_be32(&value, 5); + strbuf_add(&index, &value, sizeof(value)); + strbuf_addchars(&index, 2, algo->rawsz); + write_file_buf(fixture->index_path.buf, index.buf, index.len); + strbuf_release(&index); +} + +static void fixture_release(struct history_store_fixture *fixture) +{ + struct strbuf cleanup = STRBUF_INIT; + + strbuf_addstr(&cleanup, fixture->directory); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + strbuf_release(&fixture->index_path); + free(fixture->directory); +} + +static void replace_checksum(struct strbuf *encoded, + const struct git_hash_algo *algo) +{ + strbuf_setlen(encoded, encoded->len - algo->rawsz); + hash_append_checksum(encoded, algo); +} + +static struct strbuf history_store_path_for_index( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo) +{ + static const char domain[] = "git-clean-status-history-namespace-v1"; + struct git_hash_ctx ctx; + unsigned char hash[GIT_MAX_RAWSZ]; + char hex[GIT_MAX_HEXSZ + 1]; + struct strbuf path = STRBUF_INIT; + + git_hash_init(&ctx, algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, proof_namespace, strlen(proof_namespace)); + git_hash_final(hash, &ctx); + hash_to_hex_algop_r(hex, hash, algo); + strbuf_addf(&path, "%s.csh1.%s", index_path, hex); + return path; +} + +static struct strbuf history_store_path( + struct history_store_fixture *fixture, const char *proof_namespace, + const struct git_hash_algo *algo) +{ + return history_store_path_for_index( + fixture->index_path.buf, proof_namespace, algo); +} + +static size_t count_history_store_files(const char *directory, + const char *index_basename, + const struct git_hash_algo *algo) +{ + struct strbuf prefix = STRBUF_INIT; + struct dirent *de; + DIR *dir = opendir(directory); + size_t nr = 0; + + cl_assert(dir != NULL); + strbuf_addf(&prefix, "%s.csh1.", index_basename); + while ((de = readdir(dir))) { + const char *suffix; + + if (!starts_with(de->d_name, prefix.buf)) + continue; + suffix = de->d_name + prefix.len; + if (strlen(suffix) == algo->hexsz && + strspn(suffix, "0123456789abcdef") == algo->hexsz) + nr++; + } + closedir(dir); + strbuf_release(&prefix); + return nr; +} + +static void require_local_apfs(const char *path MAYBE_UNUSED) +{ +#ifdef __APPLE__ + struct statfs fs; + int fd = git_open_cloexec(path, O_RDONLY); + + if (fd < 0 || fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs")) { + if (fd >= 0) + close(fd); + cl_skip(); + } + close(fd); +#else + cl_skip(); +#endif +} + +void test_clean_status_history_store__rejects_incomplete_checkpoints(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + static const unsigned char fsmn[] = "fsmn"; + static const unsigned char fscf[] = "fscf"; + struct clean_status_history_checkpoint checkpoint = { 0 }, parsed; + struct strbuf encoded = STRBUF_INIT; + const size_t flags_offset = 4 + sizeof(uint32_t); + + memset(checkpoint.index_hash, 1, algo->rawsz); + checkpoint.fsmonitor = fsmn; + checkpoint.fsmonitor_len = sizeof(fsmn) - 1; + checkpoint.fsmonitor_config = fscf; + checkpoint.fsmonitor_config_len = sizeof(fscf) - 1; + cl_assert_equal_i(clean_status_history_checkpoint_write( + &encoded, "proof-schema", &checkpoint, algo), 0); + + /* The optional UNTR and FSUC pair may both be absent. */ + cl_assert_equal_i(clean_status_history_checkpoint_parse( + &parsed, "proof-schema", encoded.buf, encoded.len, algo), 0); + cl_assert_equal_i(parsed.fsmonitor_len, sizeof(fsmn) - 1); + cl_assert(!memcmp(parsed.fsmonitor, fsmn, sizeof(fsmn) - 1)); + cl_assert_equal_i(parsed.untracked_cache_len, 0); + cl_assert(parsed.untracked_cache == NULL); + cl_assert_equal_i(parsed.fsmonitor_config_len, sizeof(fscf) - 1); + cl_assert(!memcmp(parsed.fsmonitor_config, fscf, sizeof(fscf) - 1)); + cl_assert_equal_i(parsed.fsmonitor_untracked_len, 0); + cl_assert(parsed.fsmonitor_untracked == NULL); + + /* A checkpoint must contain both FSMN and FSCF. */ + put_be32(encoded.buf + flags_offset, 1U << 1); + replace_checksum(&encoded, algo); + cl_assert_equal_i(clean_status_history_checkpoint_parse( + &parsed, "proof-schema", encoded.buf, encoded.len, algo), -1); + + /* UNTR is useful only together with its FSUC binding. */ + put_be32(encoded.buf + flags_offset, + (1U << 0) | (1U << 1) | (1U << 2)); + replace_checksum(&encoded, algo); + cl_assert_equal_i(clean_status_history_checkpoint_parse( + &parsed, "proof-schema", encoded.buf, encoded.len, algo), -1); + + strbuf_release(&encoded); +} + +void test_clean_status_history_store__keeps_namespaces_independent(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_history_store_record first_record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_history_store_record second_record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_history_checkpoint first = { 0 }, second = { 0 }; + struct clean_status_index_snapshot snapshot; + struct history_store_fixture fixture; + static const unsigned char first_fsmn[] = "first-fsmn"; + static const unsigned char first_fscf[] = "first-fscf"; + static const unsigned char second_fsmn[] = "second-fsmn"; + static const unsigned char second_fscf[] = "second-fscf"; + + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + fixture_init(&fixture, algo); + memset(first.index_hash, 1, algo->rawsz); + first.fsmonitor = first_fsmn; + first.fsmonitor_len = sizeof(first_fsmn) - 1; + first.fsmonitor_config = first_fscf; + first.fsmonitor_config_len = sizeof(first_fscf) - 1; + memset(second.index_hash, 2, algo->rawsz); + second.fsmonitor = second_fsmn; + second.fsmonitor_len = sizeof(second_fsmn) - 1; + second.fsmonitor_config = second_fscf; + second.fsmonitor_config_len = sizeof(second_fscf) - 1; + + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.index_path.buf, algo), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema-one", &first, + &snapshot, algo), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema-two", &second, + &snapshot, algo), 0); + cl_assert_equal_i(clean_status_history_store_load( + fixture.index_path.buf, "proof-schema-one", algo, + &first_record), 0); + cl_assert_equal_i(clean_status_history_store_load( + fixture.index_path.buf, "proof-schema-two", algo, + &second_record), 0); + cl_assert_equal_i(first_record.checkpoint.fsmonitor_config_len, + sizeof(first_fscf) - 1); + cl_assert(!memcmp(first_record.checkpoint.fsmonitor_config, + first_fscf, sizeof(first_fscf) - 1)); + cl_assert_equal_i(second_record.checkpoint.fsmonitor_config_len, + sizeof(second_fscf) - 1); + cl_assert(!memcmp(second_record.checkpoint.fsmonitor_config, + second_fscf, sizeof(second_fscf) - 1)); + + clean_status_history_store_record_release(&second_record); + clean_status_history_store_record_release(&first_record); + clean_status_index_snapshot_release(&snapshot); + fixture_release(&fixture); +} + +void test_clean_status_history_store__does_not_rewrite_unchanged_checkpoint(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + static const unsigned char fsmn[] = "fsmn"; + static const unsigned char fscf[] = "fscf"; + struct clean_status_history_checkpoint checkpoint = { 0 }; + struct clean_status_index_snapshot snapshot; + struct history_store_fixture fixture; + struct strbuf path; + struct stat before, after; + + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + fixture_init(&fixture, algo); + memset(checkpoint.index_hash, 1, algo->rawsz); + checkpoint.fsmonitor = fsmn; + checkpoint.fsmonitor_len = sizeof(fsmn) - 1; + checkpoint.fsmonitor_config = fscf; + checkpoint.fsmonitor_config_len = sizeof(fscf) - 1; + path = history_store_path(&fixture, "proof-schema", algo); + + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.index_path.buf, algo), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema", &checkpoint, + &snapshot, algo), 0); + cl_assert_equal_i(lstat(path.buf, &before), 0); + cl_assert_equal_i(clean_status_history_store_install( + fixture.index_path.buf, "proof-schema", &checkpoint, + &snapshot, algo), 0); + cl_assert_equal_i(lstat(path.buf, &after), 0); + cl_assert_equal_i(before.st_ino, after.st_ino); + + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&path); + fixture_release(&fixture); +} + +void test_clean_status_history_store__bounds_namespaces(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + static const unsigned char fsmn[] = "fsmn"; + static const unsigned char fscf[] = "fscf"; + struct clean_status_history_checkpoint checkpoint = { 0 }; + struct clean_status_index_snapshot snapshot; + struct clean_status_history_store_record record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct history_store_fixture fixture; + struct strbuf cwd = STRBUF_INIT; + struct strbuf encoded = STRBUF_INIT; + struct strbuf extra = STRBUF_INIT; + struct utimbuf times; + char namespace[32]; + + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + fixture_init(&fixture, algo); + checkpoint.fsmonitor = fsmn; + checkpoint.fsmonitor_len = sizeof(fsmn) - 1; + checkpoint.fsmonitor_config = fscf; + checkpoint.fsmonitor_config_len = sizeof(fscf) - 1; + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.index_path.buf, algo), 0); + cl_assert_equal_i(strbuf_getcwd(&cwd), 0); + cl_assert_equal_i(chdir(fixture.directory), 0); + for (size_t i = 0; i < 10; i++) { + struct strbuf path; + + xsnprintf(namespace, sizeof(namespace), "proof-schema-%"PRIuMAX, + (uintmax_t)i); + memset(checkpoint.index_hash, i + 1, algo->rawsz); + cl_assert_equal_i(clean_status_history_store_install( + "index", namespace, &checkpoint, &snapshot, algo), 0); + path = history_store_path_for_index("index", namespace, algo); + times.actime = times.modtime = 100 + i; + cl_assert_equal_i(utime(path.buf, ×), 0); + strbuf_release(&path); + } + for (size_t i = 0; i < 10; i++) { + xsnprintf(namespace, sizeof(namespace), "proof-schema-%"PRIuMAX, + (uintmax_t)i); + if (i < 2) { + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), -1); + } else { + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), 0); + clean_status_history_store_record_release(&record); + } + } + cl_assert_equal_i(count_history_store_files(".", "index", algo), 8); + + /* + * An identical reinstall must retain its target even when a relative + * index path makes the scanned candidate spell that path as "./...". + */ + xsnprintf(namespace, sizeof(namespace), "proof-schema-2"); + { + struct strbuf retained = history_store_path_for_index( + "index", namespace, algo); + + times.actime = times.modtime = 1; + cl_assert_equal_i(utime(retained.buf, ×), 0); + strbuf_addf(&extra, "index.csh1.%0*d", (int)algo->hexsz, 0); + cl_assert(strbuf_read_file(&encoded, retained.buf, 0) > 0); + write_file_buf(extra.buf, encoded.buf, encoded.len); + times.actime = times.modtime = 1000; + cl_assert_equal_i(utime(extra.buf, ×), 0); + cl_assert_equal_i( + count_history_store_files(".", "index", algo), 9); + + memset(checkpoint.index_hash, 3, algo->rawsz); + cl_assert_equal_i(clean_status_history_store_install( + "index", namespace, &checkpoint, &snapshot, algo), 0); + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), 0); + clean_status_history_store_record_release(&record); + cl_assert_equal_i( + count_history_store_files(".", "index", algo), 8); + strbuf_release(&retained); + } + xsnprintf(namespace, sizeof(namespace), "proof-schema-3"); + cl_assert_equal_i(clean_status_history_store_load( + "index", namespace, algo, &record), -1); + cl_assert_equal_i(chdir(cwd.buf), 0); + + clean_status_history_store_record_release(&record); + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&extra); + strbuf_release(&encoded); + strbuf_release(&cwd); + fixture_release(&fixture); +} diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index 5d769692eea894..35b4637a22be28 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -1,11 +1,287 @@ #include "unit-test.h" #include "clean-status.h" +#include "clean-status-index.h" #include "clean-status-internal.h" #include "dir.h" #include "read-cache-ll.h" +#include "repository.h" #include "strbuf.h" #include "wrapper.h" +struct index_fixture { + char *path; + int fd; + struct stat st; + struct object_id checksum; +}; + +static void fixture_init(struct index_fixture *fixture, + const struct git_hash_algo *algo) +{ + const char *tmp = getenv("TMPDIR"); + unsigned char header[12] = "DIRC"; + unsigned char hash[GIT_MAX_RAWSZ]; + static const char payload[] = "payload"; + + memset(fixture, 0, sizeof(*fixture)); + fixture->path = xstrfmt("%s/index-snapshot.XXXXXX", + tmp ? tmp : "/tmp"); + fixture->fd = mkstemp(fixture->path); + cl_assert(fixture->fd >= 0); + put_be32(header + 4, 4); + put_be32(header + 8, 7); + memset(hash, 1, algo->rawsz); + oidread(&fixture->checksum, hash, algo); + cl_assert_equal_i(write_in_full(fixture->fd, header, sizeof(header)), + sizeof(header)); + cl_assert_equal_i(write_in_full(fixture->fd, payload, sizeof(payload)), + sizeof(payload)); + cl_assert_equal_i(write_in_full(fixture->fd, hash, algo->rawsz), + algo->rawsz); + cl_assert_equal_i(fstat(fixture->fd, &fixture->st), 0); +} + +static void fixture_release(struct index_fixture *fixture) +{ + cl_assert_equal_i(close(fixture->fd), 0); + cl_assert_equal_i(unlink(fixture->path), 0); + free(fixture->path); +} + +static void write_at(int fd, const void *data, size_t len, off_t offset) +{ + cl_assert_equal_i(lseek(fd, offset, SEEK_SET), offset); + cl_assert_equal_i(write_in_full(fd, data, len), len); +} + +static void fixture_clear_checksum(struct index_fixture *fixture, + const struct git_hash_algo *algo) +{ + unsigned char null_hash[GIT_MAX_RAWSZ] = { 0 }; + + write_at(fixture->fd, null_hash, algo->rawsz, + fixture->st.st_size - algo->rawsz); + oidclr(&fixture->checksum, algo); + cl_assert_equal_i(fstat(fixture->fd, &fixture->st), 0); +} + +static void assert_reads_snapshot(const struct git_hash_algo *algo) +{ + struct index_fixture fixture; + struct clean_status_index_snapshot snapshot; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), 0); + cl_assert_equal_i(snapshot.version, 4); + cl_assert_equal_i(snapshot.cache_nr, 7); + cl_assert(oideq(&snapshot.checksum, &fixture.checksum)); + cl_assert(clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + clean_status_index_snapshot_release(&snapshot); + fixture_release(&fixture); +} + +void test_clean_status_index__reads_both_object_formats(void) +{ + assert_reads_snapshot(&hash_algos[GIT_HASH_SHA1]); + assert_reads_snapshot(&hash_algos[GIT_HASH_SHA256]); +} + +void test_clean_status_index__rejects_invalid_headers(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct index_fixture fixture; + struct clean_status_index_snapshot snapshot; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + uint32_t value; + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + write_at(fixture.fd, "NOPE", 4, 0); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + write_at(fixture.fd, "DIRC", 4, 0); + put_be32(&value, 1); + write_at(fixture.fd, &value, sizeof(value), 4); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + fixture_release(&fixture); +} + +static void assert_rejects_null_checksum(const struct git_hash_algo *algo) +{ + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + fixture_clear_checksum(&fixture, algo); + oidcpy(&istate.oid, &fixture.checksum); + cl_assert_equal_i(clean_status_index_snapshot_open( + &snapshot, fixture.path, algo), -1); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + fixture_release(&fixture); +} + +void test_clean_status_index__rejects_null_checksums(void) +{ + assert_rejects_null_checksum(&hash_algos[GIT_HASH_SHA1]); + assert_rejects_null_checksum(&hash_algos[GIT_HASH_SHA256]); +} + +static void assert_pins_null_checksum_source( + const struct git_hash_algo *algo) +{ + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture, replacement; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct index_state parsed = INDEX_STATE_INIT(&repo); + char *moved; + + fixture_init(&fixture, algo); + fixture_init(&replacement, algo); + fixture_clear_checksum(&fixture, algo); + fixture_clear_checksum(&replacement, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + parsed.version = 4; + parsed.cache_nr = 7; + oidcpy(&parsed.oid, &replacement.checksum); + clean_status_get_state(&istate); + clean_status_record_source_identity(&istate, &fixture.st); + clean_status_get_state(&parsed); + clean_status_record_source_identity(&parsed, &replacement.st); + + if (clean_status_identity_is_durable()) { + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), 0); + cl_assert(clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + + /* + * Model an A-to-B-to-A replacement while a second index state + * parses B. The named path and held descriptor are back on A, + * while the parsed state's source identity still binds it to B. + */ + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &parsed)); + clean_status_index_snapshot_release(&snapshot); + } else { + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + clean_status_release(&istate); + clean_status_release(&parsed); + fixture_release(&fixture); + fixture_release(&replacement); + return; + } + + moved = xstrfmt("%s.old", fixture.path); + cl_assert_equal_i(rename(fixture.path, moved), 0); + cl_assert_equal_i(rename(replacement.path, fixture.path), 0); + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + + clean_status_release(&istate); + clean_status_release(&parsed); + cl_assert_equal_i(close(fixture.fd), 0); + cl_assert_equal_i(close(replacement.fd), 0); + cl_assert_equal_i(unlink(fixture.path), 0); + cl_assert_equal_i(unlink(moved), 0); + free(moved); + free(fixture.path); + free(replacement.path); +} + +void test_clean_status_index__pins_null_checksum_source_identity(void) +{ + assert_pins_null_checksum_source(&hash_algos[GIT_HASH_SHA1]); + assert_pins_null_checksum_source(&hash_algos[GIT_HASH_SHA256]); +} + +void test_clean_status_index__pins_named_index_identity(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); +#ifndef GIT_WINDOWS_NATIVE + char *moved; + int replacement; +#endif + + fixture_init(&fixture, algo); + repo.hash_algo = algo; + repo.index_file = fixture.path; + istate.version = 4; + istate.cache_nr = 7; + oidcpy(&istate.oid, &fixture.checksum); + + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), 0); + cl_assert(clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + + istate.cache_nr++; + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + istate.cache_nr--; + +#ifndef GIT_WINDOWS_NATIVE + moved = xstrfmt("%s.old", fixture.path); + cl_assert_equal_i(rename(fixture.path, moved), 0); + replacement = open(fixture.path, O_WRONLY | O_CREAT | O_EXCL, 0600); + cl_assert(replacement >= 0); + cl_assert_equal_i(close(replacement), 0); + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + cl_assert_equal_i(unlink(fixture.path), 0); + cl_assert_equal_i(rename(moved, fixture.path), 0); + free(moved); +#endif + clean_status_index_snapshot_release(&snapshot); + +#ifndef GIT_WINDOWS_NATIVE + { + char *symlink_path = xstrfmt("%s.link", fixture.path); + + cl_assert_equal_i(symlink(fixture.path, symlink_path), 0); + repo.index_file = symlink_path; + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + repo.index_file = fixture.path; + cl_assert_equal_i(unlink(symlink_path), 0); + free(symlink_path); + } +#endif + + fixture_release(&fixture); +} + void test_clean_status_index__binds_the_parsed_source(void) { const char *tmp = getenv("TMPDIR"); @@ -41,3 +317,76 @@ void test_clean_status_index__binds_the_parsed_source(void) strbuf_release(&path); free(worktree); } + +void test_clean_status_index__digests_only_persistent_logical_entries(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct cache_entry *ce; + unsigned char baseline[GIT_MAX_RAWSZ]; + unsigned char changed[GIT_MAX_RAWSZ]; + static const char path[] = "tracked"; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + ce = make_empty_cache_entry(&istate, sizeof(path) - 1); + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = sizeof(path) - 1; + memcpy(ce->name, path, sizeof(path)); + memset(ce->oid.hash, 1, repo.hash_algo->rawsz); + oid_set_algo(&ce->oid, repo.hash_algo); + istate.cache[0] = ce; + + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, baseline), 0); + ce->ce_flags = CE_FSMONITOR_VALID | CE_UPTODATE | CE_HASHED; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(!memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_flags |= CE_VALID; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_flags = CE_SKIP_WORKTREE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_flags = CE_INTENT_TO_ADD; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_flags = create_ce_flags(1); + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_flags = 0; + ce->ce_mode = S_IFREG | 0755; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_mode = S_IFREG | 0644; + ce->oid.hash[0] = 2; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->oid.hash[0] = 1; + ce->name[0] = 'T'; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->name[0] = 't'; + ce->ce_flags = CE_WT_REMOVE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + ce->ce_flags = CE_CONTENT_CHECK_REQUIRED; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + ce->ce_flags = CE_UPDATE_IN_BASE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + + release_index(&istate); +} From 002a49e3225d194044bb52b9c60d96e2be150ded Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:57 -0500 Subject: [PATCH 186/403] fsmonitor: ignore empty hook path records A version-2 fsmonitor hook can return consecutive NUL delimiters after its token. The hook parser passed the resulting empty record to pathname invalidation, whose callback inspects the last byte of a nonempty path. Skip zero-length hook records and count only pathnames that actually reach fsmonitor_refresh_callback(). Preserve valid reported paths, the existing treatment of a final unterminated hook record, and the separately validated builtin response path. Add a t/t7519-status-fsmonitor.sh regression whose hook emits an empty record before a modified tracked path. Require status to report the real modification without processing the empty pathname. Signed-off-by: Taylor Blau --- fsmonitor.c | 6 ++++-- t/t7519-status-fsmonitor.sh | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index d2d369e6a1d2e5..4d4979770a27c6 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -998,9 +998,11 @@ void refresh_fsmonitor(struct index_state *istate) for (i = bol; i < query_result.len; i++) { if (buf[i] != '\0') continue; - fsmonitor_refresh_callback(istate, buf + bol); + if (i > bol) { + fsmonitor_refresh_callback(istate, buf + bol); + count++; + } bol = i + 1; - count++; } if (bol < query_result.len) { fsmonitor_refresh_callback(istate, buf + bol); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index f29bea912efd18..e8cc70c428b181 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -68,6 +68,26 @@ test_expect_success 'FSUC parser fails closed' ' test-tool read-cache --test-fsuc-parser ' +test_expect_success 'hook parser ignores empty path records' ' + test_when_finished "rm -rf empty-hook-record" && + test_create_repo empty-hook-record && + ( + cd empty-hook-record && + test_commit base tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + printf "\0" + printf "tracked\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + echo changed >tracked && + git status --porcelain --untracked-files=no >actual && + echo " M tracked" >expect && + test_cmp expect actual + ) +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From f9daeec32e06cebdffcfad96ad5adc817c4e9017 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:07:02 -0500 Subject: [PATCH 187/403] fsmonitor: rebuild sparse-index history without expanding the live index A collapsed sparse index cannot enumerate every tracked path needed for a complete attribute manifest. Expanding the live index would discard the sparse representation that status is supposed to preserve. Pin the named index with S09/P02, reread the verified index into a scratch index, and expand only that scratch copy. Build the complete manifest from the expanded scratch index. Check that both the parsed scratch state and original live state still match the held descriptor and stored trailer checksum; discard the manifest if either check fails. Add a sparse-checkout regression that checks the collapsed outside entry before and after status while detecting a same-size tracked rewrite. Extend the existing index unit case with a parsed A-to-B-to-A mismatch. Failed snapshot validation retains ordinary full-invalidation fallback. Signed-off-by: Taylor Blau --- clean-status-manifest.c | 31 ++++++++++++++++++++++++++--- t/unit-tests/u-clean-status-index.c | 14 +++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/clean-status-manifest.c b/clean-status-manifest.c index b13066a23c2c47..b2c8aaec97e71e 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "attr-manifest.h" +#include "clean-status-index.h" #include "clean-status-manifest.h" #include "dir.h" #include "fsmonitor-clean-proof.h" @@ -7,6 +8,7 @@ #include "hash-framing.h" #include "read-cache-ll.h" #include "repository.h" +#include "sparse-index.h" #include "trace2.h" #include "worktree-attr-manifest.h" @@ -20,10 +22,33 @@ static int build_manifest(struct index_state *istate, unsigned char *manifest_hash, struct worktree_attr_manifest_stats *stats) { - if (istate->sparse_index != INDEX_EXPANDED) + struct clean_status_index_snapshot snapshot; + struct index_state scratch = INDEX_STATE_INIT(istate->repo); + int ret = -1; + + if (istate->sparse_index == INDEX_EXPANDED) + return worktree_attr_manifest_build( + istate, manifest, manifest_hash, stats); + if (clean_status_index_snapshot_pin(&snapshot, istate)) return -1; - return worktree_attr_manifest_build( - istate, manifest, manifest_hash, stats); + scratch.fsmonitor_has_run_once = 1; + if (read_index_from(&scratch, istate->repo->index_file, + istate->repo->gitdir) < 0 || + !clean_status_index_snapshot_still_matches(&snapshot, &scratch)) + goto done; + ensure_full_index(&scratch); + ret = worktree_attr_manifest_build( + &scratch, manifest, manifest_hash, stats); + if (ret || + !clean_status_index_snapshot_still_matches(&snapshot, istate)) { + strbuf_reset(manifest); + ret = -1; + } + +done: + release_index(&scratch); + clean_status_index_snapshot_release(&snapshot); + return ret; } void clean_status_manifest_init(struct clean_status_manifest_state *state) diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index 35b4637a22be28..0a62f4dfeede5e 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -229,6 +229,8 @@ void test_clean_status_index__pins_named_index_identity(void) struct index_fixture fixture; struct repository repo = { 0 }; struct index_state istate = INDEX_STATE_INIT(&repo); + struct index_state parsed = INDEX_STATE_INIT(&repo); + unsigned char replacement_hash[GIT_MAX_RAWSZ]; #ifndef GIT_WINDOWS_NATIVE char *moved; int replacement; @@ -246,6 +248,18 @@ void test_clean_status_index__pins_named_index_identity(void) cl_assert(clean_status_index_snapshot_still_matches( &snapshot, &istate)); + /* + * Model an A-to-B-to-A replacement while a consumer parses B. + * Matching the restored named path is insufficient unless the parsed + * state is also bound to the pinned A contents. + */ + memset(replacement_hash, 2, algo->rawsz); + parsed.version = 4; + parsed.cache_nr = 7; + oidread(&parsed.oid, replacement_hash, algo); + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &parsed)); + istate.cache_nr++; cl_assert(!clean_status_index_snapshot_still_matches( &snapshot, &istate)); From 51f4df5013c66b4b352dfd8f334b121c5c7d6ebe Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:48:46 -0700 Subject: [PATCH 188/403] fsmonitor: centralize complete fsmonitor invalidation The fsmonitor failure path cleared tracked validity bits and disabled untracked-cache monitoring, but left the separate fsmonitor_untracked_valid proof intact. An untrusted cache token could therefore outlive the tracked state it was meant to certify. Extract invalidate_all_fsmonitor() and call it from the existing failure branch. Clear every CE_FSMONITOR_VALID bit, revoke the untracked-token proof, disable fsmonitor use for the untracked cache, and set FSMONITOR_CHANGED only when a tracked validity bit actually changed. The new helper has an immediate production consumer. It neither issues nor closes a provider token and introduces no independent benchmark. Signed-off-by: Taylor Blau --- fsmonitor.c | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index 4d4979770a27c6..dea229e7a8597a 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -832,6 +832,23 @@ static int apply_fsmonitor_paths(struct index_state *istate, return count; } +static void invalidate_all_fsmonitor(struct index_state *istate) +{ + unsigned int i; + int changed = 0; + + for (i = 0; i < istate->cache_nr; i++) { + if (istate->cache[i]->ce_flags & CE_FSMONITOR_VALID) + changed = 1; + istate->cache[i]->ce_flags &= ~CE_FSMONITOR_VALID; + } + istate->fsmonitor_untracked_valid = 0; + if (istate->untracked) + istate->untracked->use_fsmonitor = 0; + if (changed) + istate->cache_changed |= FSMONITOR_CHANGED; +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -1029,24 +1046,7 @@ void refresh_fsmonitor(struct index_state *istate) * we've actually changed entries, so keep track if we * actually changed entries or not. */ - int is_cache_changed = 0; - - for (i = 0; i < istate->cache_nr; i++) { - if (istate->cache[i]->ce_flags & CE_FSMONITOR_VALID) { - is_cache_changed = 1; - istate->cache[i]->ce_flags &= ~CE_FSMONITOR_VALID; - } - } - - /* - * If we're going to check every file, ensure we save - * the results. - */ - if (is_cache_changed) - istate->cache_changed |= FSMONITOR_CHANGED; - - if (istate->untracked) - istate->untracked->use_fsmonitor = 0; + invalidate_all_fsmonitor(istate); } trace2_region_leave("fsmonitor", "apply_results", istate->repo); From 24d99dbd8ffbbc501feb27c9cdb49cf4ee9c694c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:41:16 -0500 Subject: [PATCH 189/403] status: bind each closing token to its complete proof epoch A clean provider response closes only the filesystem interval after its starting token. It cannot certify a refresh that started before the named index, configuration, attributes, and manifest were captured, or one whose semantic inputs subsequently changed. Capture the proof epoch before each refresh whose provider token may be accepted. Pin the named index, starting token, repository configuration, external attribute fingerprint, and complete full-index manifest. Recheck those inputs after the closing query. Record semantic history only for the accepted token; reject missing or changed inputs and fall back to a complete refresh. For a null-checksum index, let only the proof-epoch pin use the process-local reader descriptor retained by the preceding patch. The proof-only exception rechecks both the retained source descriptor's original stat identity and the current named path when pinning and closing the epoch. Generic certification and persisted CSHS continue to reject the null trailer. Always rebuild the manifest when physical history is unavailable, even if the stored semantic configuration already matches. Without that manifest, a trivial response invalidates the old binding and leaves the closing query with no complete epoch to bind, so each later status repeats the fallback. Teach this lifecycle to restore and save complete external history checkpoints through the preceding CSHS store. A restore validates the logical index and all FSMN, UNTR/FSUC, and FSCF sections in scratch state, then rechecks the pinned index before installing them together. A save requires the same logical entries before and after status and a closed, writable proof. Keep both paths dormant until a later patch enables them only for a normal top-level status. A retry inside a captured epoch can also lose a freshly acquired CE_FSMONITOR_VALID bit when replace_index_entry() applies its generic conservative invalidation. Mark proof-epoch refreshes explicitly and restore only a validity bit acquired by the replacement itself. Changed or rejected closures still invalidate those provisional bits before falling back. Register clean-status-epoch.c in Make and Meson alongside its first production consumer in wt-status.c. Add scripted regressions for capture-before-refresh ordering and recovery from unbound physical history. Add unit coverage for the complete full-index manifest, the restricted post-status logical-digest exception, retained-descriptor lifetime, a stat-visible same-inode size change, and atomic path replacement. Later activation patches cover external checkpoint recovery and the immediate warm run. Signed-off-by: Taylor Blau --- Makefile | 1 + clean-status-config.c | 26 ++ clean-status-config.h | 4 + clean-status-epoch.c | 193 ++++++++++++++ clean-status-history.c | 339 ++++++++++++++++++++++++- clean-status-index.c | 113 +++++++-- clean-status-index.h | 8 + clean-status-internal.h | 3 + clean-status.c | 58 +++++ clean-status.h | 26 ++ fsmonitor-ll.h | 2 + fsmonitor.c | 43 +++- meson.build | 1 + read-cache-ll.h | 1 + read-cache.c | 12 +- t/t7519-status-fsmonitor.sh | 23 ++ t/unit-tests/u-clean-status-index.c | 193 ++++++++++++++ t/unit-tests/u-clean-status-manifest.c | 47 ++++ wt-status.c | 84 +++++- 19 files changed, 1131 insertions(+), 46 deletions(-) create mode 100644 clean-status-epoch.c diff --git a/Makefile b/Makefile index 5da7272e3e5e67..85cbac77e5faea 100644 --- a/Makefile +++ b/Makefile @@ -1127,6 +1127,7 @@ LIB_OBJS += checkout.o LIB_OBJS += chunk-format.o LIB_OBJS += clean-status.o LIB_OBJS += clean-status-config.o +LIB_OBJS += clean-status-epoch.o LIB_OBJS += clean-status-history-store.o LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o diff --git a/clean-status-config.c b/clean-status-config.c index 951893ac833117..0cbab0fe50acd4 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -2,6 +2,7 @@ #include "clean-status-config.h" #include "config.h" #include "hash-framing.h" +#include "repository.h" #include "strbuf.h" #define CLEAN_STATUS_FILTER_PROOF_DOMAIN \ @@ -92,3 +93,28 @@ void clean_status_config_final(struct clean_status_config_digest *digest) git_hash_final(digest->semantic_hash, &digest->semantic_ctx); digest->finalized = 1; } + +static int config_digest_callback(const char *key, const char *value, + const struct config_context *ctx, + void *data) +{ + clean_status_config_add(data, key, value, ctx); + return 0; +} + +int clean_status_config_read_repository( + struct repository *repo, + struct clean_status_config_digest *digest) +{ + struct config_options opts = { 0 }; + + clean_status_config_init(digest, repo->hash_algo); + opts.respect_includes = 1; + opts.commondir = repo->commondir; + opts.git_dir = repo->gitdir; + if (config_with_options(config_digest_callback, digest, NULL, + repo, &opts) < 0) + return -1; + clean_status_config_final(digest); + return 0; +} diff --git a/clean-status-config.h b/clean-status-config.h index 47420ed282d4d9..0a4275ea92242f 100644 --- a/clean-status-config.h +++ b/clean-status-config.h @@ -4,6 +4,7 @@ #include "hash.h" struct config_context; +struct repository; struct clean_status_config_digest { struct git_hash_ctx ctx; @@ -22,5 +23,8 @@ void clean_status_config_add(struct clean_status_config_digest *digest, const char *key, const char *value, const struct config_context *ctx); void clean_status_config_final(struct clean_status_config_digest *digest); +int clean_status_config_read_repository( + struct repository *repo, + struct clean_status_config_digest *digest); #endif /* CLEAN_STATUS_CONFIG_H */ diff --git a/clean-status-epoch.c b/clean-status-epoch.c new file mode 100644 index 00000000000000..b5760649cb3c10 --- /dev/null +++ b/clean-status-epoch.c @@ -0,0 +1,193 @@ +#include "git-compat-util.h" +#include "attr-fingerprint.h" +#include "clean-status.h" +#include "clean-status-index.h" +#include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" + +/* + * State captured before a worktree scan. Every recorded input must still + * match after the closing provider query before scan results are accepted. + */ +struct clean_status_proof_epoch { + struct index_state *istate; + struct clean_status_index_snapshot index; + char *scan_start_token; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char attr_hash[GIT_MAX_RAWSZ]; + unsigned char attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned char manifest_hash[GIT_MAX_RAWSZ]; + uint32_t manifest_flags; + unsigned semantic_explicit : 1; + unsigned attr_sources_present : 1; + unsigned filter_configured : 1; + unsigned filter_scope_valid : 1; + unsigned strong_mismatch : 1; + unsigned config_mismatch : 1; +}; + +static int config_matches_epoch( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + struct clean_status_state *state = istate->clean_status; + struct clean_status_config_digest digest; + const struct git_hash_algo *algo = istate->repo->hash_algo; + + if (clean_status_config_read_repository(istate->repo, &digest)) + return 0; + return digest.finalized && + digest.filter_configured == epoch->filter_configured && + digest.semantic_config_explicit == epoch->semantic_explicit && + !memcmp(digest.hash, epoch->config_hash, algo->rawsz) && + !memcmp(digest.semantic_hash, epoch->semantic_hash, algo->rawsz) && + state && state->current_config_valid && + state->current_semantic_valid && + !memcmp(state->current_config_hash, epoch->config_hash, + algo->rawsz) && + !memcmp(state->current_semantic_hash, epoch->semantic_hash, + algo->rawsz); +} + +struct clean_status_proof_epoch *clean_status_capture_proof_epoch( + struct index_state *istate, + const struct attr_source_snapshot *attrs, + int validate_filter_scope) +{ + struct clean_status_state *state = istate->clean_status; + struct clean_status_proof_epoch *epoch; + struct clean_status_config_digest digest; + struct clean_status_index_snapshot index; + const struct attr_fingerprint *fingerprint = + attr_source_snapshot_fingerprint(attrs); + uint32_t manifest_requirements = + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + if (istate->split_index || !state || !state->current_config_valid || + !state->config_enforced || + !state->current_semantic_valid || !state->current_attr_valid || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.global_fallback || + (clean_status_filter_scope_needs_validation(istate) && + !validate_filter_scope) || + (state->manifest.current_flags & manifest_requirements) != + manifest_requirements || + !fsmonitor_pending_token_from_provider(istate) || + !istate->fsmonitor_last_update_pending || !fingerprint || + memcmp(fingerprint->content_hash, state->current_attr_hash, + istate->repo->hash_algo->rawsz) || + memcmp(fingerprint->namespace_hash, + state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz) || + fingerprint->sources_present != + state->current_attr_sources_present) + return NULL; + if (clean_status_config_read_repository(istate->repo, &digest) || + !digest.finalized || + digest.filter_configured != state->filter_configured || + digest.semantic_config_explicit != + state->current_semantic_explicit || + memcmp(digest.hash, state->current_config_hash, + istate->repo->hash_algo->rawsz) || + memcmp(digest.semantic_hash, state->current_semantic_hash, + istate->repo->hash_algo->rawsz) || + clean_status_index_snapshot_pin_proof_epoch(&index, istate)) + return NULL; + + CALLOC_ARRAY(epoch, 1); + epoch->istate = istate; + epoch->index = index; + epoch->scan_start_token = xstrdup(istate->fsmonitor_last_update_pending); + memcpy(epoch->config_hash, state->current_config_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->semantic_hash, state->current_semantic_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->attr_hash, state->current_attr_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->attr_namespace_hash, + state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz); + memcpy(epoch->manifest_hash, state->manifest.current_hash, + istate->repo->hash_algo->rawsz); + epoch->manifest_flags = state->manifest.current_flags; + epoch->semantic_explicit = state->current_semantic_explicit; + epoch->attr_sources_present = state->current_attr_sources_present; + epoch->filter_configured = state->filter_configured; + epoch->filter_scope_valid = state->filter_scope_valid; + epoch->strong_mismatch = state->strong_mismatch; + epoch->config_mismatch = state->config_mismatch; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/proof-epoch-captured", 1); + return epoch; +} + +int clean_status_proof_epoch_start_token_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + return epoch && epoch->istate == istate && epoch->scan_start_token && + fsmonitor_pending_token_from_provider(istate) && + istate->fsmonitor_last_update_pending && + !strcmp(epoch->scan_start_token, + istate->fsmonitor_last_update_pending); +} + +int clean_status_proof_epoch_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + struct clean_status_state *state; + struct attr_fingerprint attrs; + const struct git_hash_algo *algo = istate->repo->hash_algo; + int matched = 0; + + if (!epoch || epoch->istate != istate || + !fsmonitor_pending_token_from_provider(istate) || + !istate->fsmonitor_last_update_pending) + goto done; + state = istate->clean_status; + if (!state || !state->current_config_valid || !state->config_enforced || + !state->current_semantic_valid || !state->current_attr_valid || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.global_fallback || + state->manifest.current_flags != epoch->manifest_flags || + state->current_semantic_explicit != epoch->semantic_explicit || + state->current_attr_sources_present != epoch->attr_sources_present || + state->filter_configured != epoch->filter_configured || + state->filter_scope_valid != epoch->filter_scope_valid || + state->strong_mismatch != epoch->strong_mismatch || + state->config_mismatch != epoch->config_mismatch) + goto done; + if (attr_fingerprint_repository(istate->repo, &attrs) || + memcmp(attrs.content_hash, epoch->attr_hash, algo->rawsz) || + memcmp(attrs.namespace_hash, epoch->attr_namespace_hash, + algo->rawsz) || + attrs.sources_present != epoch->attr_sources_present || + memcmp(state->manifest.current_hash, epoch->manifest_hash, + algo->rawsz) || + !config_matches_epoch(istate, epoch) || + !clean_status_index_snapshot_still_matches_proof_epoch( + &epoch->index, istate)) + goto done; + matched = 1; +done: + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/proof-epoch-matched", matched); + return matched; +} + +void clean_status_release_proof_epoch( + struct clean_status_proof_epoch *epoch) +{ + if (!epoch) + return; + clean_status_index_snapshot_release(&epoch->index); + free(epoch->scan_start_token); + free(epoch); +} diff --git a/clean-status-history.c b/clean-status-history.c index fc917bb1bcbc3f..28f32092325d06 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1,11 +1,22 @@ #include "git-compat-util.h" +#include "abspath.h" #include "clean-status.h" +#include "clean-status-history-store.h" +#include "clean-status-index.h" #include "clean-status-internal.h" +#include "dir.h" +#include "environment.h" #include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" +#include "hash-framing.h" +#include "hex.h" #include "read-cache-ll.h" #include "repository.h" #include "strbuf.h" #include "trace2.h" +#include "ewah/ewok.h" + +#define CLEAN_STATUS_HISTORY_SCHEMA "builtin-fsmonitor-history-v2" static void invalidate_disk_history(struct clean_status_state *state) { @@ -56,7 +67,7 @@ int clean_status_read_fsmonitor_config(struct index_state *istate, return 0; } -void clean_status_prepare_fsmonitor_config(struct index_state *istate) +static int prepare_fsmonitor_config(struct index_state *istate, int trace) { struct clean_status_state *state = istate->clean_status; const struct git_hash_algo *algo = istate->repo->hash_algo; @@ -64,7 +75,7 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) int coherent; if (!state || !state->current_config_valid) - return; + return 0; token_coherent = state->disk_config_valid && !state->disk_config_invalid && istate->fsmonitor_token_valid && istate->fsmonitor_last_update && state->disk_config_token && @@ -106,10 +117,24 @@ void clean_status_prepare_fsmonitor_config(struct index_state *istate) (!state->disk_attr_valid && state->current_attr_sources_present) || clean_status_filter_scope_needs_validation(istate)); - trace2_data_intmax("fsmonitor", istate->repo, - "config/coherent", coherent); - trace2_data_intmax("fsmonitor", istate->repo, - "semantic/initial-mismatch", state->strong_mismatch); + if (trace) { + trace2_data_intmax("fsmonitor", istate->repo, + "config/coherent", coherent); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/initial-mismatch", + state->strong_mismatch); + } + return coherent; +} + +void clean_status_prepare_fsmonitor_config(struct index_state *istate) +{ + prepare_fsmonitor_config(istate, 1); +} + +int clean_status_probe_fsmonitor_config(struct index_state *istate) +{ + return prepare_fsmonitor_config(istate, 0); } int clean_status_has_persistent_fsmonitor_semantic_history( @@ -264,6 +289,308 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, BUG("cannot preserve validated fsmonitor clean proof"); } +struct clean_status_external_checkpoint { + char proof_namespace[GIT_MAX_HEXSZ + 1]; + struct clean_status_history_checkpoint checkpoint; + struct strbuf fsmonitor; + struct strbuf untracked_cache; + struct strbuf fsmonitor_config; + struct strbuf fsmonitor_untracked; +}; + +static void clean_status_release_external_history( + struct clean_status_external_checkpoint *checkpoint); + +static int external_history_namespace(struct index_state *istate, char *out) +{ + static const char domain[] = "git-clean-status-history-key-v2"; + struct clean_status_state *state = istate->clean_status; + struct git_hash_ctx ctx; + unsigned char hash[GIT_MAX_RAWSZ]; + char *worktree = NULL, *gitdir = NULL, *commondir = NULL; + int ret = -1; + + if (!state || !state->current_config_valid || + !state->current_semantic_valid || !state->current_attr_valid || + !repo_get_work_tree(istate->repo)) + return -1; + worktree = real_pathdup(repo_get_work_tree(istate->repo), 0); + gitdir = real_pathdup(repo_get_git_dir(istate->repo), 0); + commondir = real_pathdup(repo_get_common_dir(istate->repo), 0); + if (!worktree || !gitdir || !commondir) + goto done; + git_hash_init(&ctx, istate->repo->hash_algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, CLEAN_STATUS_HISTORY_SCHEMA, + strlen(CLEAN_STATUS_HISTORY_SCHEMA)); + hash_length_delimited(&ctx, state->current_config_hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, state->current_semantic_hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, state->current_attr_namespace_hash, + istate->repo->hash_algo->rawsz); + hash_length_delimited(&ctx, worktree, strlen(worktree)); + hash_length_delimited(&ctx, gitdir, strlen(gitdir)); + hash_length_delimited(&ctx, commondir, strlen(commondir)); + git_hash_final(hash, &ctx); + hash_to_hex_algop_r(out, hash, istate->repo->hash_algo); + ret = 0; + +done: + free(worktree); + free(gitdir); + free(commondir); + return ret; +} + +static struct clean_status_external_checkpoint * +clean_status_prepare_external_history(struct index_state *istate) +{ + struct clean_status_external_checkpoint *checkpoint; + struct clean_status_state *state = istate->clean_status; + const unsigned int acceleration_changes = + CE_ENTRY_CHANGED | FSMONITOR_CHANGED | UNTRACKED_CHANGED; + + if (!clean_status_external_history_enabled(istate) || + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || + !state || !state->source_logical_hash_valid || + !current_proof_is_writable(istate) || + (istate->cache_changed & ~acceleration_changes) || + has_racy_timestamp(istate)) + return NULL; + CALLOC_ARRAY(checkpoint, 1); + checkpoint->fsmonitor = (struct strbuf)STRBUF_INIT; + checkpoint->untracked_cache = (struct strbuf)STRBUF_INIT; + checkpoint->fsmonitor_config = (struct strbuf)STRBUF_INIT; + checkpoint->fsmonitor_untracked = (struct strbuf)STRBUF_INIT; + if (external_history_namespace( + istate, checkpoint->proof_namespace)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "namespace"); + goto fail; + } + if (clean_status_index_logical_digest_after_status( + istate, checkpoint->checkpoint.index_hash)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "logical-flags"); + goto fail; + } + if (memcmp(checkpoint->checkpoint.index_hash, + state->source_logical_hash, + istate->repo->hash_algo->rawsz)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "logical-change"); + goto fail; + } + snapshot_fsmonitor_extension(&checkpoint->fsmonitor, istate); + clean_status_write_fsmonitor_config( + &checkpoint->fsmonitor_config, istate); + if (istate->untracked) { + if (!istate->fsmonitor_untracked_valid || + !istate->fsmonitor_untracked_token || + strcmp(istate->fsmonitor_untracked_token, + istate->fsmonitor_last_update)) { + trace2_data_string( + "fsmonitor", istate->repo, + "history/external-save-reject", "untracked-token"); + goto fail; + } + write_untracked_extension( + &checkpoint->untracked_cache, istate->untracked); + write_fsmonitor_untracked_extension( + &checkpoint->fsmonitor_untracked, istate); + } + checkpoint->checkpoint.fsmonitor = + (const unsigned char *)checkpoint->fsmonitor.buf; + checkpoint->checkpoint.fsmonitor_len = checkpoint->fsmonitor.len; + checkpoint->checkpoint.untracked_cache = + (const unsigned char *)checkpoint->untracked_cache.buf; + checkpoint->checkpoint.untracked_cache_len = + checkpoint->untracked_cache.len; + checkpoint->checkpoint.fsmonitor_config = + (const unsigned char *)checkpoint->fsmonitor_config.buf; + checkpoint->checkpoint.fsmonitor_config_len = + checkpoint->fsmonitor_config.len; + checkpoint->checkpoint.fsmonitor_untracked = + (const unsigned char *)checkpoint->fsmonitor_untracked.buf; + checkpoint->checkpoint.fsmonitor_untracked_len = + checkpoint->fsmonitor_untracked.len; + return checkpoint; + +fail: + clean_status_release_external_history(checkpoint); + return NULL; +} + +static int clean_status_install_external_history( + struct index_state *istate, + struct clean_status_external_checkpoint *checkpoint) +{ + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + int installed = 0; + + if (!checkpoint || clean_status_index_snapshot_pin(&snapshot, istate) || + clean_status_history_store_install( + istate->repo->index_file, checkpoint->proof_namespace, + &checkpoint->checkpoint, &snapshot, + istate->repo->hash_algo)) + goto done; + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-stored", 1); + installed = 1; + +done: + clean_status_index_snapshot_release(&snapshot); + return installed; +} + +static void clean_status_release_external_history( + struct clean_status_external_checkpoint *checkpoint) +{ + if (!checkpoint) + return; + strbuf_release(&checkpoint->fsmonitor); + strbuf_release(&checkpoint->untracked_cache); + strbuf_release(&checkpoint->fsmonitor_config); + strbuf_release(&checkpoint->fsmonitor_untracked); + free(checkpoint); +} + +int clean_status_save_external_history(struct index_state *istate) +{ + struct clean_status_external_checkpoint *checkpoint = + clean_status_prepare_external_history(istate); + int saved = clean_status_install_external_history( + istate, checkpoint); + + clean_status_release_external_history(checkpoint); + return saved; +} + +static int on_index_history_is_coherent(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || !state->disk_config_seen) + return 0; + clean_status_probe_fsmonitor_config(istate); + prepare_fsmonitor_untracked(istate); + return state->initial_coherent && + (!istate->untracked || istate->fsmonitor_untracked_valid); +} + +int clean_status_restore_external_history(struct index_state *istate) +{ + struct clean_status_history_store_record record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + struct clean_status_state *state = istate->clean_status; + struct index_state parsed = INDEX_STATE_INIT(istate->repo); + unsigned char index_hash[GIT_MAX_RAWSZ]; + char proof_namespace[GIT_MAX_HEXSZ + 1]; + int restored = 0; + + if (!clean_status_external_history_enabled(istate) || !state || + !state->config_enforced || + !state->current_config_valid || !state->current_semantic_valid || + !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || + istate != istate->repo->index || + on_index_history_is_coherent(istate) || + clean_status_index_snapshot_pin(&snapshot, istate) || + clean_status_index_logical_digest(istate, index_hash)) + goto done; + memcpy(state->source_logical_hash, index_hash, + istate->repo->hash_algo->rawsz); + state->source_logical_hash_valid = 1; + if (external_history_namespace(istate, proof_namespace) || + clean_status_history_store_load( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo, &record) || + memcmp(index_hash, record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz)) + goto done; + parsed.cache_nr = istate->cache_nr; + if (read_fsmonitor_extension( + &parsed, record.checkpoint.fsmonitor, + record.checkpoint.fsmonitor_len) || + !parsed.fsmonitor_token_valid || !parsed.fsmonitor_last_update || + !parsed.fsmonitor_dirty) + goto done; + if (record.checkpoint.untracked_cache_len) { + parsed.untracked = read_untracked_extension( + record.checkpoint.untracked_cache, + record.checkpoint.untracked_cache_len); + read_fsmonitor_untracked_extension( + &parsed, record.checkpoint.fsmonitor_untracked, + record.checkpoint.fsmonitor_untracked_len); + if (!parsed.untracked || + parsed.fsmonitor_untracked_extension_invalid || + !parsed.fsmonitor_untracked_token || + strcmp(parsed.fsmonitor_untracked_token, + parsed.fsmonitor_last_update)) + goto done; + } + clean_status_attach_config(&parsed); + clean_status_read_fsmonitor_config( + &parsed, record.checkpoint.fsmonitor_config, + record.checkpoint.fsmonitor_config_len); + prepare_fsmonitor_untracked(&parsed); + clean_status_probe_fsmonitor_config(&parsed); + if (!current_proof_is_writable(&parsed) || + (!!parsed.untracked && !parsed.fsmonitor_untracked_valid)) + goto done; + if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + goto done; + clean_status_invalidate_current_proof(istate); + clean_status_copy_fsmonitor_history(istate, &parsed); + FREE_AND_NULL(istate->fsmonitor_last_update); + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + if (istate->fsmonitor_dirty) + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_last_update = parsed.fsmonitor_last_update; + parsed.fsmonitor_last_update = NULL; + istate->fsmonitor_dirty = parsed.fsmonitor_dirty; + parsed.fsmonitor_dirty = NULL; + istate->fsmonitor_token_valid = 1; + istate->fsmonitor_extension_seen = 1; + free_untracked_cache(istate->untracked); + istate->untracked = parsed.untracked; + parsed.untracked = NULL; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + parsed.fsmonitor_untracked_token; + parsed.fsmonitor_untracked_token = NULL; + istate->fsmonitor_untracked_extension_seen = + parsed.fsmonitor_untracked_extension_seen; + istate->fsmonitor_untracked_extension_invalid = 0; + istate->fsmonitor_untracked_valid = + parsed.fsmonitor_untracked_valid; + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-restored", 1); + state->external_history_restored = 1; + restored = 1; + +done: + if (parsed.fsmonitor_dirty) + ewah_free(parsed.fsmonitor_dirty); + parsed.fsmonitor_dirty = NULL; + parsed.cache_nr = 0; + release_index(&parsed); + clean_status_index_snapshot_release(&snapshot); + clean_status_history_store_record_release(&record); + return restored; +} + +int clean_status_external_history_was_restored( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->external_history_restored; +} + + void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src) { diff --git a/clean-status-index.c b/clean-status-index.c index 51399c0f24f720..0042fb7086ff19 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -101,45 +101,107 @@ int clean_status_index_snapshot_still_matches_path( snapshot->cache_nr, &snapshot->checksum, algo); } +static int source_index_matches_snapshot( + const struct clean_status_index_snapshot *snapshot, + const struct clean_status_state *state, + const struct git_hash_algo *algo) +{ + struct clean_status_identity identity; + struct stat st; + + return state && state->source_index_fd >= 0 && + state->source_index_identity_valid && + !fstat(state->source_index_fd, &st) && + !clean_status_identity_from_stat(&identity, &st) && + clean_status_identity_equal( + &identity, &state->source_index_identity) && + clean_status_identity_equal( + &snapshot->identity, &state->source_index_identity) && + snapshot_matches(state->source_index_fd, &st, + snapshot->version, snapshot->cache_nr, + &snapshot->checksum, algo); +} + static int snapshot_matches_index_state( const struct clean_status_index_snapshot *snapshot, - const struct index_state *istate) + const struct index_state *istate, int allow_process_local_source) { const struct clean_status_state *state = istate->clean_status; - return istate->version == snapshot->version && - istate->cache_nr == snapshot->cache_nr && - oideq(&istate->oid, &snapshot->checksum) && - (!is_null_oid(&snapshot->checksum) || - (clean_status_identity_is_durable() && state && - state->source_identity_valid && - clean_status_identity_equal(&snapshot->identity, - &state->source_identity))); + if (istate->version != snapshot->version || + istate->cache_nr != snapshot->cache_nr || + !oideq(&istate->oid, &snapshot->checksum)) + return 0; + if (!is_null_oid(&snapshot->checksum)) + return 1; + if (clean_status_identity_is_durable() && state && + state->source_identity_valid && + clean_status_identity_equal(&snapshot->identity, + &state->source_identity)) + return 1; + return allow_process_local_source && + source_index_matches_snapshot( + snapshot, state, istate->repo->hash_algo); } -int clean_status_index_snapshot_pin( +static int snapshot_pin( struct clean_status_index_snapshot *snapshot, - struct index_state *istate) + struct index_state *istate, int allow_process_local_source) { if (snapshot_open(snapshot, istate->repo->index_file, istate->repo->hash_algo, 1)) return -1; - if (snapshot_matches_index_state(snapshot, istate)) + if (snapshot_matches_index_state( + snapshot, istate, allow_process_local_source)) return 0; clean_status_index_snapshot_release(snapshot); return -1; } -int clean_status_index_snapshot_still_matches( +int clean_status_index_snapshot_pin( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate) +{ + return snapshot_pin(snapshot, istate, 0); +} + +int clean_status_index_snapshot_pin_proof_epoch( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate) +{ + /* + * A proof epoch is process-local and dies with its index state. It may + * therefore use the descriptor for the file which populated that state. + * Persisted history and sidecars continue to use the generic pin above. + */ + return snapshot_pin(snapshot, istate, 1); +} + +static int snapshot_still_matches( const struct clean_status_index_snapshot *snapshot, - const struct index_state *istate) + const struct index_state *istate, int allow_process_local_source) { - return snapshot_matches_index_state(snapshot, istate) && + return snapshot_matches_index_state( + snapshot, istate, allow_process_local_source) && clean_status_index_snapshot_still_matches_path( snapshot, istate->repo->index_file, istate->repo->hash_algo); } +int clean_status_index_snapshot_still_matches( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + return snapshot_still_matches(snapshot, istate, 0); +} + +int clean_status_index_snapshot_still_matches_proof_epoch( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate) +{ + return snapshot_still_matches(snapshot, istate, 1); +} + void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot) { @@ -211,6 +273,27 @@ int clean_status_index_logical_digest(const struct index_state *istate, return index_logical_digest(istate, 0, out); } +int clean_status_index_logical_digest_after_status( + const struct index_state *istate, unsigned char *out) +{ + const unsigned int acceleration_changes = + CE_ENTRY_CHANGED | FSMONITOR_CHANGED | UNTRACKED_CHANGED; + + /* + * CE_UPDATE_IN_BASE has no independent meaning for a full index; status + * uses it as stat-refresh bookkeeping. Keep that exception confined + * to the main, expanded, acceleration-only status result. The common + * digest still rejects split/sparse indexes and every other transient + * flag, and hashes every persistent logical field. + */ + if (!istate->repo || + !clean_status_external_history_enabled(istate) || + istate != istate->repo->index || + (istate->cache_changed & ~acceleration_changes)) + return -1; + return index_logical_digest(istate, CE_UPDATE_IN_BASE, out); +} + void clean_status_record_source_identity(struct index_state *istate, const struct stat *st) { diff --git a/clean-status-index.h b/clean-status-index.h index b8c7dbb78487f0..61b288d3de2e4f 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -23,12 +23,20 @@ int clean_status_index_snapshot_still_matches_path( int clean_status_index_snapshot_pin( struct clean_status_index_snapshot *snapshot, struct index_state *istate); +int clean_status_index_snapshot_pin_proof_epoch( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate); int clean_status_index_snapshot_still_matches( const struct clean_status_index_snapshot *snapshot, const struct index_state *istate); +int clean_status_index_snapshot_still_matches_proof_epoch( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate); void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot); int clean_status_index_logical_digest(const struct index_state *istate, unsigned char *out); +int clean_status_index_logical_digest_after_status( + const struct index_state *istate, unsigned char *out); #endif /* CLEAN_STATUS_INDEX_H */ diff --git a/clean-status-internal.h b/clean-status-internal.h index 65dd4da0ae4019..62f73acfcd00cf 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -21,6 +21,7 @@ struct clean_status_state { unsigned char current_attr_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; unsigned char disk_attr_hash[GIT_MAX_RAWSZ]; + unsigned char source_logical_hash[GIT_MAX_RAWSZ]; unsigned current_config_valid : 1; unsigned current_semantic_valid : 1; unsigned current_attr_valid : 1; @@ -35,6 +36,8 @@ struct clean_status_state { unsigned initial_coherent : 1; unsigned source_identity_valid : 1; unsigned source_index_identity_valid : 1; + unsigned source_logical_hash_valid : 1; + unsigned external_history_restored : 1; unsigned disk_config_valid : 1; unsigned disk_semantic_valid : 1; unsigned disk_attr_valid : 1; diff --git a/clean-status.c b/clean-status.c index ea1d02e00ae09f..5d8858ce3fdc3f 100644 --- a/clean-status.c +++ b/clean-status.c @@ -2,6 +2,7 @@ #include "attr-fingerprint.h" #include "clean-status.h" #include "clean-status-internal.h" +#include "fsmonitor-clean-proof.h" #include "read-cache-ll.h" #include "repository.h" #include "trace2.h" @@ -9,10 +10,21 @@ static struct repository *configured_repo; static unsigned char configured_hash[GIT_MAX_RAWSZ]; static unsigned char configured_semantic_hash[GIT_MAX_RAWSZ]; +static struct repository *external_history_repo; static int configured_hash_valid; static int configured_filter_configured; static int configured_semantic_explicit; +void clean_status_enable_external_history(struct repository *repo) +{ + external_history_repo = repo; +} + +int clean_status_external_history_enabled(const struct index_state *istate) +{ + return istate && istate->repo == external_history_repo; +} + struct clean_status_state *clean_status_get_state(struct index_state *istate) { if (!istate->clean_status) { @@ -79,6 +91,18 @@ int clean_status_filter_scope_needs_validation( state->filter_configured && !state->filter_scope_valid; } +void clean_status_mark_filter_scope_valid(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || !state->current_config_valid || !state->config_enforced || + !state->filter_configured) + return; + state->filter_scope_valid = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "filter-scope/valid", 1); +} + int clean_status_revalidated_token_matches(const struct index_state *istate) { const struct clean_status_state *state = istate->clean_status; @@ -181,6 +205,40 @@ int clean_status_manifest_global_fallback(const struct index_state *istate) istate->clean_status->manifest.global_fallback; } +void clean_status_mark_fsmonitor_config_valid(struct index_state *istate, + const char *closed_token) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state || !state->current_config_valid) + return; + if (!closed_token || clean_status_filter_scope_needs_validation(istate) || + !state->manifest.current_valid || !state->manifest.checked || + (state->manifest.current_flags & + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) != + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) { + clean_status_invalidate_current_proof(istate); + FREE_AND_NULL(state->config_revalidated_token); + trace2_data_intmax("fsmonitor", istate->repo, + "config/manifest-unbound", 1); + return; + } + state->config_mismatch = 0; + state->strong_mismatch = 0; + state->semantic_baseline_pending = 0; + state->manifest.current_flags = FSMONITOR_CLEAN_PROOF_ALL; + state->config_revalidated = state->current_semantic_valid && + state->current_attr_valid && state->manifest.current_valid; + state->initial_coherent = state->config_revalidated; + FREE_AND_NULL(state->config_revalidated_token); + if (state->config_revalidated) + state->config_revalidated_token = xstrdup(closed_token); + trace2_data_intmax("fsmonitor", istate->repo, + "config/revalidated", 1); +} + void clean_status_release(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index f6c001a6e834f4..bd518740eaaeb9 100644 --- a/clean-status.h +++ b/clean-status.h @@ -5,6 +5,7 @@ struct index_state; struct attr_source_snapshot; +struct clean_status_proof_epoch; struct repository; struct stat; struct strbuf; @@ -17,12 +18,28 @@ enum clean_status_attr_change { void clean_status_set_config_digest( struct repository *repo, const struct clean_status_config_digest *digest); +void clean_status_enable_external_history(struct repository *repo); +int clean_status_external_history_enabled(const struct index_state *istate); void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); +void clean_status_mark_filter_scope_valid(struct index_state *istate); + int clean_status_capture_attr_snapshot( struct index_state *istate, struct attr_source_snapshot **snapshot); +struct clean_status_proof_epoch *clean_status_capture_proof_epoch( + struct index_state *istate, + const struct attr_source_snapshot *attrs, + int validate_filter_scope); +int clean_status_proof_epoch_start_token_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); +int clean_status_proof_epoch_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); +void clean_status_release_proof_epoch( + struct clean_status_proof_epoch *epoch); int clean_status_fsmonitor_config_mismatch(const struct index_state *istate); int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); @@ -42,6 +59,10 @@ void clean_status_begin_fsmonitor_semantic_baseline( int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); + +void clean_status_mark_fsmonitor_config_valid( + struct index_state *istate, const char *closed_token); + void clean_status_record_source_identity(struct index_state *istate, const struct stat *st); /* Takes ownership of fd only when it returns 1. */ @@ -53,6 +74,7 @@ int clean_status_verify_null_index(const struct index_state *istate, int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); void clean_status_prepare_fsmonitor_config(struct index_state *istate); +int clean_status_probe_fsmonitor_config(struct index_state *istate); void clean_status_invalidate_current_proof(struct index_state *istate); void clean_status_advance_fsmonitor_config_token( struct index_state *istate, const char *next_token); @@ -60,6 +82,10 @@ int clean_status_should_write_fsmonitor_config( const struct index_state *istate); void clean_status_write_fsmonitor_config(struct strbuf *out, const struct index_state *istate); +int clean_status_restore_external_history(struct index_state *istate); +int clean_status_external_history_was_restored( + const struct index_state *istate); +int clean_status_save_external_history(struct index_state *istate); void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src); int clean_status_transfer_current_proof_if_same_index( diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 7e7564e5e2c493..d7522222fc09cb 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -34,6 +34,8 @@ void prepare_fsmonitor_untracked(struct index_state *istate); * before it is split during writing. */ void fill_fsmonitor_bitmap(struct index_state *istate); +void snapshot_fsmonitor_extension(struct strbuf *sb, + struct index_state *istate); /* * Write the CE_FSMONITOR_VALID state into the fsmonitor index diff --git a/fsmonitor.c b/fsmonitor.c index c90bfebc2e4712..2e046b15a4a66c 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -263,19 +263,29 @@ void prepare_fsmonitor_untracked(struct index_state *istate) istate->fsmonitor_untracked_token))); } -void fill_fsmonitor_bitmap(struct index_state *istate) +static struct ewah_bitmap *fsmonitor_bitmap_from_index( + struct index_state *istate) { + struct ewah_bitmap *bitmap = ewah_new(); unsigned int i, skipped = 0; - istate->fsmonitor_dirty = ewah_new(); + for (i = 0; i < istate->cache_nr; i++) { if (istate->cache[i]->ce_flags & CE_REMOVE) skipped++; else if (!(istate->cache[i]->ce_flags & CE_FSMONITOR_VALID)) - ewah_set(istate->fsmonitor_dirty, i - skipped); + ewah_set(bitmap, i - skipped); } + return bitmap; } -void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) +void fill_fsmonitor_bitmap(struct index_state *istate) +{ + istate->fsmonitor_dirty = fsmonitor_bitmap_from_index(istate); +} + +static void serialize_fsmonitor_extension(struct strbuf *sb, + struct index_state *istate, + struct ewah_bitmap *bitmap) { uint32_t hdr_version; uint32_t ewah_start; @@ -283,7 +293,7 @@ void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) int fixup = 0; if (!istate->split_index) - assert_index_minimum(istate, istate->fsmonitor_dirty->bit_size); + assert_index_minimum(istate, bitmap->bit_size); put_be32(&hdr_version, INDEX_EXTENSION_VERSION2); strbuf_add(sb, &hdr_version, sizeof(uint32_t)); @@ -295,13 +305,27 @@ void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) strbuf_add(sb, &ewah_size, sizeof(uint32_t)); /* we'll fix this up later */ ewah_start = sb->len; - ewah_serialize_strbuf(istate->fsmonitor_dirty, sb); - ewah_free(istate->fsmonitor_dirty); - istate->fsmonitor_dirty = NULL; + ewah_serialize_strbuf(bitmap, sb); /* fix up size field */ put_be32(&ewah_size, sb->len - ewah_start); memcpy(sb->buf + fixup, &ewah_size, sizeof(uint32_t)); +} + +void snapshot_fsmonitor_extension(struct strbuf *sb, + struct index_state *istate) +{ + struct ewah_bitmap *bitmap = fsmonitor_bitmap_from_index(istate); + + serialize_fsmonitor_extension(sb, istate, bitmap); + ewah_free(bitmap); +} + +void write_fsmonitor_extension(struct strbuf *sb, struct index_state *istate) +{ + serialize_fsmonitor_extension(sb, istate, istate->fsmonitor_dirty); + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; trace2_data_string("index", NULL, "extension/fsmn/write/token", istate->fsmonitor_last_update); @@ -944,8 +968,7 @@ static void invalidate_fsmonitor_for_bootstrap( } if (physical_history_unavailable) { - if (semantic_adoption_needed) - clean_status_refresh_worktree_manifest(istate); + clean_status_refresh_worktree_manifest(istate); fsmonitor_invalidate_semantics(istate); untracked_cache_invalidate_all(istate); return; diff --git a/meson.build b/meson.build index 1c52ff8fb71826..1e1b624fab11a3 100644 --- a/meson.build +++ b/meson.build @@ -335,6 +335,7 @@ libgit_sources = [ 'chunk-format.c', 'clean-status.c', 'clean-status-config.c', + 'clean-status-epoch.c', 'clean-status-history-store.c', 'clean-status-history.c', 'clean-status-identity.c', diff --git a/read-cache-ll.h b/read-cache-ll.h index 53d0d0c0bfc9f6..ea14e94de1ef7c 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -477,6 +477,7 @@ int fake_lstat(const struct cache_entry *ce, struct stat *st); #define REFRESH_IN_PORCELAIN (1 << 5) /* user friendly output, not "needs update" */ #define REFRESH_PROGRESS (1 << 6) /* show progress bar if stderr is tty */ #define REFRESH_IGNORE_SKIP_WORKTREE (1 << 7) /* ignore skip_worktree entries */ +#define REFRESH_IN_PROOF_EPOCH (1 << 9) /* refresh is bounded by a proof epoch */ int refresh_index(struct index_state *, unsigned int flags, const struct pathspec *pathspec, char *seen, const char *header_msg); /* * Refresh the index and write it to disk. diff --git a/read-cache.c b/read-cache.c index 0e8be0ab995058..3934122a1f4957 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1659,13 +1659,14 @@ int refresh_index(struct index_state *istate, unsigned int flags, } { - int baseline_valid = - clean_status_fsmonitor_semantic_baseline_pending( - istate) && - (new_entry->ce_flags & CE_FSMONITOR_VALID); + int fsmonitor_valid = + (new_entry->ce_flags & CE_FSMONITOR_VALID) && + ((flags & REFRESH_IN_PROOF_EPOCH) || + clean_status_fsmonitor_semantic_baseline_pending( + istate)); replace_index_entry(istate, i, new_entry); - if (baseline_valid) + if (fsmonitor_valid) mark_fsmonitor_valid(istate, istate->cache[i]); } @@ -2022,6 +2023,7 @@ static void post_read_index_from(struct index_state *istate) check_ce_order(istate); tweak_untracked_cache(istate); tweak_split_index(istate); + clean_status_restore_external_history(istate); prepare_fsmonitor_untracked(istate); clean_status_prepare_fsmonitor_config(istate); tweak_fsmonitor(istate); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 90228af9d007d2..c4cbd1adfb382b 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -69,6 +69,29 @@ test_expect_success 'FSUC parser fails closed' ' test-tool read-cache --test-fsuc-parser ' +test_expect_success !SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'unsupported identity preserves an ordinary provider token' ' + test_when_finished "rm -rf unsupported-provider-token" && + test_create_repo unsupported-provider-token && + ( + cd unsupported-provider-token && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "^fsmonitor last update builtin:test:" \ + .git/fsmonitor && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/status.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'FSCF survives index I/O and generic rewrites' ' test_when_finished "rm -rf fscf-round-trip" && diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index 0a62f4dfeede5e..c13fbc03123299 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -222,6 +222,129 @@ void test_clean_status_index__pins_null_checksum_source_identity(void) assert_pins_null_checksum_source(&hash_algos[GIT_HASH_SHA256]); } +static int retain_null_checksum_source( + struct index_fixture *fixture, struct repository *repo, + struct index_state *istate) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_state *state; + struct stat source_st; + int source_fd; + + repo->hash_algo = algo; + repo->index_file = fixture->path; + istate->version = 4; + istate->cache_nr = 7; + oidcpy(&istate->oid, &fixture->checksum); + state = clean_status_get_state(istate); + source_fd = git_open_cloexec(fixture->path, O_RDONLY); + cl_assert(source_fd >= 0); +#if defined(F_GETFD) && defined(FD_CLOEXEC) + cl_assert(fcntl(source_fd, F_GETFD) & FD_CLOEXEC); +#endif + cl_assert_equal_i(fstat(source_fd, &source_st), 0); + cl_assert(!clean_status_retain_source_index_fd( + istate, source_fd, &source_st)); + cl_assert_equal_i(fstat(source_fd, &source_st), 0); + state->config_enforced = 1; + cl_assert(clean_status_retain_source_index_fd( + istate, source_fd, &source_st)); + return source_fd; +} + +void test_clean_status_index__pins_null_checksum_epoch_to_source_fd(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct stat source_st; + int source_fd; + + if (!fstat_is_reliable()) + return; + fixture_init(&fixture, algo); + fixture_clear_checksum(&fixture, algo); + source_fd = retain_null_checksum_source(&fixture, &repo, &istate); + + /* The held source is an exception only for proof epochs. */ + cl_assert_equal_i(clean_status_index_snapshot_pin( + &snapshot, &istate), -1); + cl_assert_equal_i(clean_status_index_snapshot_pin_proof_epoch( + &snapshot, &istate), 0); + cl_assert(clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, &istate)); + cl_assert(!clean_status_index_snapshot_still_matches( + &snapshot, &istate)); + clean_status_index_snapshot_release(&snapshot); + + release_index(&istate); + errno = 0; + cl_assert_equal_i(fstat(source_fd, &source_st), -1); + cl_assert_equal_i(errno, EBADF); + fixture_release(&fixture); +} + +static void assert_changed_null_checksum_source_is_rejected(int replace) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_index_snapshot snapshot; + struct index_fixture fixture, replacement; + struct repository repo = { 0 }; + struct index_state istate = INDEX_STATE_INIT(&repo); +#ifndef GIT_WINDOWS_NATIVE + char *moved = NULL; +#endif + + fixture_init(&fixture, algo); + fixture_clear_checksum(&fixture, algo); + if (replace) { + fixture_init(&replacement, algo); + fixture_clear_checksum(&replacement, algo); + } + retain_null_checksum_source(&fixture, &repo, &istate); + cl_assert_equal_i(clean_status_index_snapshot_pin_proof_epoch( + &snapshot, &istate), 0); + + if (replace) { +#ifndef GIT_WINDOWS_NATIVE + moved = xstrfmt("%s.old", fixture.path); + cl_assert_equal_i(rename(fixture.path, moved), 0); + cl_assert_equal_i(rename(replacement.path, fixture.path), 0); +#endif + } else { + write_at(fixture.fd, "\0", 1, fixture.st.st_size); + } + cl_assert(!clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, &istate)); + clean_status_index_snapshot_release(&snapshot); + cl_assert_equal_i(clean_status_index_snapshot_pin_proof_epoch( + &snapshot, &istate), -1); + if (replace) { +#ifndef GIT_WINDOWS_NATIVE + cl_assert_equal_i(rename(fixture.path, replacement.path), 0); + cl_assert_equal_i(rename(moved, fixture.path), 0); + free(moved); +#endif + } + + release_index(&istate); + fixture_release(&fixture); + if (replace) + fixture_release(&replacement); +} + +void test_clean_status_index__rejects_changed_null_checksum_epoch_source(void) +{ + if (!fstat_is_reliable()) + return; + assert_changed_null_checksum_source_is_rejected(0); +#ifndef GIT_WINDOWS_NATIVE + assert_changed_null_checksum_source_is_rejected(1); +#endif +} + void test_clean_status_index__pins_named_index_identity(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -404,3 +527,73 @@ void test_clean_status_index__digests_only_persistent_logical_entries(void) release_index(&istate); } + +void test_clean_status_index__limits_full_status_bookkeeping_exception(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct cache_entry *ce; + unsigned char baseline[GIT_MAX_RAWSZ]; + unsigned char changed[GIT_MAX_RAWSZ]; + static const char path[] = "tracked"; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + ce = make_empty_cache_entry(&istate, sizeof(path) - 1); + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = sizeof(path) - 1; + memcpy(ce->name, path, sizeof(path)); + memset(ce->oid.hash, 1, repo.hash_algo->rawsz); + oid_set_algo(&ce->oid, repo.hash_algo); + istate.cache[0] = ce; + repo.index = &istate; + istate.cache_changed = CE_ENTRY_CHANGED; + clean_status_enable_external_history(&repo); + + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, baseline), 0); + ce->ce_flags = CE_UPDATE_IN_BASE; + cl_assert_equal_i(clean_status_index_logical_digest( + &istate, changed), -1); + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(!memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_mode = S_IFREG | 0755; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->ce_mode = S_IFREG | 0644; + ce->oid.hash[0] = 2; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->oid.hash[0] = 1; + ce->name[0] = 'T'; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + ce->name[0] = 't'; + ce->ce_flags = CE_UPDATE_IN_BASE | CE_VALID; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), 0); + cl_assert(memcmp(baseline, changed, repo.hash_algo->rawsz)); + + ce->ce_flags = CE_UPDATE_IN_BASE | CE_CONTENT_CHECK_REQUIRED; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + ce->ce_flags = CE_UPDATE_IN_BASE | CE_WT_REMOVE; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + ce->ce_flags = CE_UPDATE_IN_BASE; + istate.cache_changed |= CACHE_TREE_CHANGED; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + istate.cache_changed = CE_ENTRY_CHANGED; + repo.index = NULL; + cl_assert_equal_i(clean_status_index_logical_digest_after_status( + &istate, changed), -1); + clean_status_enable_external_history(NULL); + + release_index(&istate); +} diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c index fc17fd8ec0dbb8..41c565330e5cde 100644 --- a/t/unit-tests/u-clean-status-manifest.c +++ b/t/unit-tests/u-clean-status-manifest.c @@ -1,5 +1,7 @@ #include "unit-test.h" #include "attr-manifest.h" +#include "clean-status.h" +#include "clean-status-internal.h" #include "clean-status-manifest.h" #include "dir.h" #include "fsmonitor-clean-proof.h" @@ -73,6 +75,51 @@ void test_clean_status_manifest__rejects_invalid_history(void) clean_status_manifest_release(&state); strbuf_release(&manifest); } + +void test_clean_status_manifest__requires_complete_full_index(void) +{ + const char *current_token = "builtin:1:2"; + const char *closed_token = "builtin:1:3"; + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct clean_status_state *state = clean_status_get_state(&istate); + + istate.fsmonitor_last_update = xstrdup(current_token); + istate.fsmonitor_token_valid = 1; + state->current_config_valid = 1; + state->current_semantic_valid = 1; + state->current_attr_valid = 1; + state->config_enforced = 1; + state->manifest.current_valid = 1; + state->manifest.checked = 1; + state->manifest.current_flags = + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE; + + clean_status_mark_fsmonitor_config_valid(&istate, closed_token); + cl_assert(!state->config_revalidated); + cl_assert(!state->config_revalidated_token); + cl_assert(!clean_status_should_write_fsmonitor_config(&istate)); + cl_assert_equal_i(state->manifest.current_flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE); + + state->manifest.current_flags |= FSMONITOR_CLEAN_PROOF_FULL_INDEX; + clean_status_mark_fsmonitor_config_valid(&istate, closed_token); + cl_assert(state->config_revalidated); + cl_assert_equal_s(state->config_revalidated_token, closed_token); + cl_assert(!clean_status_revalidated_token_matches(&istate)); + cl_assert(!clean_status_should_write_fsmonitor_config(&istate)); + + FREE_AND_NULL(istate.fsmonitor_last_update); + istate.fsmonitor_last_update = xstrdup(closed_token); + cl_assert(clean_status_revalidated_token_matches(&istate)); + cl_assert(clean_status_should_write_fsmonitor_config(&istate)); + cl_assert_equal_i(state->manifest.current_flags, + FSMONITOR_CLEAN_PROOF_ALL); + + clean_status_release(&istate); + FREE_AND_NULL(istate.fsmonitor_last_update); +} + #if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN static char *create_worktree(void) { diff --git a/wt-status.c b/wt-status.c index 7146f3e42f1760..ec1eab6f2eb255 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1015,45 +1015,104 @@ static void wt_status_publish_staged_untracked( closure->staged_untracked_ready = 0; } +static int fsmonitor_token_requires_rescan(enum fsmonitor_token_result result) +{ + return result == FSMONITOR_TOKEN_CHANGED || + result == FSMONITOR_TOKEN_TRIVIAL; +} + +static void wt_status_refresh_for_token( + struct wt_status *s, unsigned int refresh_flags, + struct clean_status_proof_epoch **epoch, int *refresh_result) +{ + struct index_state *istate = s->repo->index; + + clean_status_release_proof_epoch(*epoch); + *epoch = clean_status_capture_proof_epoch( + istate, s->attr_source_snapshot, 0); + if (*epoch) { + *refresh_result |= refresh_index( + istate, refresh_flags | REFRESH_IN_PROOF_EPOCH, + &s->pathspec, NULL, NULL); + } +} + static int wt_status_close_ordinary_fsmonitor_token( struct wt_status_token_closure *closure, int refreshed_before_closure) { struct wt_status *s = closure->status; struct index_state *istate = s->repo->index; + struct clean_status_proof_epoch *scan_epoch = NULL; + int reliable_stat = fstat_is_reliable(); - if (!refreshed_before_closure) - closure->refresh_result = refresh_index( + /* + * A pending token must close a refresh begun after its epoch was + * captured. A refresh performed before entering token closure cannot + * be validated by capturing its inputs afterward. + */ + if (reliable_stat) { + wt_status_refresh_for_token( + s, closure->refresh_flags, &scan_epoch, + &closure->refresh_result); + if (!scan_epoch) + return 0; + } else if (!refreshed_before_closure) { + closure->refresh_result |= refresh_index( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); + } if (!closure->untracked_ready && closure->can_prime) closure->untracked_ready = wt_status_stage_untracked(closure); while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { - enum fsmonitor_token_result result = - fsmonitor_query_pending_token( - istate, closure->untracked_ready); + enum fsmonitor_token_result result; + if (reliable_stat && + !clean_status_proof_epoch_start_token_matches( + istate, scan_epoch)) + break; closure->queries++; + result = fsmonitor_query_pending_token( + istate, closure->untracked_ready); if (result == FSMONITOR_TOKEN_CLEAN) { + if (reliable_stat && + !clean_status_proof_epoch_matches( + istate, scan_epoch)) + break; if (closure->untracked_ready) { + if (reliable_stat) + clean_status_mark_fsmonitor_config_valid( + istate, + istate->fsmonitor_last_update_pending); + clean_status_release_proof_epoch(scan_epoch); fsmonitor_accept_pending_token(istate); return 1; } break; } - if (result == FSMONITOR_TOKEN_ERROR || - result == FSMONITOR_TOKEN_NOT_PENDING) + clean_status_release_proof_epoch(scan_epoch); + scan_epoch = NULL; + if (!fsmonitor_token_requires_rescan(result)) break; /* Rescan invalidations returned by the closure query. */ - closure->refresh_result |= refresh_index( - istate, closure->refresh_flags, &s->pathspec, - NULL, NULL); + if (reliable_stat) { + wt_status_refresh_for_token( + s, closure->refresh_flags, &scan_epoch, + &closure->refresh_result); + if (!scan_epoch) + break; + } else { + closure->refresh_result |= refresh_index( + istate, closure->refresh_flags, + &s->pathspec, NULL, NULL); + } if (closure->can_prime) closure->untracked_ready = wt_status_stage_untracked(closure); } + clean_status_release_proof_epoch(scan_epoch); return 0; } @@ -1095,6 +1154,11 @@ static int wt_status_close_fsmonitor_token( /* Keep the last valid token and fall back to complete scans. */ wt_status_discard_staged_untracked(&closure); fsmonitor_reject_pending_token(istate); + if (fstat_is_reliable()) { + if (closure.can_prime) + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); + } closure.refresh_result |= refresh_index( istate, refresh_flags, &s->pathspec, NULL, NULL); accepted: From 0c1520261aa04d7be61471c6a042c8d864626411 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:52:33 -0700 Subject: [PATCH 190/403] wt-status: separate untracked traversal from result collection Untracked status used one helper both to traverse the worktree and to copy untracked and ignored entries into status output. Checking whether a traversal actually validated the repository's UNTR cache requires that directory walk without copying results or recording user-facing timing. Factor the walk into wt_status_collect_untracked_1() with an explicit collection flag. Return whether the traversal used the index's own untracked cache, and populate the result lists and advice timing only when collection is requested. Retain wt_status_collect_untracked() as the collecting wrapper. Every existing production caller still requests collection, so status output and ordinary traversal behavior remain unchanged. Token adoption and validation-only production use are not added by this preparatory patch. Signed-off-by: Taylor Blau --- wt-status.c | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/wt-status.c b/wt-status.c index da642642d4a229..fab9f1af38bea6 100644 --- a/wt-status.c +++ b/wt-status.c @@ -828,15 +828,16 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) untracked_cache_preload_start_ordinary(istate, dir_flags); } -static void wt_status_collect_untracked(struct wt_status *s) +static int wt_status_collect_untracked_1(struct wt_status *s, int collect) { int i; + int used_untracked_cache; struct dir_struct dir = DIR_INIT; uint64_t t_begin = getnanotime(); struct index_state *istate = s->repo->index; if (!s->show_untracked_files) - return; + return 0; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -859,25 +860,35 @@ static void wt_status_collect_untracked(struct wt_status *s) s->untracked_cache_preloaded; fill_directory(&dir, istate, &s->pathspec); + used_untracked_cache = dir.untracked && + dir.untracked == istate->untracked; + + if (collect) { + for (i = 0; i < dir.nr; i++) { + struct dir_entry *ent = dir.entries[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(&s->untracked, ent->name); + } + string_list_sort_u(&s->untracked, 0); - for (i = 0; i < dir.nr; i++) { - struct dir_entry *ent = dir.entries[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->untracked, ent->name); - } - string_list_sort_u(&s->untracked, 0); - - for (i = 0; i < dir.ignored_nr; i++) { - struct dir_entry *ent = dir.ignored[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->ignored, ent->name); + for (i = 0; i < dir.ignored_nr; i++) { + struct dir_entry *ent = dir.ignored[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(&s->ignored, ent->name); + } + string_list_sort_u(&s->ignored, 0); } - string_list_sort_u(&s->ignored, 0); dir_clear(&dir); - if (advice_enabled(ADVICE_STATUS_U_OPTION)) + if (collect && advice_enabled(ADVICE_STATUS_U_OPTION)) s->untracked_in_ms = (getnanotime() - t_begin) / 1000000; + return used_untracked_cache; +} + +static int wt_status_collect_untracked(struct wt_status *s) +{ + return wt_status_collect_untracked_1(s, 1); } static int has_unmerged(struct wt_status *s) From a01798ecf7e6343e56b27830329ebec06331eb7f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:42:44 -0500 Subject: [PATCH 191/403] fsmonitor: reopen semantic proofs after attribute events A provider event can change a tracked .gitattributes file after status captures its conversion inputs. Accepting the resulting token against the previous manifest can incorrectly reuse tracked validity when a complete status would report a content change. Record when provider invalidation expires the current manifest and semantic proof. Before retrying token closure, rebuild that manifest and recapture external attribute sources when their content changes. Keep the response token pending until the new scan and current attribute epoch are both closed. Preserve ordinary provider handling when file identity is unreliable. Preserve reusable manifest history across ordinary index rewrites without retaining expired bindings. Extend the history and manifest unit cases and the index round-trip helper. Add a scripted regression for tracked attribute changes. Manifest or snapshot failure still forces a complete scan. Signed-off-by: Taylor Blau --- attr-fingerprint.c | 15 +++++ attr-fingerprint.h | 3 + clean-status-manifest.c | 4 ++ clean-status-manifest.h | 1 + clean-status.c | 20 ++++++- clean-status.h | 4 +- fsmonitor.c | 44 +++++++++++++-- t/unit-tests/u-clean-status-history.c | 22 ++++++++ t/unit-tests/u-clean-status-manifest.c | 4 ++ t/unit-tests/u-fsmonitor-attributes.c | 27 +++++++++ wt-status.c | 78 ++++++++++++++++++++++---- 11 files changed, 205 insertions(+), 17 deletions(-) diff --git a/attr-fingerprint.c b/attr-fingerprint.c index d7fdc1870dd2c3..d0152d6fe23963 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -213,6 +213,21 @@ int attr_source_snapshot_repository(struct repository *repo, return 0; } +int attr_source_snapshot_matches_repository( + struct repository *repo, + const struct attr_source_snapshot *snapshot) +{ + struct attr_fingerprint current; + + return snapshot && + !attr_fingerprint_repository(repo, ¤t) && + current.sources_present == + snapshot->fingerprint.sources_present && + !memcmp(current.content_hash, + snapshot->fingerprint.content_hash, + repo->hash_algo->rawsz); +} + const struct attr_fingerprint *attr_source_snapshot_fingerprint( const struct attr_source_snapshot *snapshot) { diff --git a/attr-fingerprint.h b/attr-fingerprint.h index 6d15646fcd1975..518a5b31b4e485 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -32,6 +32,9 @@ int attr_fingerprint_repository(struct repository *repo, struct attr_fingerprint *result); int attr_source_snapshot_repository(struct repository *repo, struct attr_source_snapshot **result); +int attr_source_snapshot_matches_repository( + struct repository *repo, + const struct attr_source_snapshot *snapshot); const struct attr_fingerprint *attr_source_snapshot_fingerprint( const struct attr_source_snapshot *snapshot); int attr_source_snapshot_read( diff --git a/clean-status-manifest.c b/clean-status-manifest.c index b2c8aaec97e71e..2750ddeb7280a9 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -93,6 +93,7 @@ void clean_status_manifest_adopt_disk( state->current_flags = state->disk_flags; state->current_valid = 1; state->checked = 1; + state->current_invalidated = 0; } static int invalidate_manifest_path(const struct attr_manifest_entry *entry, @@ -155,6 +156,7 @@ int clean_status_manifest_refresh(struct index_state *istate, state->current_valid = 1; state->current_flags = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | FSMONITOR_CLEAN_PROOF_FULL_INDEX; + state->current_invalidated = 0; trace2_data_intmax("fsmonitor", istate->repo, "semantic/manifest-candidates", stats.candidates); trace2_data_intmax("fsmonitor", istate->repo, @@ -180,6 +182,8 @@ int clean_status_manifest_refresh(struct index_state *istate, void clean_status_manifest_invalidate( struct clean_status_manifest_state *state) { + if (state->current_valid) + state->current_invalidated = 1; state->current_valid = 0; state->current_flags = 0; } diff --git a/clean-status-manifest.h b/clean-status-manifest.h index 04a56d56abe65c..394fe25a888c0d 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -19,6 +19,7 @@ struct clean_status_manifest_state { unsigned checked : 1; unsigned changed : 1; unsigned global_fallback : 1; + unsigned current_invalidated : 1; }; void clean_status_manifest_init(struct clean_status_manifest_state *state); diff --git a/clean-status.c b/clean-status.c index 5d8858ce3fdc3f..374738e81cbdb0 100644 --- a/clean-status.c +++ b/clean-status.c @@ -182,7 +182,8 @@ int clean_status_fsmonitor_config_mismatch(const struct index_state *istate) { return istate->clean_status && istate->clean_status->current_config_valid && - istate->clean_status->config_mismatch; + (istate->clean_status->config_mismatch || + !istate->clean_status->config_revalidated); } int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate) @@ -205,6 +206,23 @@ int clean_status_manifest_global_fallback(const struct index_state *istate) istate->clean_status->manifest.global_fallback; } +int clean_status_worktree_manifest_needs_refresh( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->config_enforced && + state->manifest.current_invalidated; +} + +void clean_status_invalidate_current_manifest(struct index_state *istate) +{ + if (!istate->clean_status) + return; + clean_status_manifest_invalidate(&istate->clean_status->manifest); + clean_status_invalidate_current_proof(istate); +} + void clean_status_mark_fsmonitor_config_valid(struct index_state *istate, const char *closed_token) { diff --git a/clean-status.h b/clean-status.h index bd518740eaaeb9..cfe6a2db889f38 100644 --- a/clean-status.h +++ b/clean-status.h @@ -59,7 +59,9 @@ void clean_status_begin_fsmonitor_semantic_baseline( int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); - +int clean_status_worktree_manifest_needs_refresh( + const struct index_state *istate); +void clean_status_invalidate_current_manifest(struct index_state *istate); void clean_status_mark_fsmonitor_config_valid( struct index_state *istate, const char *closed_token); diff --git a/fsmonitor.c b/fsmonitor.c index 2e046b15a4a66c..e65567feaa0cb4 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -671,7 +671,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) if (!strcmp(name, FSMONITOR_PATH_GLOBAL_INVALIDATE)) { unsigned int i; - clean_status_invalidate_current_proof(istate); + clean_status_invalidate_current_manifest(istate); git_attr_invalidate_all(); untracked_cache_invalidate_all(istate); for (i = 0; i < istate->cache_nr; i++) @@ -708,7 +708,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) } } if (attributes_may_have_changed) - clean_status_invalidate_current_proof(istate); + clean_status_invalidate_current_manifest(istate); if (nr_in_cone) trace_printf_key(&trace_fsmonitor, @@ -939,14 +939,22 @@ static void invalidate_all_fsmonitor_for_baseline( static void invalidate_all_fsmonitor_strong(struct index_state *istate) { unsigned int i; + int provider_disabled = + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_DISABLED; invalidate_all_fsmonitor(istate); - for (i = 0; i < istate->cache_nr; i++) - fsmonitor_invalidate_cache_entry(istate->cache[i]); + for (i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + + if (provider_disabled && ce_skip_worktree(ce)) + continue; + fsmonitor_invalidate_cache_entry(ce); + } } void fsmonitor_invalidate_semantics(struct index_state *istate) { + clean_status_invalidate_current_proof(istate); git_attr_invalidate_all(); invalidate_all_fsmonitor_strong(istate); istate->cache_changed |= FSMONITOR_CHANGED; @@ -1189,11 +1197,37 @@ void refresh_fsmonitor(struct index_state *istate) } } - if (tracked_requires_bootstrap) + /* + * Applying a provider event may expire semantic history after + * the initial bootstrap decision. Keep the new token pending + * until status has rescanned against rebuilt inputs. + */ + if (fstat_is_reliable() && !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_config_mismatch(istate)) + tracked_requires_bootstrap = 1; + + if (tracked_requires_bootstrap) { + /* + * Provider paths can invalidate the manifest or + * semantic inputs after our pre-query snapshot. + * Recheck before choosing the narrow baseline lane. + */ + semantic_adoption_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_adoption_needed( + istate); + semantic_baseline_needed = fstat_is_reliable() && + !istate->split_index && + fsm_mode == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_baseline_needed( + istate); invalidate_fsmonitor_for_bootstrap( istate, fsm_mode, semantic_adoption_needed, semantic_baseline_needed, !istate->fsmonitor_token_valid); + } /* Now mark the untracked cache for fsmonitor usage */ if (istate->untracked) diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index feb813c185c624..3d196c73c8236e 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -277,6 +277,28 @@ void test_clean_status_history__advances_only_current_proofs(void) fixture_release(&fixture); } +void test_clean_status_history__expires_invalidated_proofs(void) +{ + struct history_fixture fixture; + struct clean_status_state *state; + + fixture_init(&fixture, &hash_algos[GIT_HASH_SHA1]); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(state->manifest.current_valid); + cl_assert(state->config_revalidated); + cl_assert(state->initial_coherent); + + clean_status_invalidate_current_manifest(&fixture.istate); + cl_assert(!state->manifest.current_valid); + cl_assert(!state->config_revalidated); + cl_assert(!state->initial_coherent); + cl_assert(clean_status_fsmonitor_config_mismatch(&fixture.istate)); + fixture_release(&fixture); +} + void test_clean_status_history__copies_validated_history(void) { struct history_fixture fixture; diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c index 41c565330e5cde..971b4bea973c0c 100644 --- a/t/unit-tests/u-clean-status-manifest.c +++ b/t/unit-tests/u-clean-status-manifest.c @@ -42,6 +42,7 @@ void test_clean_status_manifest__loads_and_adopts_valid_history(void) cl_assert(!memcmp(state.current.buf, manifest.buf, manifest.len)); clean_status_manifest_invalidate(&state); cl_assert(!state.current_valid); + cl_assert(state.current_invalidated); cl_assert_equal_i(state.current_flags, 0); clean_status_manifest_release(&state); strbuf_release(&manifest); @@ -183,6 +184,7 @@ void test_clean_status_manifest__invalidates_only_changed_scopes(void) } cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); cl_assert(state.changed); + cl_assert(!state.current_invalidated); cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); @@ -195,6 +197,7 @@ void test_clean_status_manifest__invalidates_only_changed_scopes(void) strbuf_reset(&old); strbuf_addbuf(&old, &state.current); clean_status_manifest_invalidate(&state); + cl_assert(state.current_invalidated); istate.cache[0]->ce_flags = create_ce_flags(1); cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), -1); cl_assert(!state.current_valid); @@ -209,6 +212,7 @@ void test_clean_status_manifest__invalidates_only_changed_scopes(void) } cl_assert_equal_i(clean_status_manifest_refresh(&istate, &state), 1); cl_assert(state.changed); + cl_assert(!state.current_invalidated); cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); cl_assert(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED); cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); diff --git a/t/unit-tests/u-fsmonitor-attributes.c b/t/unit-tests/u-fsmonitor-attributes.c index 5a2b7a25f137b3..3eedefca7aad0b 100644 --- a/t/unit-tests/u-fsmonitor-attributes.c +++ b/t/unit-tests/u-fsmonitor-attributes.c @@ -1,5 +1,7 @@ #include "unit-test.h" +#include "fsmonitor.h" #include "fsmonitor-ll.h" +#include "fsmonitor-settings.h" #include "read-cache-ll.h" #include "repository.h" @@ -70,3 +72,28 @@ void test_fsmonitor_attributes__root_source_invalidates_every_entry(void) } release_index(&istate); } + +void test_fsmonitor_attributes__disabled_provider_preserves_skipped_stat(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + fsm_settings__set_disabled(&repo); + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_entry(&istate, 0, "skipped"); + add_entry(&istate, 1, "tracked"); + istate.cache[0]->ce_flags |= CE_SKIP_WORKTREE; + + fsmonitor_invalidate_semantics(&istate); + + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(!(istate.cache[0]->ce_flags & CE_CONTENT_CHECK_REQUIRED)); + cl_assert(!stat_data_is_zero(istate.cache[0])); + cl_assert(!(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[1]->ce_flags & CE_CONTENT_CHECK_REQUIRED); + cl_assert(stat_data_is_zero(istate.cache[1])); + + release_index(&istate); + FREE_AND_NULL(repo.settings.fsmonitor); +} diff --git a/wt-status.c b/wt-status.c index ec1eab6f2eb255..f7769464cc2907 100644 --- a/wt-status.c +++ b/wt-status.c @@ -820,10 +820,10 @@ static int wt_status_begin_attr_snapshot(struct wt_status *s) int hook_provider = fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_HOOK; - if (s->attr_source_snapshot) - return 0; if (s->attr_snapshot_failed) return -1; + if (s->attr_source_snapshot) + return 0; ret = clean_status_capture_attr_snapshot( s->repo->index, &s->attr_source_snapshot); if (ret < 0) { @@ -1021,6 +1021,35 @@ static int fsmonitor_token_requires_rescan(enum fsmonitor_token_result result) result == FSMONITOR_TOKEN_TRIVIAL; } +static void wt_status_release_attr_snapshot(struct wt_status *s); + +static int wt_status_attr_snapshot_matches(struct wt_status *s) +{ + if (s->attr_snapshot_failed) + return 0; + return !s->attr_source_snapshot || + attr_source_snapshot_matches_repository( + s->repo, s->attr_source_snapshot); +} + +static int wt_status_refresh_invalidated_manifest(struct wt_status *s) +{ + if (!clean_status_worktree_manifest_needs_refresh(s->repo->index)) + return 0; + return clean_status_refresh_worktree_manifest(s->repo->index) < 0 ? + -1 : 0; +} + +static void wt_status_reset_attr_snapshot_if_changed(struct wt_status *s) +{ + if (wt_status_attr_snapshot_matches(s)) + return; + wt_status_release_attr_snapshot(s); + wt_status_begin_attr_snapshot(s); + trace2_data_intmax("status", s->repo, + "semantic/attribute-epoch-rejected", 1); +} + static void wt_status_refresh_for_token( struct wt_status *s, unsigned int refresh_flags, struct clean_status_proof_epoch **epoch, int *refresh_result) @@ -1078,8 +1107,10 @@ static int wt_status_close_ordinary_fsmonitor_token( if (result == FSMONITOR_TOKEN_CLEAN) { if (reliable_stat && !clean_status_proof_epoch_matches( - istate, scan_epoch)) + istate, scan_epoch)) { + wt_status_reset_attr_snapshot_if_changed(s); break; + } if (closure->untracked_ready) { if (reliable_stat) clean_status_mark_fsmonitor_config_valid( @@ -1097,6 +1128,9 @@ static int wt_status_close_ordinary_fsmonitor_token( break; /* Rescan invalidations returned by the closure query. */ + wt_status_reset_attr_snapshot_if_changed(s); + if (wt_status_refresh_invalidated_manifest(s)) + break; if (reliable_stat) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, @@ -1131,10 +1165,24 @@ static int wt_status_close_fsmonitor_token( refresh_fsmonitor(istate); if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) { - if (!refreshed_before_closure) - closure.refresh_result = refresh_index( + int attr_inputs_match = + wt_status_attr_snapshot_matches(s) && + !clean_status_worktree_manifest_needs_refresh(istate); + + if (!refreshed_before_closure && attr_inputs_match) + return refresh_index( istate, refresh_flags, &s->pathspec, NULL, NULL); + if (refreshed_before_closure && attr_inputs_match) + return closure.refresh_result; + + wt_status_reset_attr_snapshot_if_changed(s); + if (fsmonitor_has_pending_token(istate)) + fsmonitor_reject_pending_token(istate); + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); + closure.refresh_result |= refresh_index( + istate, refresh_flags, &s->pathspec, NULL, NULL); return closure.refresh_result; } @@ -1147,11 +1195,15 @@ static int wt_status_close_fsmonitor_token( !closure.untracked_ready) BUG("cannot close required untracked scan"); trace2_region_enter("status", "fsmonitor_token_closure", s->repo); + wt_status_reset_attr_snapshot_if_changed(s); + if (wt_status_refresh_invalidated_manifest(s)) + goto fallback; if (wt_status_close_ordinary_fsmonitor_token( &closure, refreshed_before_closure)) goto accepted; /* Keep the last valid token and fall back to complete scans. */ +fallback: wt_status_discard_staged_untracked(&closure); fsmonitor_reject_pending_token(istate); if (fstat_is_reliable()) { @@ -1172,10 +1224,20 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked) { + wt_status_begin_attr_snapshot(s); return wt_status_close_fsmonitor_token( s, refresh_flags, require_untracked, 0); } +static void wt_status_release_attr_snapshot(struct wt_status *s) +{ + if (s->attr_source_snapshot) + git_attr_source_snapshot_end(s->attr_source_snapshot); + attr_source_snapshot_free(s->attr_source_snapshot); + s->attr_source_snapshot = NULL; + s->attr_snapshot_failed = 0; +} + static int has_unmerged(struct wt_status *s) { int i; @@ -1239,11 +1301,7 @@ void wt_status_collect_free_buffers(struct wt_status *s) { untracked_cache_preload_release(s->untracked_cache_preload); s->untracked_cache_preload = NULL; - if (s->attr_source_snapshot) - git_attr_source_snapshot_end(s->attr_source_snapshot); - attr_source_snapshot_free(s->attr_source_snapshot); - s->attr_source_snapshot = NULL; - s->attr_snapshot_failed = 0; + wt_status_release_attr_snapshot(s); wt_status_state_free_buffers(&s->state); } From e3c158585093f8e065c68fc412ddd7b85e17fea1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:50:12 -0500 Subject: [PATCH 192/403] status: close fsmonitor tokens around complete status scans A provider token obtained before a tracked or untracked scan does not cover worktree changes racing with that scan. Publishing it as an FSMN or FSUC proof can make a later status trust an index or untracked-cache snapshot that was never valid at that boundary. Keep bootstrap tokens pending while tracked entries are refreshed and any rooted untracked cache is traversed. For builtin providers, query again after the scans, apply intervening paths, and repeat the affected scans until a clean boundary is found or three closing queries are exhausted. Treat a trivial closing reply as complete invalidation followed by another scan; accept its replacement token only after a later clean reply. Reject provider errors, incomplete cache proofs, and exhausted retries with strong invalidation and complete fallback scans. Hook providers cannot perform a closing IPC query, so accept their token only after a complete tracked and applicable untracked collection; reject failed or trivial hook replies. A matching on-disk FSUC token can now authorize replay of recursive UNTR validity established by S01. Reconstruct that validity only after the entire extension has decoded, and only for directories without a cached per-directory exclude digest. This lets a warm status prune known-empty subtrees while still rechecking a changed .gitignore, including changes made through an unwatched hardlink alias. Trust an indexed exclude's metadata alone only when its identity is reliable and it has exactly one link; otherwise retain the complete content-hash check. Route both status collection and commit index refresh through the shared closure. Preserve ordinary behavior for existing paired state, path-limited requests, and ignored-mode collection. Cover clean and changed closures, trivial replies, retry exhaustion, provider errors, on-disk FSMN/FSUC publication, warm empty-subtree pruning, descendant events, and cached exclude changes. Signed-off-by: Taylor Blau --- builtin/commit.c | 7 +- dir.c | 467 ++++++++++++++++++++++++++-- dir.h | 11 +- fsmonitor-ll.h | 18 ++ fsmonitor.c | 215 ++++++++++++- read-cache-ll.h | 4 +- read-cache.c | 1 + t/t7519-status-fsmonitor.sh | 603 ++++++++++++++++++++++++++++++++++++ wt-status.c | 247 +++++++++++++-- wt-status.h | 8 + 10 files changed, 1518 insertions(+), 63 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 54e41c8ba578c1..e2f4d08b347707 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1628,9 +1628,10 @@ struct repository *repo UNUSED) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); wt_status_start_untracked_cache_preload(&s); - refresh_index(the_repository->index, - REFRESH_QUIET|REFRESH_UNMERGED|progress_flag, - &s.pathspec, NULL, NULL); + wt_status_refresh_index( + &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag, + s.show_untracked_files != SHOW_NO_UNTRACKED_FILES && + !s.show_ignored_mode); if (use_optional_locks()) fd = repo_hold_locked_index(the_repository, &index_lock, 0); diff --git a/dir.c b/dir.c index 940f6c744ecc7e..b2a4e4b2e5adc4 100644 --- a/dir.c +++ b/dir.c @@ -28,6 +28,7 @@ #include "varint.h" #include "ewah/ewok.h" #include "fsmonitor-ll.h" +#include "fsmonitor.h" #include "read-cache-ll.h" #include "setup.h" #include "sparse-index.h" @@ -79,10 +80,16 @@ struct untracked_cache_preload_task { char *path; struct stat_data stat_data; struct object_id exclude_oid; + unsigned int exclude_mode; unsigned int was_valid : 1; unsigned int stat_checked : 1; unsigned int stat_matches : 1; unsigned int exclude_matches : 1; + unsigned int exclude_index_present : 1; + unsigned int exclude_index_candidate : 1; + unsigned int exclude_index_matches : 1; + unsigned int exclude_index_content_matches : 1; + unsigned int normalize_exclude_oid : 1; unsigned int update_stat_data : 1; }; @@ -97,9 +104,11 @@ struct untracked_cache_preload_data { struct untracked_cache_preload { struct repository *repo; + struct index_state *istate; struct untracked_cache *uc; struct untracked_cache_dir *root; struct untracked_cache_preload_task *tasks; + struct object_id *exclude_index_oids; struct untracked_cache_preload_data *data; struct cache_time index_timestamp; char *exclude_per_dir; @@ -107,10 +116,12 @@ struct untracked_cache_preload { int threads; unsigned int dir_flags; uint64_t started_at; + unsigned int fsmonitor_excludes_only : 1; }; #define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 #define UNTRACKED_CACHE_PRELOAD_COST 1000 +#define UNTRACKED_CACHE_EXCLUDE_PRELOAD_COST 256 #define UNTRACKED_CACHE_MAX_EXCLUDE_SIZE (1024 * 1024) static void invalidate_gitignore(struct untracked_cache *uc, @@ -128,18 +139,22 @@ static void collect_untracked_cache_preload_tasks( struct strbuf *path, struct untracked_cache_preload_task **tasks, size_t *nr, - size_t *alloc) + size_t *alloc, + int fsmonitor_excludes_only) { size_t i; - ALLOC_GROW(*tasks, *nr + 1, *alloc); - memset(&(*tasks)[*nr], 0, sizeof(**tasks)); - (*tasks)[*nr].ucd = ucd; - (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); - (*tasks)[*nr].stat_data = ucd->stat_data; - oidcpy(&(*tasks)[*nr].exclude_oid, &ucd->exclude_oid); - (*tasks)[*nr].was_valid = ucd->valid; - (*nr)++; + if (!fsmonitor_excludes_only || + !is_null_oid(&ucd->exclude_oid)) { + ALLOC_GROW(*tasks, *nr + 1, *alloc); + memset(&(*tasks)[*nr], 0, sizeof(**tasks)); + (*tasks)[*nr].ucd = ucd; + (*tasks)[*nr].path = xstrdup(path->len ? path->buf : "."); + (*tasks)[*nr].stat_data = ucd->stat_data; + oidcpy(&(*tasks)[*nr].exclude_oid, &ucd->exclude_oid); + (*tasks)[*nr].was_valid = ucd->valid; + (*nr)++; + } for (i = 0; i < ucd->dirs_nr; i++) { struct untracked_cache_dir *child = ucd->dirs[i]; @@ -149,7 +164,7 @@ static void collect_untracked_cache_preload_tasks( strbuf_addch(path, '/'); strbuf_addstr(path, child->name); collect_untracked_cache_preload_tasks(child, path, tasks, nr, - alloc); + alloc, fsmonitor_excludes_only); strbuf_setlen(path, old_len); } } @@ -194,7 +209,8 @@ static int exclude_path_matches_fd(const char *path, static int cached_exclude_file_matches( const struct git_hash_algo *algo, - const char *path, const struct object_id *cached_oid) + const char *path, const struct object_id *cached_oid, + struct object_id *raw_oid_out, unsigned int *mode_out) { struct object_id raw_oid, normalized_oid; struct stat st, st_after; @@ -218,9 +234,13 @@ static int cached_exclude_file_matches( !path_namespace_stat_equal(&st, &st_after) || !exclude_path_matches_fd(path, &st_after)) goto out; + if (mode_out) + *mode_out = st_after.st_mode; /* add_patterns() may record either the blob or its LF-normalized form. */ hash_object_file(algo, buf, size, OBJ_BLOB, &raw_oid); + if (raw_oid_out) + oidcpy(raw_oid_out, &raw_oid); if (oideq(&raw_oid, cached_oid)) { ret = 1; goto out; @@ -236,8 +256,87 @@ static int cached_exclude_file_matches( return ret; } +static int cached_exclude_file_matches_index_stat( + const struct stat_data *sd, + const struct stat *st) +{ + struct stat_data current; + struct stat st_copy = *st; + + /* + * Compare every field saved in the index, independent of the user's + * ordinary stat-match settings. Unreliable object identities and + * multiply-linked files can conceal changes through paths outside + * the monitor's watch cone, so both retain the content-hash check. + */ + if (!fstat_is_reliable() || !S_ISREG(st->st_mode) || + st->st_nlink != 1) + return 0; + fill_stat_data(¤t, &st_copy); + return sd->sd_ctime.sec == current.sd_ctime.sec && + sd->sd_ctime.nsec == current.sd_ctime.nsec && + sd->sd_mtime.sec == current.sd_mtime.sec && + sd->sd_mtime.nsec == current.sd_mtime.nsec && + sd->sd_dev == current.sd_dev && + sd->sd_ino == current.sd_ino && + sd->sd_uid == current.sd_uid && + sd->sd_gid == current.sd_gid && + sd->sd_size == current.sd_size; +} + +static void preload_fsmonitor_excludes_from_index( + struct untracked_cache_preload *preload) +{ + struct repo_config_values *cfg = + repo_config_values(preload->istate->repo); + size_t i; + int stat_candidates = cfg->trust_ctime && cfg->check_stat; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = &preload->tasks[i]; + struct strbuf exclude_path = STRBUF_INIT; + struct cache_entry *ce; + int pos; + + if (!preload->exclude_per_dir) + continue; + if (strcmp(task->path, ".")) + strbuf_addstr(&exclude_path, task->path); + if (exclude_path.len) + strbuf_addch(&exclude_path, '/'); + strbuf_addstr(&exclude_path, preload->exclude_per_dir); + pos = index_name_pos_sparse( + preload->istate, exclude_path.buf, + exclude_path.len); + if (pos < 0) + goto next; + ce = preload->istate->cache[pos]; + if (!S_ISREG(ce->ce_mode) || + !(ce->ce_flags & CE_FSMONITOR_VALID) || + ce_skip_worktree(ce) || + (ce->ce_flags & CE_REMOVE) || + ce_intent_to_add(ce)) + goto next; + oidcpy(&preload->exclude_index_oids[i], &ce->oid); + task->exclude_index_present = 1; + if (!stat_candidates || + is_racy_timestamp(preload->istate, ce) || + (ce->ce_flags & CE_VALID)) + goto next; + /* + * Snapshot before launching workers. The main thread may + * refresh cache entries while exclude checks run. + */ + task->stat_data = ce->ce_stat_data; + task->exclude_index_candidate = 1; +next: + strbuf_release(&exclude_path); + } +} + static struct untracked_cache_preload *untracked_cache_preload_start_1( - struct index_state *istate, unsigned int dir_flags, int automatic) + struct index_state *istate, unsigned int dir_flags, int automatic, + int fsmonitor_excludes_only) { struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; @@ -246,22 +345,35 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( unsigned long test_threads; int threads, online, create_threads = 1; - if (!uc || !uc->root || uc->use_fsmonitor || - uc->dir_flags != dir_flags) + if (!uc || !uc->root || uc->dir_flags != dir_flags || + (fsmonitor_excludes_only ? + !uc->use_fsmonitor : + uc->use_fsmonitor)) return NULL; CALLOC_ARRAY(preload, 1); preload->repo = istate->repo; + preload->istate = istate; preload->uc = uc; preload->root = uc->root; preload->index_timestamp = istate->timestamp; preload->exclude_per_dir = xstrdup_or_null(uc->exclude_per_dir); preload->dir_flags = dir_flags; + preload->fsmonitor_excludes_only = fsmonitor_excludes_only; collect_untracked_cache_preload_tasks( - uc->root, &path, &preload->tasks, &preload->nr, &alloc); + uc->root, &path, &preload->tasks, &preload->nr, &alloc, + fsmonitor_excludes_only); strbuf_release(&path); + if (fsmonitor_excludes_only) { + CALLOC_ARRAY(preload->exclude_index_oids, preload->nr); + preload_fsmonitor_excludes_from_index(preload); + } - threads = HAVE_THREADS ? preload->nr / UNTRACKED_CACHE_PRELOAD_COST : 1; + threads = HAVE_THREADS ? + preload->nr / (fsmonitor_excludes_only ? + UNTRACKED_CACHE_EXCLUDE_PRELOAD_COST : + UNTRACKED_CACHE_PRELOAD_COST) : + 1; online = HAVE_THREADS ? online_cpus() : 1; if (threads > online * 3) threads = online * 3; @@ -273,7 +385,7 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( UNTRACKED_CACHE_MAX_OVERLAP_THREADS : test_threads; if (threads < 1) threads = 1; - if ((size_t)threads > preload->nr) + if (preload->nr && (size_t)threads > preload->nr) threads = preload->nr; preload->threads = threads; @@ -282,6 +394,9 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( "preload_untracked_cache/threads", threads); trace2_data_intmax("dir", istate->repo, "preload_untracked_cache/automatic", automatic); + trace2_data_intmax("dir", istate->repo, + "preload_untracked_cache/fsmonitor-excludes-only", + fsmonitor_excludes_only); CALLOC_ARRAY(preload->data, threads); work = DIV_ROUND_UP(preload->nr, threads); for (i = 0; i < threads; i++) { @@ -310,6 +425,14 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( return preload; } +struct untracked_cache_preload * +untracked_cache_preload_start_fsmonitor_excludes( + struct index_state *istate, unsigned int dir_flags) +{ + return untracked_cache_preload_start_1( + istate, dir_flags, 0, 1); +} + struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *istate, unsigned int dir_flags) { @@ -318,7 +441,7 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( if (!uc || uc->dir_flags != dir_flags || !untracked_cache_auto_preload_worthwhile(uc)) return NULL; - return untracked_cache_preload_start_1(istate, dir_flags, 1); + return untracked_cache_preload_start_1(istate, dir_flags, 1, 0); } static void *preload_untracked_cache_thread(void *_data) @@ -332,6 +455,47 @@ static void *preload_untracked_cache_thread(void *_data) struct strbuf exclude_path = STRBUF_INIT; struct stat st; + if (preload->fsmonitor_excludes_only) { + struct object_id raw_oid; + + if (!preload->exclude_per_dir) + continue; + if (strcmp(task->path, ".")) + strbuf_addstr(&exclude_path, task->path); + if (exclude_path.len) + strbuf_addch(&exclude_path, '/'); + strbuf_addstr(&exclude_path, + preload->exclude_per_dir); + if (task->exclude_index_candidate && + oideq(&preload->exclude_index_oids[i], + &task->exclude_oid) && + !lstat(exclude_path.buf, &st) && + cached_exclude_file_matches_index_stat( + &task->stat_data, &st)) { + task->exclude_mode = st.st_mode; + task->exclude_index_matches = 1; + task->exclude_index_content_matches = 1; + task->exclude_matches = 1; + strbuf_release(&exclude_path); + continue; + } + task->exclude_matches = cached_exclude_file_matches( + preload->repo->hash_algo, + exclude_path.buf, + &task->exclude_oid, &raw_oid, + &task->exclude_mode); + if (task->exclude_matches && + task->exclude_index_present && + oideq(&preload->exclude_index_oids[i], + &raw_oid)) { + task->exclude_index_content_matches = 1; + if (!oideq(&preload->exclude_index_oids[i], + &task->exclude_oid)) + task->normalize_exclude_oid = 1; + } + strbuf_release(&exclude_path); + continue; + } if (!task->was_valid) continue; task->stat_checked = 1; @@ -361,7 +525,7 @@ static void *preload_untracked_cache_thread(void *_data) strbuf_addstr(&exclude_path, preload->exclude_per_dir); task->exclude_matches = cached_exclude_file_matches( preload->repo->hash_algo, exclude_path.buf, - &task->exclude_oid); + &task->exclude_oid, NULL, NULL); strbuf_release(&exclude_path); } return NULL; @@ -415,6 +579,33 @@ static int compute_untracked_cache_valid_recursive( return valid; } +static int compute_untracked_cache_disk_valid_recursive( + struct untracked_cache_dir *ucd) +{ + size_t i; + int valid = ucd->valid && is_null_oid(&ucd->exclude_oid); + + for (i = 0; i < ucd->dirs_nr; i++) + if (!compute_untracked_cache_disk_valid_recursive(ucd->dirs[i])) + valid = 0; + ucd->valid_recursive = valid; + return valid; +} + +static int compute_untracked_cache_fsmonitor_valid_recursive( + struct untracked_cache_dir *ucd) +{ + size_t i; + int valid = ucd->valid; + + for (i = 0; i < ucd->dirs_nr; i++) + if (!compute_untracked_cache_fsmonitor_valid_recursive( + ucd->dirs[i])) + valid = 0; + ucd->valid_recursive = valid; + return valid; +} + static void untracked_cache_preload_join( struct untracked_cache_preload *preload) { @@ -448,25 +639,219 @@ static void untracked_cache_preload_free( for (i = 0; i < preload->nr; i++) free(preload->tasks[i].path); free(preload->tasks); + free(preload->exclude_index_oids); free(preload->exclude_per_dir); free(preload); } +static int converted_exclude_matches_cache_and_index( + struct untracked_cache_preload *preload, + struct untracked_cache_preload_task *task, + struct cache_entry *ce, + const char *path) +{ + struct object_id converted_oid, normalized_oid, raw_oid; + struct stat before, after; + char *buf = NULL; + size_t size; + int converted_fd, fd = -1; + int cached_matches, ret = 0; + + fd = open_nofollow(path, O_RDONLY); + if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || + before.st_size < 0 || + before.st_size > UNTRACKED_CACHE_MAX_EXCLUDE_SIZE) + goto done; + size = xsize_t(before.st_size); + buf = xmallocz(size + 1); + if (read_in_full(fd, buf, size) != size) + goto done; + + hash_object_file(preload->repo->hash_algo, buf, size, OBJ_BLOB, + &raw_oid); + cached_matches = oideq(&raw_oid, &task->exclude_oid); + if (!cached_matches) { + buf[size] = '\n'; + hash_object_file(preload->repo->hash_algo, buf, size + 1, + OBJ_BLOB, &normalized_oid); + cached_matches = oideq(&normalized_oid, + &task->exclude_oid); + } + if (!cached_matches || lseek(fd, 0, SEEK_SET) < 0) + goto done; + + converted_fd = xdup(fd); + if (index_fd(preload->istate, &converted_oid, converted_fd, &before, + OBJ_BLOB, path, 0) || + !oideq(&converted_oid, &ce->oid)) + goto done; + + /* + * Keep the descriptor open while computing both identities, then + * prove that neither the opened file nor its pathname changed. + */ + if (fstat(fd, &after) || + !path_namespace_stat_equal(&before, &after) || + !exclude_path_matches_fd(path, &after)) + goto done; + fill_stat_data(&task->stat_data, &after); + task->exclude_mode = after.st_mode; + ret = 1; +done: + free(buf); + if (fd >= 0) + close(fd); + return ret; +} + +static int update_preloaded_exclude_index_uptodate( + struct untracked_cache_preload *preload, + struct untracked_cache_preload_task *task, + size_t task_nr, + size_t *normalized, + size_t *index_invalidated, + int *exclude_revalidated) +{ + struct strbuf path = STRBUF_INIT; + struct cache_entry *ce; + int content_matches, converts, pos, marked = 0; + + *exclude_revalidated = -1; + if (!task->exclude_index_present || !preload->exclude_per_dir) + return 0; + if (strcmp(task->path, ".")) + strbuf_addstr(&path, task->path); + if (path.len) + strbuf_addch(&path, '/'); + strbuf_addstr(&path, preload->exclude_per_dir); + pos = index_name_pos_sparse(preload->istate, path.buf, path.len); + if (pos < 0) + goto done; + ce = preload->istate->cache[pos]; + if (!ce_stage(ce) && S_ISREG(ce->ce_mode) && + oideq(&ce->oid, &preload->exclude_index_oids[task_nr])) { + if (task->exclude_index_matches) { + converts = 0; + content_matches = 1; + } else { + converts = would_convert_to_git( + preload->istate, path.buf); + content_matches = converts ? + converted_exclude_matches_cache_and_index( + preload, task, ce, path.buf) : + task->exclude_index_content_matches; + if (converts) + *exclude_revalidated = content_matches; + } + if (!converts && task->normalize_exclude_oid) { + oidcpy(&task->ucd->exclude_oid, + &preload->exclude_index_oids[task_nr]); + (*normalized)++; + } + if (content_matches && + (ce->ce_flags & CE_FSMONITOR_VALID) && + !ce_skip_worktree(ce) && + !(ce->ce_flags & CE_REMOVE) && + !ce_intent_to_add(ce) && + (!repo_trust_executable_bit(preload->istate->repo) || + !((ce->ce_mode ^ task->exclude_mode) & 0100))) { + if (converts && + memcmp(&ce->ce_stat_data, &task->stat_data, + sizeof(ce->ce_stat_data))) { + ce->ce_stat_data = task->stat_data; + ce->ce_flags |= CE_UPDATE_IN_BASE; + preload->istate->cache_changed |= + CE_ENTRY_CHANGED; + } + ce_mark_uptodate(ce); + marked = 1; + } else { + fsmonitor_invalidate_cache_entry(ce); + preload->istate->cache_changed |= FSMONITOR_CHANGED; + (*index_invalidated)++; + } + } +done: + strbuf_release(&path); + return marked; +} + int untracked_cache_preload_finish(struct untracked_cache_preload *preload, - struct index_state *istate, - unsigned int dir_flags) + struct index_state *istate, + unsigned int dir_flags, + size_t *index_invalidated) { struct untracked_cache *uc; size_t i; int applied = 0; + if (index_invalidated) + *index_invalidated = 0; if (!preload) return 0; untracked_cache_preload_join(preload); uc = istate->untracked; if (uc != preload->uc || !uc || uc->root != preload->root || - dir_flags != preload->dir_flags) + dir_flags != preload->dir_flags || + (preload->fsmonitor_excludes_only && !uc->use_fsmonitor)) + goto done; + + if (preload->fsmonitor_excludes_only) { + size_t index_matches = 0; + size_t invalidated = 0; + size_t index_uptodate = 0; + size_t normalized = 0; + + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = + &preload->tasks[i]; + int exclude_matches = + oideq(&task->exclude_oid, + &task->ucd->exclude_oid) && + task->exclude_matches; + int exclude_revalidated; + + if (!exclude_matches) + invalidate_gitignore(uc, task->ucd); + else { + if (task->exclude_index_matches) + index_matches++; + } + index_uptodate += + update_preloaded_exclude_index_uptodate( + preload, task, i, &normalized, + &invalidated, &exclude_revalidated); + if (exclude_matches && exclude_revalidated == 0) + invalidate_gitignore(uc, task->ucd); + } + if (normalized) + istate->cache_changed |= UNTRACKED_CHANGED; + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/index-excludes", + index_matches); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/index-uptodate", + index_uptodate); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/index-invalidated", + invalidated); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/normalized-excludes", + normalized); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/valid", + compute_untracked_cache_fsmonitor_valid_recursive( + preload->root)); + if (index_invalidated) + *index_invalidated = invalidated; + applied = 1; goto done; + } for (i = 0; i < preload->nr; i++) { struct untracked_cache_preload_task *task = &preload->tasks[i]; @@ -1684,6 +2069,10 @@ static int add_patterns(const char *fname, const char *base, int baselen, (pos = index_name_pos(istate, fname, strlen(fname))) >= 0 && !ce_stage(istate->cache[pos]) && ce_uptodate(istate->cache[pos]) && + !(istate->cache[pos]->ce_flags & + (CE_VALID | CE_REMOVE)) && + !ce_skip_worktree(istate->cache[pos]) && + !ce_intent_to_add(istate->cache[pos]) && !would_convert_to_git(istate, fname)) oidcpy(&oid_stat->oid, &istate->cache[pos]->oid); @@ -2253,9 +2642,24 @@ static void prep_exclude(struct dir_struct *dir, strbuf_addbuf(&sb, &dir->internal.basebuf); strbuf_addstr(&sb, dir->exclude_per_dir); pl->src = strbuf_detach(&sb, NULL); - add_patterns(pl->src, pl->src, stk->baselen, pl, istate, - PATTERN_NOFOLLOW, - untracked ? &oid_stat : NULL); + if (add_patterns(pl->src, pl->src, stk->baselen, pl, + istate, PATTERN_NOFOLLOW, + untracked ? &oid_stat : NULL) < 0 && + untracked && is_null_oid(&oid_stat.oid)) { + struct stat st; + + /* + * Keep a non-blob sentinel for a source that is + * present but unreadable. Otherwise a valid + * untracked-cache directory cannot distinguish + * that state from an absent per-directory + * exclude file. + */ + if (!lstat(pl->src, &st) || + !is_missing_file_error(errno)) + oidcpy(&oid_stat.oid, + the_hash_algo->empty_tree); + } } /* * NEEDSWORK: when untracked cache is enabled, prep_exclude() @@ -3234,7 +3638,9 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct strbuf path = STRBUF_INIT; strbuf_add(&path, base, baselen); - if (untracked && dir->internal.untracked_cache_preloaded && + if (untracked && + (dir->internal.untracked_cache_preloaded || + dir->untracked->use_fsmonitor) && untracked->valid && untracked->valid_recursive && untracked->check_only == !!check_only && !untracked->has_untracked && @@ -3428,6 +3834,8 @@ static int treat_leading_path(struct dir_struct *dir, return state == path_recurse; } +#define UNTRACKED_CACHE_IDENT_VERSION 2 + static const char *get_ident_string(void) { static struct strbuf sb = STRBUF_INIT; @@ -3437,8 +3845,9 @@ static const char *get_ident_string(void) return sb.buf; if (uname(&uts) < 0) die_errno(_("failed to get kernel name and information")); - strbuf_addf(&sb, "Location %s, system %s", repo_get_work_tree(the_repository), - uts.sysname); + strbuf_addf(&sb, "Location %s, system %s, cache version %d", + repo_get_work_tree(the_repository), uts.sysname, + UNTRACKED_CACHE_IDENT_VERSION); return sb.buf; } @@ -4502,6 +4911,8 @@ struct untracked_cache *read_untracked_extension(const void *data, unsigned long ewah_each_bit(rd.valid, read_stat, &rd); ewah_each_bit(rd.sha1_valid, read_oid, &rd); next = rd.data; + if (next == end) + compute_untracked_cache_disk_valid_recursive(uc->root); done: free(rd.ucd); diff --git a/dir.h b/dir.h index 198d7c846f8937..f6df0b54d271e9 100644 --- a/dir.h +++ b/dir.h @@ -189,7 +189,10 @@ struct untracked_cache_dir { unsigned int stat_matches : 1; unsigned int exclude_matches : 1; unsigned int valid_recursive : 1; - /* null object ID means this directory does not have .gitignore */ + /* + * A null object ID means this directory does not have .gitignore. + * The empty-tree ID records a present source that could not be read. + */ struct object_id exclude_oid; char name[FLEX_ARRAY]; }; @@ -617,10 +620,14 @@ void untracked_cache_remove_from_index(struct index_state *, const char *); void untracked_cache_add_to_index(struct index_state *, const char *); struct untracked_cache_preload; +struct untracked_cache_preload * +untracked_cache_preload_start_fsmonitor_excludes( + struct index_state *, unsigned int dir_flags); struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *, unsigned int dir_flags); int untracked_cache_preload_finish(struct untracked_cache_preload *, - struct index_state *, unsigned int dir_flags); + struct index_state *, unsigned int dir_flags, + size_t *index_invalidated); void untracked_cache_preload_release(struct untracked_cache_preload *); void free_untracked_cache(struct untracked_cache *); diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 8591a166665bd5..7e7564e5e2c493 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -7,6 +7,14 @@ struct strbuf; /* A provider-only marker; worktree-relative paths cannot begin with '/'. */ #define FSMONITOR_PATH_GLOBAL_INVALIDATE "//" +enum fsmonitor_token_result { + FSMONITOR_TOKEN_NOT_PENDING = 0, + FSMONITOR_TOKEN_CLEAN, + FSMONITOR_TOKEN_CHANGED, + FSMONITOR_TOKEN_TRIVIAL, + FSMONITOR_TOKEN_ERROR, +}; + extern struct trace_key trace_fsmonitor; /* @@ -55,6 +63,16 @@ void refresh_fsmonitor(struct index_state *istate); int fsmonitor_invalidate_attributes_path(struct index_state *istate, const char *name); + +/* Close a provider token which was obtained before a required scan. */ +int fsmonitor_has_pending_token(const struct index_state *istate); +int fsmonitor_pending_token_from_provider(const struct index_state *istate); +enum fsmonitor_token_result fsmonitor_query_pending_token( + struct index_state *istate, int untracked_ready); +void fsmonitor_accept_pending_token(struct index_state *istate); +void fsmonitor_reject_pending_token(struct index_state *istate); +void fsmonitor_mark_untracked_cache_valid(struct index_state *istate); + /* * Does the received result contain the "trivial" response? */ diff --git a/fsmonitor.c b/fsmonitor.c index dea229e7a8597a..94ccbceba7c307 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -807,8 +807,44 @@ enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( static enum fsmonitor_query_outcome query_builtin_fsmonitor( const char *since_token, struct fsmonitor_query_result *result) { + const char *test_sequence = + getenv("GIT_TEST_FSMONITOR_QUERY_SEQUENCE"); struct strbuf raw = STRBUF_INIT; + /* + * Tests may script clean, delta, trivial, and error responses with + * C, D, T, and E. A delta uses GIT_TEST_FSMONITOR_QUERY_PATH. + */ + if (test_sequence && *test_sequence) { + static size_t query_nr; + const char *path; + char outcome; + + if (query_nr >= strlen(test_sequence)) + return FSMONITOR_QUERY_ERROR; + outcome = test_sequence[query_nr++]; + if (outcome == 'E') + return FSMONITOR_QUERY_ERROR; + + strbuf_addf(&result->token, "builtin:test:%"PRIuMAX, + (uintmax_t)query_nr); + if (outcome == 'T') { + result->outcome = FSMONITOR_QUERY_TRIVIAL; + return result->outcome; + } + if (outcome == 'D') { + path = getenv("GIT_TEST_FSMONITOR_QUERY_PATH"); + if (!path || !*path) + return FSMONITOR_QUERY_ERROR; + strbuf_addstr(&result->paths, path); + strbuf_addch(&result->paths, '\0'); + } else if (outcome != 'C') { + return FSMONITOR_QUERY_ERROR; + } + result->outcome = FSMONITOR_QUERY_DELTA; + return result->outcome; + } + if (!fsmonitor_ipc__send_query(since_token, &raw)) fsmonitor_parse_builtin_response(&raw, result); strbuf_release(&raw); @@ -849,6 +885,15 @@ static void invalidate_all_fsmonitor(struct index_state *istate) istate->cache_changed |= FSMONITOR_CHANGED; } +static void invalidate_all_fsmonitor_strong(struct index_state *istate) +{ + unsigned int i; + + invalidate_all_fsmonitor(istate); + for (i = 0; i < istate->cache_nr; i++) + fsmonitor_invalidate_cache_entry(istate->cache[i]); +} + void refresh_fsmonitor(struct index_state *istate) { static int warn_once = 0; @@ -860,6 +905,8 @@ void refresh_fsmonitor(struct index_state *istate) char *buf; unsigned int i; int is_trivial = 0; + int tracked_requires_bootstrap; + int untracked_requires_bootstrap; struct repository *r = istate->repo; enum fsmonitor_mode fsm_mode = fsm_settings__get_mode(r); enum fsmonitor_reason reason = fsm_settings__get_reason(r); @@ -1000,6 +1047,10 @@ void refresh_fsmonitor(struct index_state *istate) */ trace2_region_enter("fsmonitor", "apply_results", istate->repo); + tracked_requires_bootstrap = !query_success || is_trivial || + !istate->fsmonitor_token_valid; + untracked_requires_bootstrap = !istate->fsmonitor_untracked_valid; + if (query_success && !is_trivial) { /* * Mark all pathnames returned by the monitor as dirty. @@ -1027,9 +1078,14 @@ void refresh_fsmonitor(struct index_state *istate) } } + if (tracked_requires_bootstrap) + invalidate_all_fsmonitor(istate); + /* Now mark the untracked cache for fsmonitor usage */ if (istate->untracked) - istate->untracked->use_fsmonitor = 1; + istate->untracked->use_fsmonitor = + !tracked_requires_bootstrap && + !untracked_requires_bootstrap; if (count > fsmonitor_force_update_threshold) istate->cache_changed |= FSMONITOR_CHANGED; @@ -1052,9 +1108,152 @@ void refresh_fsmonitor(struct index_state *istate) strbuf_release(&query_result); - /* Now that we've updated istate, save the last_update_token */ + /* + * A token obtained before a full scan cannot describe changes which + * race with that scan. Keep it in memory until the caller closes the + * race with a second query. The last valid token remains safe because + * a query relative to it will return a superset of changes. + */ + if (tracked_requires_bootstrap) { + if (!last_update_token.len) { + if (istate->fsmonitor_last_update) + strbuf_addstr(&last_update_token, + istate->fsmonitor_last_update); + else + strbuf_addstr(&last_update_token, "builtin:fake"); + } + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_last_update_pending = + strbuf_detach(&last_update_token, NULL); + /* + * A trivial response cannot validate prior state, but its + * returned token is still a provider-owned boundary. Use it + * to anchor the complete scan which the caller will close with + * another query. Hook providers cannot perform that closing + * query, so do not publish their trivial-response tokens. + */ + istate->fsmonitor_pending_token_from_provider = + query_success && + (fsm_mode == FSMONITOR_MODE_IPC || !is_trivial); + istate->fsmonitor_untracked_valid = 0; + } else { + FREE_AND_NULL(istate->fsmonitor_last_update); + istate->fsmonitor_last_update = + strbuf_detach(&last_update_token, NULL); + if (untracked_requires_bootstrap) { + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_last_update_pending = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_pending_token_from_provider = 1; + } else { + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + } + if (istate->fsmonitor_untracked_valid && istate->untracked) { + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + } + } +} + +int fsmonitor_has_pending_token(const struct index_state *istate) +{ + return !!istate->fsmonitor_last_update_pending; +} + +int fsmonitor_pending_token_from_provider(const struct index_state *istate) +{ + return istate->fsmonitor_last_update_pending && + istate->fsmonitor_pending_token_from_provider; +} + +enum fsmonitor_token_result fsmonitor_query_pending_token( + struct index_state *istate, int untracked_ready) +{ + struct fsmonitor_query_result result = FSMONITOR_QUERY_RESULT_INIT; + enum fsmonitor_token_result ret; + int count; + + if (!istate->fsmonitor_last_update_pending) + return FSMONITOR_TOKEN_NOT_PENDING; + if (fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) + return FSMONITOR_TOKEN_ERROR; + + query_builtin_fsmonitor(istate->fsmonitor_last_update_pending, &result); + if (result.outcome == FSMONITOR_QUERY_ERROR) { + istate->fsmonitor_pending_token_from_provider = 0; + ret = FSMONITOR_TOKEN_ERROR; + goto done; + } + + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_last_update_pending = + strbuf_detach(&result.token, NULL); + istate->fsmonitor_pending_token_from_provider = 1; + if (result.outcome == FSMONITOR_QUERY_TRIVIAL) { + invalidate_all_fsmonitor_strong(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/trivial", 1); + ret = FSMONITOR_TOKEN_TRIVIAL; + goto done; + } + + count = apply_fsmonitor_paths(istate, &result.paths); + if (istate->untracked) + istate->untracked->use_fsmonitor = !!untracked_ready; + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/apply_count", count); + ret = count ? FSMONITOR_TOKEN_CHANGED : FSMONITOR_TOKEN_CLEAN; + +done: + fsmonitor_query_result_release(&result); + return ret; +} + +void fsmonitor_accept_pending_token(struct index_state *istate) +{ + if (!fsmonitor_pending_token_from_provider(istate)) + return; FREE_AND_NULL(istate->fsmonitor_last_update); - istate->fsmonitor_last_update = strbuf_detach(&last_update_token, NULL); + istate->fsmonitor_last_update = istate->fsmonitor_last_update_pending; + istate->fsmonitor_last_update_pending = NULL; + istate->fsmonitor_pending_token_from_provider = 0; + istate->fsmonitor_token_valid = 1; + istate->fsmonitor_untracked_valid = 1; + if (istate->untracked) + istate->untracked->use_fsmonitor = 1; + istate->cache_changed |= FSMONITOR_CHANGED; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/accepted", 1); +} + +void fsmonitor_reject_pending_token(struct index_state *istate) +{ + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + if (!istate->fsmonitor_token_valid) + FREE_AND_NULL(istate->fsmonitor_last_update); + invalidate_all_fsmonitor_strong(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "token_closure/rejected", 1); +} + +void fsmonitor_mark_untracked_cache_valid(struct index_state *istate) +{ + if (istate->fsmonitor_last_update_pending || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || !istate->untracked || + istate->fsmonitor_untracked_valid) + return; + istate->fsmonitor_untracked_valid = 1; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + istate->cache_changed |= FSMONITOR_CHANGED; } /* @@ -1086,6 +1285,7 @@ static void initialize_fsmonitor_last_update(struct index_state *istate) strbuf_addf(&last_update, "%"PRIu64"", getnanotime()); istate->fsmonitor_last_update = strbuf_detach(&last_update, NULL); + istate->fsmonitor_token_valid = 0; } void add_fsmonitor(struct index_state *istate) @@ -1104,7 +1304,7 @@ void add_fsmonitor(struct index_state *istate) /* reset the untracked cache */ if (istate->untracked) { add_untracked_cache(istate); - istate->untracked->use_fsmonitor = 1; + istate->untracked->use_fsmonitor = 0; } /* Update the fsmonitor state */ @@ -1114,6 +1314,13 @@ void add_fsmonitor(struct index_state *istate) void remove_fsmonitor(struct index_state *istate) { + istate->fsmonitor_token_valid = 0; + istate->fsmonitor_untracked_valid = 0; + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + if (istate->untracked) + istate->untracked->use_fsmonitor = 0; if (istate->fsmonitor_last_update) { trace_printf_key(&trace_fsmonitor, "remove fsmonitor"); istate->cache_changed |= FSMONITOR_CHANGED; diff --git a/read-cache-ll.h b/read-cache-ll.h index db5b13d06b946c..d44a946ed04d36 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -187,13 +187,15 @@ struct index_state { fsmonitor_extension_seen : 1, fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, - fsmonitor_untracked_extension_invalid : 1; + fsmonitor_untracked_extension_invalid : 1, + fsmonitor_pending_token_from_provider : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; struct object_id oid; struct untracked_cache *untracked; char *fsmonitor_last_update; + char *fsmonitor_last_update_pending; char *fsmonitor_untracked_token; struct ewah_bitmap *fsmonitor_dirty; struct mem_pool *ce_mem_pool; diff --git a/read-cache.c b/read-cache.c index 57cc73dec90a0e..cf3600f2a337d6 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2471,6 +2471,7 @@ void release_index(struct index_state *istate) free_name_hash(istate); cache_tree_free(&(istate->cache_tree)); free(istate->fsmonitor_last_update); + free(istate->fsmonitor_last_update_pending); free(istate->fsmonitor_untracked_token); free(istate->cache); discard_split_index(istate); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index e8cc70c428b181..6b1fdd3bcbbc6f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -88,6 +88,609 @@ test_expect_success 'hook parser ignores empty path records' ' ) ' +test_expect_success UNTRACKED_CACHE 'trivial hook clears a paired UNTR token' ' + test_when_finished "rm -rf hook-token-pair" && + test_create_repo hook-token-pair && + ( + cd hook-token-pair && + test_commit base tracked && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token1\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + test_grep ! FSUC .git/index && + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep FSUC .git/index && + test_hook --clobber fsmonitor-test <<-\EOF && + printf "token2\0/\0" + EOF + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep ! FSUC .git/index + ) +' + +test_expect_success UNTRACKED_CACHE 'failed hook clears a paired UNTR token' ' + test_when_finished "rm -rf hook-token-error" && + test_create_repo hook-token-error && + ( + cd hook-token-error && + test_commit base tracked && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 >/dev/null && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token1\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep FSUC .git/index && + test_hook --clobber fsmonitor-test <<-\EOF && + exit 1 + EOF + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep ! FSUC .git/index + ) +' + +test_expect_success UNTRACKED_CACHE \ + 'paired fsmonitor cache prunes recursively valid empty subtrees' ' + test_when_finished "rm -rf fsmonitor-untracked-prune" && + test_create_repo fsmonitor-untracked-prune && + ( + cd fsmonitor-untracked-prune && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/empty/deep && + test_write_lines tracked >cached/empty/deep/tracked && + git add cached/empty/deep/tracked && + git commit -m base && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + test_grep FSUC .git/index && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status --porcelain=v2 >.git/clean && + test_must_be_empty .git/clean && + test_trace2_data read_directory directories-visited 0 \ + <.git/clean.trace && + + test_write_lines untracked >cached/empty/deep/new && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/empty/deep/new \ + git status --porcelain=v2 >.git/changed && + test_grep "^? cached/empty/deep/new$" .git/changed + ) +' + +test_expect_success UNTRACKED_CACHE,HARDLINKS \ + 'fsmonitor pruning rechecks cached per-directory excludes' ' + test_when_finished "rm -rf fsmonitor-untracked-exclude" && + test_when_finished "rm -f fsmonitor-untracked-exclude-alias" && + test_create_repo fsmonitor-untracked-exclude && + ( + cd fsmonitor-untracked-exclude && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached cached2 cached3 && + test_write_lines ignored >cached/.gitignore && + test_write_lines hidden >cached/ignored && + test_write_lines ignored >cached2/.gitignore && + test_write_lines hidden >cached2/ignored && + test_write_lines ignored >cached3/.gitignore && + test_write_lines hidden >cached3/ignored && + git add cached/.gitignore cached2/.gitignore \ + cached3/.gitignore && + git commit -m base && + test-tool chmtime +60 cached2/.gitignore && + test-tool chmtime =-60 cached3/.gitignore && + git update-index --refresh && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + test_grep FSUC .git/index && + ln cached/.gitignore ../fsmonitor-untracked-exclude-alias && + + if test_have_prereq PTHREADS + then + threads=2 + else + threads=1 + fi && + if test_have_prereq MINGW || test_have_prereq CYGWIN + then + index_excludes=0 + else + index_excludes=1 + fi && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT_NESTING=10 \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status --porcelain >.git/clean && + test_must_be_empty .git/clean && + test_trace2_data dir \ + preload_untracked_cache/fsmonitor-excludes-only 1 \ + <.git/clean.trace && + test_trace2_data dir preload_untracked_cache/threads \ + $threads \ + <.git/clean.trace && + test_trace2_data dir preload_untracked_cache/dirs 3 \ + <.git/clean.trace && + test_trace2_data dir \ + preload_untracked_cache/index-excludes "$index_excludes" \ + <.git/clean.trace && + test_trace2_data read_directory directories-visited 0 \ + <.git/clean.trace && + + if test_have_prereq FILEMODE + then + chmod +x ../fsmonitor-untracked-exclude-alias && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/mode.trace" \ + git status --porcelain >.git/mode && + test_trace2_data dir \ + preload_untracked_cache/index-uptodate 2 \ + <.git/mode.trace && + chmod -x ../fsmonitor-untracked-exclude-alias + else + : + fi && + + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status >.git/changed && + test_grep "modified:.*cached/.gitignore" .git/changed && + test_grep "cached/ignored" .git/changed && + test_trace2_data status \ + fsmonitor/exclude-index-invalidated 1 \ + <.git/changed.trace && + + test_write_lines ignored \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/restored.trace" \ + git status >.git/restored && + test_grep "nothing to commit, working tree clean" \ + .git/restored && + + test_write_lines "?? cached/ignored" >.git/flagged.expect && + + git update-index --assume-unchanged cached/.gitignore && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/assume-prime && + test_must_be_empty .git/assume-prime && + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/assume-changed.trace" \ + git status --porcelain >.git/assume-changed && + test_cmp .git/flagged.expect .git/assume-changed && + test_write_lines ignored \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/assume-restored.trace" \ + git status --porcelain >.git/assume-restored && + test_must_be_empty .git/assume-restored && + git update-index --no-assume-unchanged cached/.gitignore && + + git update-index --skip-worktree cached/.gitignore && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/skip-prime && + test_must_be_empty .git/skip-prime && + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/skip-changed.trace" \ + git status --porcelain >.git/skip-changed && + test_cmp .git/flagged.expect .git/skip-changed && + test_write_lines ignored \ + >../fsmonitor-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=2 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/skip-restored.trace" \ + git status --porcelain >.git/skip-restored && + test_must_be_empty .git/skip-restored && + git update-index --no-skip-worktree cached/.gitignore + ) +' + +test_expect_success UNTRACKED_CACHE \ + 'converted cached excludes retain a stable index proof' ' + test_when_finished "rm -rf fsmonitor-converted-exclude" && + test_create_repo fsmonitor-converted-exclude && + ( + cd fsmonitor-converted-exclude && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + printf "ignored\r\n" >cached/.gitignore && + test_write_lines hidden >cached/ignored && + test_write_lines "cached/.gitignore text eol=lf" \ + >.gitattributes && + git add .gitattributes cached/.gitignore && + git commit -m base && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status >.git/settle && + test_grep "nothing to commit, working tree clean" \ + .git/settle && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status >.git/clean && + test_grep "nothing to commit, working tree clean" \ + .git/clean && + test_trace2_data dir \ + preload_untracked_cache/index-uptodate 1 \ + <.git/clean.trace && + test_trace2_data dir \ + preload_untracked_cache/index-invalidated 0 \ + <.git/clean.trace && + test_trace2_data dir \ + preload_untracked_cache/normalized-excludes 0 \ + <.git/clean.trace && + test_trace2_data read_directory directories-visited 0 \ + <.git/clean.trace + ) +' + +test_expect_success UNTRACKED_CACHE,HARDLINKS,POSIXPERM,SANITY \ + 'fsmonitor rechecks cached unreadable per-directory excludes' ' + test_when_finished "rm -rf fsmonitor-unreadable-exclude" && + test_when_finished "rm -f fsmonitor-unreadable-exclude-alias" && + test_create_repo fsmonitor-unreadable-exclude && + ( + cd fsmonitor-unreadable-exclude && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines hidden >cached/.gitignore && + test_write_lines untracked >cached/hidden && + test_write_lines tracked >cached/tracked && + git add cached/tracked && + git commit -m base && + chmod 000 cached/.gitignore && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain \ + >.git/prime 2>.git/prime.err && + test_grep "^?? cached/hidden$" .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/prime-fsmonitor \ + 2>.git/prime-fsmonitor.err && + test_grep "^?? cached/hidden$" .git/prime-fsmonitor && + empty_tree=$(git mktree .git/untracked-cache && + test_grep "cached/ $empty_tree" .git/untracked-cache && + ln cached/.gitignore \ + ../fsmonitor-unreadable-exclude-alias && + + chmod 644 ../fsmonitor-unreadable-exclude-alias && + test_write_lines "?? cached/.gitignore" \ + >.git/readable.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/readable.trace" \ + git status --porcelain >.git/readable && + test_cmp .git/readable.expect .git/readable && + test_trace2_data dir \ + preload_untracked_cache/dirs "[1-9]" \ + <.git/readable.trace + ) +' + +check_weak_exclude_stat () { + repo=$1 && + key=$2 && + value=$3 && + test_create_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines ignored >cached/.gitignore && + test_write_lines hidden >cached/ignored && + git add cached/.gitignore && + git commit -m base && + git config "$key" "$value" && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible >cached/.gitignore && + test-tool chmtime =$mtime cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain >.git/changed && + test_grep "^?? cached/ignored$" .git/changed && + test_trace2_data dir \ + preload_untracked_cache/index-excludes 0 \ + <.git/changed.trace + ) +} + +test_expect_success UNTRACKED_CACHE \ + 'weak stat settings retain exclude content checks' ' + test_when_finished "rm -rf weak-exclude-ctime weak-exclude-stat" && + check_weak_exclude_stat weak-exclude-ctime \ + core.trustctime false && + check_weak_exclude_stat weak-exclude-stat \ + core.checkStat minimal +' + +test_expect_success UNTRACKED_CACHE \ + 'root untracked events preserve cached descendant excludes' ' + test_when_finished "rm -rf root-untracked-event" && + test_create_repo root-untracked-event && + ( + cd root-untracked-event && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + test_write_lines tracked >cached/deep/tracked && + test_write_lines ignored >cached/deep/junk.ignored && + git add .gitignore cached/.gitignore cached/deep/tracked && + git commit -m base && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + + test_write_lines visible >root-probe && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=root-probe \ + GIT_TRACE2_EVENT="$PWD/.git/created.trace" \ + git status >.git/created && + test_grep "root-probe" .git/created && + test_trace2_data read_directory directories-visited 1 \ + <.git/created.trace && + test_trace2_data read_directory gitignore-invalidation 0 \ + <.git/created.trace && + + rm root-probe && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=root-probe \ + GIT_TRACE2_EVENT="$PWD/.git/removed.trace" \ + git status >.git/removed && + test_grep "nothing to commit, working tree clean" \ + .git/removed && + test_trace2_data read_directory directories-visited 1 \ + <.git/removed.trace && + test_trace2_data read_directory gitignore-invalidation 0 \ + <.git/removed.trace + ) +' + +prepare_builtin_closure_repo () { + test_create_repo "$1" && + ( + cd "$1" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + if test "${2-}" = untracked + then + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/actual && + test_must_be_empty .git/actual && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index + else + : + fi && + git config core.fsmonitor true && + test_grep ! FSMN .git/index + ) +} + +test_expect_success UNTRACKED_CACHE 'builtin clean closure publishes its proof' ' + test_when_finished "rm -rf builtin-closure-clean" && + prepare_builtin_closure_repo builtin-closure-clean untracked && + ( + cd builtin-closure-clean && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines visible >visible && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^? visible$" .git/actual && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 1 .git/read-directory && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index + ) +' + +test_expect_success 'builtin changed closure rescans before acceptance' ' + test_when_finished "rm -rf builtin-closure-changed" && + prepare_builtin_closure_repo builtin-closure-changed && + ( + cd builtin-closure-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + GIT_TRACE_FSMONITOR="$PWD/.git/fsmonitor.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_grep "fsmonitor_refresh_callback.*tracked" \ + .git/fsmonitor.trace && + test_trace2_data fsmonitor token_closure/apply_count 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace + ) +' + +test_expect_success 'builtin initial trivial response anchors a closure' ' + test_when_finished "rm -rf builtin-initial-trivial" && + prepare_builtin_closure_repo builtin-initial-trivial && + ( + cd builtin-initial-trivial && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "^fsmonitor last update builtin:test:2" \ + .git/fsmonitor + ) +' + +test_expect_success 'builtin trivial closure can rescan and accept' ' + test_when_finished "rm -rf builtin-closure-trivial" && + prepare_builtin_closure_repo builtin-closure-trivial && + ( + cd builtin-closure-trivial && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CTC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor token_closure/trivial 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace + ) +' + +test_expect_success 'builtin closure rejects three intervening changes' ' + test_when_finished "rm -rf builtin-closure-exhausted" && + prepare_builtin_closure_repo builtin-closure-exhausted && + ( + cd builtin-closure-exhausted && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDDD \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 \ + --untracked-files=no >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor token_closure/apply_count 1 \ + <.git/status.trace >.git/applied && + test_line_count = 3 .git/applied && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSUC .git/index + ) +' + +test_expect_success 'builtin closure query errors fall back completely' ' + test_when_finished "rm -rf builtin-closure-error" && + prepare_builtin_closure_repo builtin-closure-error untracked && + ( + cd builtin-closure-error && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines visible >visible && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^? visible$" .git/actual && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 2 .git/read-directory && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSUC .git/index + ) +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && diff --git a/wt-status.c b/wt-status.c index fab9f1af38bea6..57e2321275dc07 100644 --- a/wt-status.c +++ b/wt-status.c @@ -34,6 +34,7 @@ #include "worktree.h" #include "lockfile.h" #include "sequencer.h" +#include "fsmonitor.h" #include "fsmonitor-settings.h" #define AB_DELAY_WARNING_IN_MS (2 * 1000) @@ -814,21 +815,50 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) { struct index_state *istate = s->repo->index; unsigned int dir_flags; + int has_fsmonitor = + fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED; if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); - if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED || - s->pathspec.nr || + if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) return; - dir_flags = wt_status_untracked_dir_flags(s); + if (has_fsmonitor) { + s->untracked_cache_preload = + untracked_cache_preload_start_fsmonitor_excludes( + istate, dir_flags); + return; + } + s->untracked_cache_preload = untracked_cache_preload_start_ordinary(istate, dir_flags); } -static int wt_status_collect_untracked_1(struct wt_status *s, int collect) +static void wt_status_finish_untracked_cache_preload(struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + size_t index_invalidated = 0; + + if (!s->untracked_cache_preload) + return; + s->untracked_cache_preloaded = untracked_cache_preload_finish( + s->untracked_cache_preload, istate, + wt_status_untracked_dir_flags(s), &index_invalidated); + s->untracked_cache_preload = NULL; + if (!index_invalidated) + return; + + trace2_data_intmax("status", s->repo, + "fsmonitor/exclude-index-invalidated", + index_invalidated); +} + +static int wt_status_collect_untracked_1( + struct wt_status *s, + struct string_list *untracked, + struct string_list *ignored) { int i; int used_untracked_cache; @@ -851,11 +881,7 @@ static int wt_status_collect_untracked_1(struct wt_status *s, int collect) } setup_standard_excludes(&dir); - if (s->untracked_cache_preload) { - s->untracked_cache_preloaded = untracked_cache_preload_finish( - s->untracked_cache_preload, istate, dir.flags); - s->untracked_cache_preload = NULL; - } + wt_status_finish_untracked_cache_preload(s); dir.internal.untracked_cache_preloaded = s->untracked_cache_preloaded; @@ -863,32 +889,183 @@ static int wt_status_collect_untracked_1(struct wt_status *s, int collect) used_untracked_cache = dir.untracked && dir.untracked == istate->untracked; - if (collect) { - for (i = 0; i < dir.nr; i++) { - struct dir_entry *ent = dir.entries[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->untracked, ent->name); - } - string_list_sort_u(&s->untracked, 0); + for (i = 0; i < dir.nr; i++) { + struct dir_entry *ent = dir.entries[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(untracked, ent->name); + } + string_list_sort_u(untracked, 0); - for (i = 0; i < dir.ignored_nr; i++) { - struct dir_entry *ent = dir.ignored[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(&s->ignored, ent->name); - } - string_list_sort_u(&s->ignored, 0); + for (i = 0; i < dir.ignored_nr; i++) { + struct dir_entry *ent = dir.ignored[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(ignored, ent->name); } + string_list_sort_u(ignored, 0); dir_clear(&dir); - if (collect && advice_enabled(ADVICE_STATUS_U_OPTION)) + if (advice_enabled(ADVICE_STATUS_U_OPTION)) s->untracked_in_ms = (getnanotime() - t_begin) / 1000000; + if (used_untracked_cache) + fsmonitor_mark_untracked_cache_valid(istate); return used_untracked_cache; } static int wt_status_collect_untracked(struct wt_status *s) { - return wt_status_collect_untracked_1(s, 1); + if (s->untracked_from_token_closure && !s->show_ignored_mode) + return 1; + return wt_status_collect_untracked_1( + s, &s->untracked, &s->ignored); +} + +#define FSMONITOR_TOKEN_MAX_QUERIES 3 + +struct wt_status_token_closure { + struct wt_status *status; + unsigned int refresh_flags; + int can_prime; + int untracked_ready; + struct string_list staged_untracked; + struct string_list staged_ignored; + int staged_untracked_ready; + int refresh_result; + int queries; +}; + +static void wt_status_discard_staged_untracked( + struct wt_status_token_closure *closure) +{ + string_list_clear(&closure->staged_untracked, 0); + string_list_clear(&closure->staged_ignored, 0); + closure->staged_untracked_ready = 0; +} + +static int wt_status_stage_untracked( + struct wt_status_token_closure *closure) +{ + wt_status_discard_staged_untracked(closure); + closure->staged_untracked_ready = + wt_status_collect_untracked_1( + closure->status, + &closure->staged_untracked, + &closure->staged_ignored); + if (!closure->staged_untracked_ready) + wt_status_discard_staged_untracked(closure); + return closure->staged_untracked_ready; +} + +static void wt_status_publish_staged_untracked( + struct wt_status_token_closure *closure) +{ + struct wt_status *s = closure->status; + + if (!closure->staged_untracked_ready) + return; + if (s->untracked.nr || s->ignored.nr) + BUG("publishing untracked results over collected status"); + SWAP(s->untracked, closure->staged_untracked); + SWAP(s->ignored, closure->staged_ignored); + s->untracked_from_token_closure = 1; + closure->staged_untracked_ready = 0; +} + +static int wt_status_close_ordinary_fsmonitor_token( + struct wt_status_token_closure *closure, + int refreshed_before_closure) +{ + struct wt_status *s = closure->status; + struct index_state *istate = s->repo->index; + + if (!refreshed_before_closure) + closure->refresh_result = refresh_index( + istate, closure->refresh_flags, &s->pathspec, + NULL, NULL); + if (!closure->untracked_ready && closure->can_prime) + closure->untracked_ready = wt_status_stage_untracked(closure); + + while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { + enum fsmonitor_token_result result = + fsmonitor_query_pending_token( + istate, closure->untracked_ready); + + closure->queries++; + if (result == FSMONITOR_TOKEN_CLEAN) { + if (closure->untracked_ready) { + fsmonitor_accept_pending_token(istate); + return 1; + } + break; + } + if (result == FSMONITOR_TOKEN_ERROR || + result == FSMONITOR_TOKEN_NOT_PENDING) + break; + + /* Rescan invalidations returned by the closure query. */ + closure->refresh_result |= refresh_index( + istate, closure->refresh_flags, &s->pathspec, + NULL, NULL); + if (closure->can_prime) + closure->untracked_ready = + wt_status_stage_untracked(closure); + } + return 0; +} + +static int wt_status_close_fsmonitor_token( + struct wt_status *s, unsigned int refresh_flags, + int require_untracked, int refreshed_before_closure) +{ + struct index_state *istate = s->repo->index; + struct wt_status_token_closure closure = { + .status = s, + .refresh_flags = refresh_flags, + .staged_untracked = STRING_LIST_INIT_DUP, + .staged_ignored = STRING_LIST_INIT_DUP, + }; + + refresh_fsmonitor(istate); + if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) { + if (!refreshed_before_closure) + closure.refresh_result = refresh_index( + istate, refresh_flags, &s->pathspec, + NULL, NULL); + return closure.refresh_result; + } + + closure.can_prime = require_untracked && + s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && + !s->show_ignored_mode; + closure.untracked_ready = !istate->untracked || + !istate->untracked->root; + if (require_untracked && !closure.can_prime && + !closure.untracked_ready) + BUG("cannot close required untracked scan"); + trace2_region_enter("status", "fsmonitor_token_closure", s->repo); + if (wt_status_close_ordinary_fsmonitor_token( + &closure, refreshed_before_closure)) + goto accepted; + + /* Keep the last valid token and fall back to complete scans. */ + wt_status_discard_staged_untracked(&closure); + fsmonitor_reject_pending_token(istate); + closure.refresh_result |= refresh_index( + istate, refresh_flags, &s->pathspec, NULL, NULL); +accepted: + wt_status_publish_staged_untracked(&closure); + wt_status_discard_staged_untracked(&closure); + trace2_region_leave("status", "fsmonitor_token_closure", s->repo); + return closure.refresh_result; +} + +int wt_status_refresh_index(struct wt_status *s, + unsigned int refresh_flags, + int require_untracked) +{ + return wt_status_close_fsmonitor_token( + s, refresh_flags, require_untracked, 0); } static int has_unmerged(struct wt_status *s) @@ -906,6 +1083,15 @@ static int has_unmerged(struct wt_status *s) void wt_status_collect(struct wt_status *s) { + int used_untracked_cache; + + if (fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED) + wt_status_finish_untracked_cache_preload(s); + wt_status_close_fsmonitor_token( + s, REFRESH_QUIET | REFRESH_UNMERGED, + s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && + !s->show_ignored_mode, 1); + trace2_region_enter("status", "worktrees", s->repo); wt_status_collect_changes_worktree(s); trace2_region_leave("status", "worktrees", s->repo); @@ -921,9 +1107,20 @@ void wt_status_collect(struct wt_status *s) } trace2_region_enter("status", "untracked", s->repo); - wt_status_collect_untracked(s); + used_untracked_cache = wt_status_collect_untracked(s); trace2_region_leave("status", "untracked", s->repo); + /* Hook providers have no second query with which to close the scan. */ + if (fsmonitor_has_pending_token(s->repo->index) && !s->pathspec.nr && + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_HOOK && + (used_untracked_cache || !s->repo->index->untracked || + !s->repo->index->untracked->root)) { + if (fsmonitor_pending_token_from_provider(s->repo->index)) + fsmonitor_accept_pending_token(s->repo->index); + else + fsmonitor_reject_pending_token(s->repo->index); + } + wt_status_get_state(s->repo, &s->state, s->branch && !strcmp(s->branch, "HEAD")); if (s->state.merge_in_progress && !has_unmerged(s)) s->committable = 1; diff --git a/wt-status.h b/wt-status.h index e64eda2d9cc666..34beac22576fc9 100644 --- a/wt-status.h +++ b/wt-status.h @@ -139,6 +139,7 @@ struct wt_status { /* These are computed during processing of the individual sections */ int committable; int workdir_dirty; + unsigned untracked_from_token_closure : 1; const char *index_file; FILE *fp; const char *prefix; @@ -157,6 +158,13 @@ void wt_status_prepare(struct repository *r, struct wt_status *s); void wt_status_print(struct wt_status *s); void wt_status_collect(struct wt_status *s); void wt_status_start_untracked_cache_preload(struct wt_status *s); +/* + * Refresh tracked entries and close any provider token. When requested, + * also close a complete untracked-cache scan before accepting that token. + */ +int wt_status_refresh_index(struct wt_status *s, + unsigned int refresh_flags, + int require_untracked); /* * Collect all changes between the two trees. Changes will be displayed as if From 079682ac0d12bc52db3eeb6c839831c58309c654 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:47:44 -0500 Subject: [PATCH 193/403] preload-index: queue bounded bulk directory scans Ordinary index preload assigns existing paths directly to workers. A physical directory walk instead discovers new tasks while it runs, so an unbounded queue can exhaust descriptors or strand tasks when worker creation fails. Add a directory-task queue that retains parent and child identities, budgets descriptors against RLIMIT_NOFILE, and tracks queued as well as in-flight work. Reserve at most 128 task descriptors, leave up to 16 for the rest of the process, and run a worker synchronously when extra threads cannot start. Register the common queue for Darwin in Make, CMake, and Meson. No bulk scan is invoked from preload_index(), so existing behavior is unchanged. Signed-off-by: Taylor Blau --- Makefile | 8 + config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 6 + meson.build | 3 + preload-index-bulk-thread.c | 232 ++++++++++++++++++++++++++++ preload-index-bulk.h | 76 +++++++++ 6 files changed, 326 insertions(+) create mode 100644 preload-index-bulk-thread.c create mode 100644 preload-index-bulk.h diff --git a/Makefile b/Makefile index edac13257671b1..f9bd42aba71222 100644 --- a/Makefile +++ b/Makefile @@ -413,6 +413,9 @@ include shared.mak # `compat/fsmonitor/fsm-health-.c` files # that implement the `fsm_listen__*()` and `fsm_health__*()` routines. # +# If a platform supports bulk worktree scans during index preload, set +# PRELOAD_INDEX_BULK_BACKEND to the name of its backend. +# # If your platform has OS-specific ways to tell if a repo is incompatible with # fsmonitor (whether the hook or IPC daemon version), set FSMONITOR_OS_SETTINGS # to the "" of the corresponding `compat/fsmonitor/fsm-settings-.c` @@ -1378,6 +1381,11 @@ LIB_OBJS += worktree.o LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o +ifdef PRELOAD_INDEX_BULK_BACKEND +PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o +PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) +endif +LIB_OBJS += $(PRELOAD_INDEX_BULK_OBJS) LIB_OBJS += wt-status.o LIB_OBJS += xdiff-interface.o LIB_OBJS += xdiff/xdiffi.o diff --git a/config.mak.uname b/config.mak.uname index 9ebd240378ca59..af3f927d8b8a50 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -162,6 +162,7 @@ ifeq ($(uname_S),Darwin) USE_ENHANCED_BASIC_REGULAR_EXPRESSIONS = YesPlease HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/darwin/procinfo.o + PRELOAD_INDEX_BULK_BACKEND = darwin ifeq ($(uname_M),arm64) HOMEBREW_PREFIX = /opt/homebrew diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index a57c4b464fa456..1eb0a9fc0ef646 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -103,6 +103,7 @@ macro(parse_makefile_for_sources list_var makefile regex) file(STRINGS ${makefile} ${list_var} REGEX "^${regex} \\+=(.*)") string(REPLACE "${regex} +=" "" ${list_var} ${${list_var}}) string(REPLACE "$(COMPAT_OBJS)" "" ${list_var} ${${list_var}}) #remove "$(COMPAT_OBJS)" This is only for libgit. + string(REPLACE "$(PRELOAD_INDEX_BULK_OBJS)" "" ${list_var} ${${list_var}}) string(STRIP ${${list_var}} ${list_var}) #remove trailing/leading whitespaces string(REPLACE ".o" ".c;" ${list_var} ${${list_var}}) #change .o to .c, ; is for converting the string into a list list(TRANSFORM ${list_var} STRIP) #remove trailing/leading whitespaces for each element in list @@ -664,6 +665,11 @@ include_directories(${CMAKE_BINARY_DIR}) #libgit parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS") +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + list(APPEND libgit_SOURCES + preload-index-bulk-thread.c) +endif() + list(TRANSFORM libgit_SOURCES PREPEND "${CMAKE_SOURCE_DIR}/") list(TRANSFORM compat_SOURCES PREPEND "${CMAKE_SOURCE_DIR}/") diff --git a/meson.build b/meson.build index a777f4722a2061..68cd5819811f3e 100644 --- a/meson.build +++ b/meson.build @@ -1345,6 +1345,9 @@ elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' compat_sources += 'compat/darwin/procinfo.c' + libgit_sources += [ + 'preload-index-bulk-thread.c', + ] else compat_sources += 'compat/stub/procinfo.c' endif diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c new file mode 100644 index 00000000000000..0a73d5a1fdeafd --- /dev/null +++ b/preload-index-bulk-thread.c @@ -0,0 +1,232 @@ +#include "git-compat-util.h" + +#include + +#include "preload-index-bulk.h" + +#define PRELOAD_INDEX_BULK_OPEN_FD_CAP 128 +#define PRELOAD_INDEX_BULK_OPEN_FD_RESERVE 16 + +static void queue_set_failed(struct preload_bulk_queue *queue) +{ + pthread_mutex_lock(&queue->mutex); + queue->failed = 1; + pthread_mutex_unlock(&queue->mutex); +} + +static void enqueue_task(struct preload_bulk_scan *scan, + struct preload_bulk_task *task) +{ + struct preload_bulk_queue *queue = &scan->queue; + + pthread_mutex_lock(&queue->mutex); + task->next = queue->head; + queue->head = task; + queue->pending++; + pthread_cond_signal(&queue->cond); + pthread_mutex_unlock(&queue->mutex); +} + +static int reserve_open_fd(struct preload_bulk_queue *queue) +{ + int reserved = 0; + + pthread_mutex_lock(&queue->mutex); + if (queue->open_fds < queue->open_fd_limit) { + queue->open_fds++; + reserved = 1; + } + pthread_mutex_unlock(&queue->mutex); + return reserved; +} + +static void release_open_fd(struct preload_bulk_queue *queue) +{ + pthread_mutex_lock(&queue->mutex); + if (!queue->open_fds) + BUG("bulk preload open-fd count underflow"); + queue->open_fds--; + pthread_mutex_unlock(&queue->mutex); +} + +void preload_bulk_schedule_directory( + struct preload_bulk_worker *worker, int parent_fd, + const struct preload_bulk_dir_identity *parent_identity, + const struct preload_bulk_dir_identity *child_identity, + const char *name, const char *path, size_t path_len) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_task *task; + + FLEX_ALLOC_MEM(task, path, path, path_len); + if (parent_identity) { + task->parent_identity = *parent_identity; + task->has_parent_identity = 1; + } + if (child_identity) { + task->child_identity = *child_identity; + task->has_child_identity = 1; + } + task->fd = -1; + if (reserve_open_fd(&scan->queue)) { + task->reserved_fd = 1; + task->fd = scan->backend->open_dir_at(worker, parent_fd, name); + if (task->fd < 0) { + int saved_errno = errno; + + task->reserved_fd = 0; + release_open_fd(&scan->queue); + if (saved_errno == EXDEV) { + free(task); + return; + } + if (saved_errno != EMFILE && saved_errno != ENFILE) { + free(task); + queue_set_failed(&scan->queue); + return; + } + } + } + enqueue_task(scan, task); +} + +static size_t preload_bulk_open_fd_limit(void) +{ + struct rlimit limit; + rlim_t value; + + if (getrlimit(RLIMIT_NOFILE, &limit)) + return 1; + if (limit.rlim_cur == RLIM_INFINITY) + return PRELOAD_INDEX_BULK_OPEN_FD_CAP; + if (limit.rlim_cur <= PRELOAD_INDEX_BULK_OPEN_FD_RESERVE) + return 1; + value = limit.rlim_cur - PRELOAD_INDEX_BULK_OPEN_FD_RESERVE; + if (value > PRELOAD_INDEX_BULK_OPEN_FD_CAP) + value = PRELOAD_INDEX_BULK_OPEN_FD_CAP; + return value; +} + +static int queue_init(struct preload_bulk_queue *queue) +{ + memset(queue, 0, sizeof(*queue)); +#if HAVE_THREADS + if (pthread_mutex_init(&queue->mutex, NULL)) + return -1; + if (pthread_cond_init(&queue->cond, NULL)) { + pthread_mutex_destroy(&queue->mutex); + return -1; + } +#endif + queue->open_fd_limit = preload_bulk_open_fd_limit(); + return 0; +} + +static void queue_release(struct preload_bulk_queue *queue) +{ + if (queue->head || queue->pending || queue->open_fds) + BUG("releasing non-empty bulk preload queue"); +#if HAVE_THREADS + pthread_cond_destroy(&queue->cond); + pthread_mutex_destroy(&queue->mutex); +#endif + memset(queue, 0, sizeof(*queue)); +} + +static void *preload_bulk_worker_main(void *data) +{ + struct preload_bulk_worker *worker = data; + struct preload_bulk_queue *queue = &worker->scan->queue; + + for (;;) { + struct preload_bulk_task *task; + int failed, reserved_fd; + + pthread_mutex_lock(&queue->mutex); + while (!queue->head && queue->pending) + pthread_cond_wait(&queue->cond, &queue->mutex); + if (!queue->pending) { + pthread_mutex_unlock(&queue->mutex); + break; + } + task = queue->head; + queue->head = task->next; + pthread_mutex_unlock(&queue->mutex); + + failed = + worker->scan->backend->scan_directory(worker, task); + reserved_fd = task->reserved_fd; + free(task); + + pthread_mutex_lock(&queue->mutex); + if (failed) + queue->failed = 1; + if (reserved_fd) { + if (!queue->open_fds) + BUG("bulk preload open-fd count underflow"); + queue->open_fds--; + } + if (!queue->pending) + BUG("bulk preload task count underflow"); + queue->pending--; + if (!queue->pending) + pthread_cond_broadcast(&queue->cond); + pthread_mutex_unlock(&queue->mutex); + } + return NULL; +} + +int preload_bulk_run_scan(struct preload_bulk_scan *scan, + struct preload_bulk_run_result *result) +{ + struct preload_bulk_task *root_task; + int failed, started_threads = 1; + + if (scan->threads < 1) + BUG("bulk preload scan requires at least one worker"); + memset(result, 0, sizeof(*result)); + if (queue_init(&scan->queue)) + return -1; + CALLOC_ARRAY(scan->workers, scan->threads); + for (int i = 0; i < scan->threads; i++) + scan->workers[i].scan = scan; + + FLEX_ALLOC_STR(root_task, path, "."); + if (!reserve_open_fd(&scan->queue)) + BUG("bulk preload queue cannot reserve its root descriptor"); + root_task->reserved_fd = 1; + root_task->fd = fcntl(scan->root_fd, F_DUPFD_CLOEXEC, 0); + if (root_task->fd < 0) { + release_open_fd(&scan->queue); + free(root_task); + free(scan->workers); + scan->workers = NULL; + queue_release(&scan->queue); + return -1; + } + enqueue_task(scan, root_task); + + for (int i = 1; i < scan->threads; i++) { + int err = pthread_create(&scan->workers[i].thread, NULL, + preload_bulk_worker_main, + &scan->workers[i]); + + if (err) + break; + scan->workers[i].started = 1; + started_threads++; + } + preload_bulk_worker_main(&scan->workers[0]); + for (int i = 1; i < scan->threads; i++) + if (scan->workers[i].started && + pthread_join(scan->workers[i].thread, NULL)) + BUG("unable to join bulk preload worker"); + + result->threads = started_threads; + failed = scan->queue.failed; + + free(scan->workers); + scan->workers = NULL; + queue_release(&scan->queue); + return failed ? -1 : 0; +} diff --git a/preload-index-bulk.h b/preload-index-bulk.h new file mode 100644 index 00000000000000..64cb000474413a --- /dev/null +++ b/preload-index-bulk.h @@ -0,0 +1,76 @@ +#ifndef PRELOAD_INDEX_BULK_H +#define PRELOAD_INDEX_BULK_H + +#include "git-compat-util.h" +#include "thread-utils.h" + +struct preload_bulk_dir_identity { + struct stat stat; + unsigned complete : 1; +}; + +struct preload_bulk_task { + struct preload_bulk_task *next; + struct preload_bulk_dir_identity parent_identity; + struct preload_bulk_dir_identity child_identity; + int fd; + unsigned reserved_fd : 1; + unsigned has_parent_identity : 1; + unsigned has_child_identity : 1; + char path[FLEX_ARRAY]; +}; + +struct preload_bulk_queue { + pthread_mutex_t mutex; + pthread_cond_t cond; + struct preload_bulk_task *head; + /* + * pending includes queued and in-flight tasks. open_fds counts only + * descriptor reservations held by tasks. + */ + size_t pending; + size_t open_fds; + size_t open_fd_limit; + int failed; +}; + +struct preload_bulk_scan; + +struct preload_bulk_worker { + struct preload_bulk_scan *scan; + pthread_t thread; + unsigned started : 1; +}; + +struct preload_bulk_backend { + int (*open_dir_at)(struct preload_bulk_worker *worker, int parent_fd, + const char *name); + /* + * Consume task->fd when it is non-negative, and close it before + * returning. + */ + int (*scan_directory)(struct preload_bulk_worker *worker, + struct preload_bulk_task *task); +}; + +struct preload_bulk_scan { + const struct preload_bulk_backend *backend; + struct preload_bulk_queue queue; + struct preload_bulk_worker *workers; + int root_fd; + int threads; +}; + +struct preload_bulk_run_result { + int threads; +}; + +void preload_bulk_schedule_directory( + struct preload_bulk_worker *worker, int parent_fd, + const struct preload_bulk_dir_identity *parent_identity, + const struct preload_bulk_dir_identity *child_identity, + const char *name, const char *path, size_t path_len); +int preload_bulk_run_scan(struct preload_bulk_scan *scan, + struct preload_bulk_run_result *result); + +#endif /* PRELOAD_INDEX_BULK_H */ From 604f05c782176b6c88fb65f41c3c1b4f0b64a3a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:48:20 -0500 Subject: [PATCH 194/403] preload-index: classify sparse-aware bulk stat observations A bulk directory worker must locate each observed tracked path and decide whether a directory has tracked descendants. Plain pathname ordering cannot answer either question correctly for sparse indexes. Add sparse-aware entry and descendant lookups with unseen, clean, content-check, and fallback states. Compare observed metadata with ie_match_stat(), and make duplicate observations fall back through an atomic compare-and-exchange or the existing queue mutex. Skip staged, intent-to-add, skip-worktree, removed, and otherwise ineligible entries. Register the index classifier in Make, CMake, and Meson without introducing deletion outcomes or content proofs. Signed-off-by: Taylor Blau --- Makefile | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + preload-index-bulk-index.c | 101 ++++++++++++++++++++++++++++ preload-index-bulk.h | 10 +++ preload-index.h | 7 ++ 6 files changed, 121 insertions(+) create mode 100644 preload-index-bulk-index.c diff --git a/Makefile b/Makefile index f9bd42aba71222..51059dadddf171 100644 --- a/Makefile +++ b/Makefile @@ -1382,6 +1382,7 @@ LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o ifdef PRELOAD_INDEX_BULK_BACKEND +PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-index.o PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) endif diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 1eb0a9fc0ef646..a9e4e6aaa9d10c 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -667,6 +667,7 @@ parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") list(APPEND libgit_SOURCES + preload-index-bulk-index.c preload-index-bulk-thread.c) endif() diff --git a/meson.build b/meson.build index 68cd5819811f3e..1f8667e551a9c9 100644 --- a/meson.build +++ b/meson.build @@ -1346,6 +1346,7 @@ elif host_machine.system() == 'windows' elif host_machine.system() == 'darwin' compat_sources += 'compat/darwin/procinfo.c' libgit_sources += [ + 'preload-index-bulk-index.c', 'preload-index-bulk-thread.c', ] else diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c new file mode 100644 index 00000000000000..3c8bfad7c2a631 --- /dev/null +++ b/preload-index-bulk-index.c @@ -0,0 +1,101 @@ +#include "git-compat-util.h" +#include "preload-index-bulk.h" +#include "read-cache-ll.h" + +#ifndef __has_builtin +#define __has_builtin(x) 0 +#endif + +int preload_bulk_index_position(struct preload_bulk_scan *scan, + const char *path, size_t path_len) +{ + if (path_len > INT_MAX) + return -1; + return index_name_pos_sparse(scan->istate, path, path_len); +} + +int preload_bulk_index_pos_has_tracked_descendants( + struct preload_bulk_scan *scan, const char *path, size_t path_len, + int pos) +{ + struct index_state *istate = scan->istate; + const struct cache_entry *ce; + + if (pos >= 0) + return 0; + pos = -pos - 1; + while ((unsigned int)pos < istate->cache_nr) { + ce = istate->cache[pos]; + if (ce_namelen(ce) < path_len || + memcmp(ce->name, path, path_len)) + return 0; + if (ce_namelen(ce) == path_len) { + pos++; + continue; + } + if (ce->name[path_len] == '/') + return 1; + if ((unsigned char)ce->name[path_len] > '/') + return 0; + pos++; + } + return 0; +} + +static int record_tracked_state(struct preload_bulk_worker *worker, int pos, + unsigned char state) +{ + struct preload_bulk_scan *scan = worker->scan; + int recorded = 1; + +#if GIT_GNUC_PREREQ(4, 7) || \ + (__has_builtin(__atomic_compare_exchange_n) && \ + __has_builtin(__atomic_store_n)) + unsigned char expected = PRELOAD_BULK_TRACKED_UNSEEN; + + if (!__atomic_compare_exchange_n(&scan->tracked_state[pos], &expected, + state, 0, __ATOMIC_RELAXED, + __ATOMIC_RELAXED)) { + __atomic_store_n(&scan->tracked_state[pos], + PRELOAD_BULK_TRACKED_FALLBACK, + __ATOMIC_RELAXED); + recorded = 0; + } +#else + pthread_mutex_lock(&scan->queue.mutex); + if (scan->tracked_state[pos] != PRELOAD_BULK_TRACKED_UNSEEN) { + state = PRELOAD_BULK_TRACKED_FALLBACK; + recorded = 0; + } + scan->tracked_state[pos] = state; + pthread_mutex_unlock(&scan->queue.mutex); +#endif + return recorded; +} + +static int tracked_entry_is_eligible(const struct cache_entry *ce) +{ + return !ce_stage(ce) && + !ce_intent_to_add(ce) && + !ce_skip_worktree(ce) && + !(ce->ce_flags & (CE_VALID | CE_REMOVE)) && + (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)); +} + +void preload_bulk_record_tracked( + struct preload_bulk_worker *worker, int pos, const struct stat *st) +{ + struct preload_bulk_scan *scan = worker->scan; + struct cache_entry *ce = scan->istate->cache[pos]; + unsigned int changed; + unsigned char state; + + if (!tracked_entry_is_eligible(ce)) + return; + changed = ie_match_stat( + scan->istate, ce, (struct stat *)st, + CE_MATCH_RACY_IS_DIRTY | CE_MATCH_IGNORE_FSMONITOR); + state = changed ? PRELOAD_BULK_TRACKED_CONTENT_CHECK : + PRELOAD_BULK_TRACKED_CLEAN; + record_tracked_state(worker, pos, state); +} diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 64cb000474413a..64f5e9cc9a167d 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -2,6 +2,7 @@ #define PRELOAD_INDEX_BULK_H #include "git-compat-util.h" +#include "preload-index.h" #include "thread-utils.h" struct preload_bulk_dir_identity { @@ -54,9 +55,11 @@ struct preload_bulk_backend { }; struct preload_bulk_scan { + struct index_state *istate; const struct preload_bulk_backend *backend; struct preload_bulk_queue queue; struct preload_bulk_worker *workers; + unsigned char *tracked_state; int root_fd; int threads; }; @@ -70,6 +73,13 @@ void preload_bulk_schedule_directory( const struct preload_bulk_dir_identity *parent_identity, const struct preload_bulk_dir_identity *child_identity, const char *name, const char *path, size_t path_len); +int preload_bulk_index_position(struct preload_bulk_scan *scan, + const char *path, size_t path_len); +int preload_bulk_index_pos_has_tracked_descendants( + struct preload_bulk_scan *scan, const char *path, size_t path_len, + int pos); +void preload_bulk_record_tracked( + struct preload_bulk_worker *worker, int pos, const struct stat *st); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); diff --git a/preload-index.h b/preload-index.h index 251b1ed88e9820..4b21e22b6afb19 100644 --- a/preload-index.h +++ b/preload-index.h @@ -5,6 +5,13 @@ struct index_state; struct pathspec; struct repository; +enum preload_bulk_tracked_state { + PRELOAD_BULK_TRACKED_UNSEEN = 0, + PRELOAD_BULK_TRACKED_CLEAN, + PRELOAD_BULK_TRACKED_CONTENT_CHECK, + PRELOAD_BULK_TRACKED_FALLBACK, +}; + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags); From 3f85309bf927ed6cfeef19d409c5080419924e20 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:44:44 -0500 Subject: [PATCH 195/403] status: adopt tracked history only after closing its proof An IPC provider cannot safely adopt missing semantic history merely because tracked entries are marked fsmonitor-valid. Minimal stat checks can conceal a content rewrite, and a clean token cannot retroactively certify workers started under different attributes. Capture the complete proof epoch before preparing semantic workers. Prime each worker's attribute frames and verify the starting token and complete epoch before hashing. After a clean closing query, apply the proof only if the pinned index, configuration, attribute content, manifest, worktree identity, and token remain consistent. Permit attribute-namespace bookkeeping to change only after its source bytes and initial namespace were verified. Accept tracked validity independently of untracked validity. Keep a query pending when the untracked scan has not run. Leave collapsed sparse indexes, pathspecs, ignored-file requests, unreliable file identity, non-IPC providers, and failed proofs on ordinary closure or complete refresh. Add scripted regressions for adopting missing tracked history without hiding a same-size rewrite and for preserving a collapsed sparse index on the ordinary closure path. Signed-off-by: Taylor Blau --- clean-status-epoch.c | 37 ++++++++- clean-status.h | 6 ++ fsmonitor-ll.h | 3 +- fsmonitor.c | 22 ++++-- semantic-verify-internal.h | 4 + semantic-verify-worker.c | 6 +- semantic-verify.c | 59 ++++++++++++++- semantic-verify.h | 6 ++ wt-status.c | 149 +++++++++++++++++++++++++++++++++++-- 9 files changed, 272 insertions(+), 20 deletions(-) diff --git a/clean-status-epoch.c b/clean-status-epoch.c index b5760649cb3c10..f78bf8fb6bd78f 100644 --- a/clean-status-epoch.c +++ b/clean-status-epoch.c @@ -138,9 +138,10 @@ int clean_status_proof_epoch_start_token_matches( istate->fsmonitor_last_update_pending); } -int clean_status_proof_epoch_matches( +static int proof_epoch_matches( struct index_state *istate, - const struct clean_status_proof_epoch *epoch) + const struct clean_status_proof_epoch *epoch, + int check_attr_namespace) { struct clean_status_state *state; struct attr_fingerprint attrs; @@ -166,8 +167,9 @@ int clean_status_proof_epoch_matches( goto done; if (attr_fingerprint_repository(istate->repo, &attrs) || memcmp(attrs.content_hash, epoch->attr_hash, algo->rawsz) || - memcmp(attrs.namespace_hash, epoch->attr_namespace_hash, - algo->rawsz) || + (check_attr_namespace && + memcmp(attrs.namespace_hash, epoch->attr_namespace_hash, + algo->rawsz)) || attrs.sources_present != epoch->attr_sources_present || memcmp(state->manifest.current_hash, epoch->manifest_hash, algo->rawsz) || @@ -182,6 +184,33 @@ int clean_status_proof_epoch_matches( return matched; } +int clean_status_proof_epoch_prime_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + int matched = + clean_status_proof_epoch_start_token_matches(istate, epoch) && + proof_epoch_matches(istate, epoch, 1); + + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/proof-epoch-primed", matched); + return matched; +} + +int clean_status_proof_epoch_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + return proof_epoch_matches(istate, epoch, 1); +} + +int clean_status_proof_epoch_content_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch) +{ + return proof_epoch_matches(istate, epoch, 0); +} + void clean_status_release_proof_epoch( struct clean_status_proof_epoch *epoch) { diff --git a/clean-status.h b/clean-status.h index cfe6a2db889f38..1cbfd1e0329456 100644 --- a/clean-status.h +++ b/clean-status.h @@ -35,9 +35,15 @@ struct clean_status_proof_epoch *clean_status_capture_proof_epoch( int clean_status_proof_epoch_start_token_matches( struct index_state *istate, const struct clean_status_proof_epoch *epoch); +int clean_status_proof_epoch_prime_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); int clean_status_proof_epoch_matches( struct index_state *istate, const struct clean_status_proof_epoch *epoch); +int clean_status_proof_epoch_content_matches( + struct index_state *istate, + const struct clean_status_proof_epoch *epoch); void clean_status_release_proof_epoch( struct clean_status_proof_epoch *epoch); diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index d7522222fc09cb..dc6998d5789a75 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -71,7 +71,8 @@ int fsmonitor_has_pending_token(const struct index_state *istate); int fsmonitor_pending_token_from_provider(const struct index_state *istate); enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready); -void fsmonitor_accept_pending_token(struct index_state *istate); +void fsmonitor_accept_pending_token(struct index_state *istate, + int untracked_ready); void fsmonitor_reject_pending_token(struct index_state *istate); void fsmonitor_mark_untracked_cache_valid(struct index_state *istate); diff --git a/fsmonitor.c b/fsmonitor.c index e65567feaa0cb4..69ed171b040fd1 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1369,7 +1369,8 @@ enum fsmonitor_token_result fsmonitor_query_pending_token( return ret; } -void fsmonitor_accept_pending_token(struct index_state *istate) +void fsmonitor_accept_pending_token(struct index_state *istate, + int untracked_ready) { if (!fsmonitor_pending_token_from_provider(istate)) return; @@ -1378,13 +1379,24 @@ void fsmonitor_accept_pending_token(struct index_state *istate) istate->fsmonitor_last_update_pending = NULL; istate->fsmonitor_pending_token_from_provider = 0; istate->fsmonitor_token_valid = 1; - istate->fsmonitor_untracked_valid = 1; + istate->fsmonitor_untracked_valid = !!untracked_ready; if (istate->untracked) - istate->untracked->use_fsmonitor = 1; + istate->untracked->use_fsmonitor = !!untracked_ready; istate->cache_changed |= FSMONITOR_CHANGED; FREE_AND_NULL(istate->fsmonitor_untracked_token); - istate->fsmonitor_untracked_token = - xstrdup(istate->fsmonitor_last_update); + if (untracked_ready) + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + else { + /* + * Keep a query anchored at the accepted tracked token. A + * later in-process status may need to close work done after + * this point before validating its untracked cache. + */ + istate->fsmonitor_last_update_pending = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_pending_token_from_provider = 1; + } trace2_data_intmax("fsmonitor", istate->repo, "token_closure/accepted", 1); } diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index 70f253ba2885ed..b35af3ff93b271 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -30,6 +30,7 @@ struct attr_check; struct repository; +struct clean_status_proof_epoch; struct cache_entry; struct git_hash_algo; struct index_state; @@ -106,6 +107,7 @@ struct semantic_verify_worker { struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; + struct attr_check *check; size_t start; size_t end; struct semantic_verify_stat_update *updates; @@ -130,6 +132,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker); struct semantic_verify_proof { struct index_state *istate; struct semantic_verify_root *root; + struct clean_status_proof_epoch *epoch; struct semantic_verify_result *results; struct semantic_verify_entry_identity *entry_identities; struct semantic_verify_stat_update *stat_updates; @@ -146,6 +149,7 @@ struct semantic_verify_proof { size_t hardlinks; size_t active_filters; unsigned int namespace_unstable; + unsigned int epoch_required; unsigned int filter_scope_checked; }; diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index b0f00099577b0d..591da8aa70703d 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -56,10 +56,14 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) { struct semantic_verify_path *path = semantic_verify_path_new(worker->root); - struct attr_check *check = convert_attrs_check_alloc(); + struct attr_check *check = worker->check; void *buffer = xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); size_t unstable_from = SIZE_MAX; + worker->check = NULL; + if (!check) + check = convert_attrs_check_alloc(); + for (size_t i = worker->start; i < worker->end; i++) { struct cache_entry *ce = worker->istate->cache[i]; struct semantic_verify_result *result = &worker->results[i]; diff --git a/semantic-verify.c b/semantic-verify.c index 52582793760ce2..277b71f7e6d38f 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -1,6 +1,8 @@ #define USE_THE_REPOSITORY_VARIABLE #include "git-compat-util.h" +#include "attr.h" +#include "clean-status.h" #include "convert.h" #include "fsmonitor.h" #include "object.h" @@ -89,6 +91,7 @@ int semantic_verify_prepare(struct index_state *istate, (uintmax_t)sizeof(struct semantic_verify_result)); CALLOC_ARRAY(proof, 1); proof->istate = istate; + proof->epoch_required = options && options->require_proof_epoch; proof->filter_scope_checked = options && options->validate_filter_scope; proof->cache_nr = istate->cache_nr; @@ -108,7 +111,7 @@ int semantic_verify_prepare(struct index_state *istate, identity->flags = ce->ce_flags & SEMANTIC_VERIFY_ENTRY_FLAGS; } *proof_out = proof; - if (!proof->cache_nr) + if (!proof->cache_nr && !proof->epoch_required) return 0; if (istate->sparse_index != INDEX_EXPANDED) { for (size_t i = 0; i < proof->cache_nr; i++) { @@ -128,11 +131,49 @@ int semantic_verify_prepare(struct index_state *istate, proof->errors = proof->cache_nr; return -1; } + if (proof->epoch_required) { + proof->epoch = clean_status_capture_proof_epoch( + istate, options->attr_snapshot, + proof->filter_scope_checked); + if (!proof->epoch) { + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_ERROR; + proof->results[i].error = EAGAIN; + } + proof->errors = proof->cache_nr; + return -1; + } + } + if (!proof->cache_nr) + return 0; /* Initialize conversion config and default attribute state serially. */ convert_attrs_prepare(istate); nr_threads = select_thread_count(proof->cache_nr, options); CALLOC_ARRAY(workers, nr_threads); + if (proof->epoch_required) { + /* + * Load each worker's system, global, root, and info + * attribute frames before closing the proof epoch. + */ + for (unsigned int i = 0; i < nr_threads; i++) { + workers[i].check = convert_attrs_check_alloc(); + git_check_attr(istate, "", workers[i].check); + } + if (!clean_status_proof_epoch_prime_matches( + istate, proof->epoch)) { + for (unsigned int i = 0; i < nr_threads; i++) + attr_check_free(workers[i].check); + free(workers); + for (size_t i = 0; i < proof->cache_nr; i++) { + proof->results[i].kind = SEMANTIC_VERIFY_ERROR; + proof->results[i].error = EAGAIN; + } + proof->errors = proof->cache_nr; + git_attr_invalidate_all(); + return -1; + } + } trace2_region_enter("semantic_verify", "prepare", istate->repo); trace2_data_intmax("semantic_verify", istate->repo, "threads", nr_threads); @@ -208,6 +249,16 @@ int semantic_verify_root_is_stable(const struct semantic_verify_proof *proof) return proof && semantic_verify_root_stable(proof->root); } +int semantic_verify_start_token_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + return proof && proof->istate == istate && + (!proof->epoch_required || + clean_status_proof_epoch_start_token_matches( + istate, proof->epoch)); +} + void semantic_verify_get_stats(const struct semantic_verify_proof *proof, struct semantic_verify_stats *stats) { @@ -248,7 +299,10 @@ int semantic_verify_apply_after_closure( if (!istate || !proof || proof->istate != istate || proof->cache_nr != istate->cache_nr || proof->namespace_unstable || - !semantic_verify_root_is_stable(proof)) + !semantic_verify_root_is_stable(proof) || + (proof->epoch_required && + !clean_status_proof_epoch_content_matches( + istate, proof->epoch))) return -1; if (proof->active_filters) { trace2_data_intmax("semantic_verify", istate->repo, @@ -349,6 +403,7 @@ void semantic_verify_proof_clear(struct semantic_verify_proof *proof) { if (!proof) return; + clean_status_release_proof_epoch(proof->epoch); semantic_verify_root_clear(proof->root); for (size_t i = 0; i < proof->cache_nr; i++) free(proof->entry_identities[i].name); diff --git a/semantic-verify.h b/semantic-verify.h index 87692a3e88a424..68aecff252b864 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -2,10 +2,13 @@ #define SEMANTIC_VERIFY_H struct index_state; +struct attr_source_snapshot; struct semantic_verify_proof; struct semantic_verify_options { unsigned int nr_threads; + const struct attr_source_snapshot *attr_snapshot; + unsigned int require_proof_epoch : 1; unsigned int validate_filter_scope : 1; }; @@ -63,6 +66,9 @@ int semantic_verify_apply_after_closure( const struct semantic_verify_proof *proof); int semantic_verify_root_is_stable( const struct semantic_verify_proof *proof); +int semantic_verify_start_token_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof); void semantic_verify_proof_clear(struct semantic_verify_proof *proof); /* Introspection used by the semantic verifier test helper. */ diff --git a/wt-status.c b/wt-status.c index f7769464cc2907..affbd91f7afb32 100644 --- a/wt-status.c +++ b/wt-status.c @@ -29,6 +29,7 @@ #include "column.h" #include "read-cache.h" #include "setup.h" +#include "semantic-verify.h" #include "strbuf.h" #include "trace.h" #include "trace2.h" @@ -956,6 +957,41 @@ static int wt_status_collect_untracked_1( return used_untracked_cache; } +static struct semantic_verify_proof *wt_status_prepare_semantic_verify( + struct wt_status *s, int require_untracked) +{ + struct index_state *istate = s->repo->index; + struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; + struct semantic_verify_proof *proof = NULL; + int ret; + + if (!fstat_is_reliable() || istate->split_index || + require_untracked || + s->show_untracked_files != SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode || s->pathspec.nr || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || + istate->sparse_index != INDEX_EXPANDED || + !fsmonitor_has_pending_token(istate) || + !fsmonitor_pending_token_from_provider(istate) || + !clean_status_fsmonitor_semantic_adoption_needed(istate)) + return NULL; + + options.require_proof_epoch = 1; + options.validate_filter_scope = + clean_status_filter_scope_needs_validation(istate); + options.attr_snapshot = s->attr_source_snapshot; + trace2_region_enter("status", "semantic_verify", s->repo); + ret = semantic_verify_prepare(istate, &options, &proof); + trace2_data_intmax("status", s->repo, + "semantic_verify/prepared", !ret); + trace2_region_leave("status", "semantic_verify", s->repo); + if (ret) { + semantic_verify_proof_clear(proof); + return NULL; + } + return proof; +} + static int wt_status_collect_untracked(struct wt_status *s) { if (s->untracked_from_token_closure && !s->show_ignored_mode) @@ -969,6 +1005,7 @@ static int wt_status_collect_untracked(struct wt_status *s) struct wt_status_token_closure { struct wt_status *status; unsigned int refresh_flags; + int require_untracked; int can_prime; int untracked_ready; struct string_list staged_untracked; @@ -1050,6 +1087,19 @@ static void wt_status_reset_attr_snapshot_if_changed(struct wt_status *s) "semantic/attribute-epoch-rejected", 1); } +static void wt_status_discard_semantic_verify( + struct wt_status *s, struct semantic_verify_proof **proof, + const char *reason) +{ + if (!*proof) + return; + trace2_data_string("status", s->repo, "semantic_verify/discard", + reason); + semantic_verify_proof_clear(*proof); + *proof = NULL; + git_attr_invalidate_all(); +} + static void wt_status_refresh_for_token( struct wt_status *s, unsigned int refresh_flags, struct clean_status_proof_epoch **epoch, int *refresh_result) @@ -1111,13 +1161,15 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); break; } - if (closure->untracked_ready) { + if (closure->untracked_ready || + !closure->require_untracked) { if (reliable_stat) clean_status_mark_fsmonitor_config_valid( istate, istate->fsmonitor_last_update_pending); clean_status_release_proof_epoch(scan_epoch); - fsmonitor_accept_pending_token(istate); + fsmonitor_accept_pending_token( + istate, closure->untracked_ready); return 1; } break; @@ -1150,17 +1202,80 @@ static int wt_status_close_ordinary_fsmonitor_token( return 0; } +enum wt_status_token_closure_result { + WT_STATUS_TOKEN_CLOSURE_FALLBACK = -1, + WT_STATUS_TOKEN_CLOSURE_RETRY, + WT_STATUS_TOKEN_CLOSURE_ACCEPTED, +}; + +static enum wt_status_token_closure_result +wt_status_close_semantic_fsmonitor_token( + struct wt_status_token_closure *closure, + struct semantic_verify_proof **proof) +{ + struct wt_status *s = closure->status; + struct index_state *istate = s->repo->index; + enum fsmonitor_token_result result; + int applied; + + if (!semantic_verify_start_token_is_current(istate, *proof)) { + wt_status_discard_semantic_verify( + s, proof, "start-token-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + closure->queries++; + result = fsmonitor_query_pending_token( + istate, closure->untracked_ready); + if (result != FSMONITOR_TOKEN_CLEAN) { + wt_status_discard_semantic_verify( + s, proof, "token-reset"); + if (fsmonitor_token_requires_rescan(result)) + return WT_STATUS_TOKEN_CLOSURE_RETRY; + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + applied = semantic_verify_apply_after_closure(istate, *proof); + if (applied < 0) { + wt_status_reset_attr_snapshot_if_changed(s); + wt_status_discard_semantic_verify( + s, proof, "closure-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + closure->refresh_result |= refresh_index( + istate, closure->refresh_flags, &s->pathspec, NULL, NULL); + trace2_data_intmax("status", s->repo, + "fsmonitor_token/semantic-closed", 1); + if (!wt_status_attr_snapshot_matches(s) || + clean_status_worktree_manifest_needs_refresh(istate)) { + wt_status_reset_attr_snapshot_if_changed(s); + wt_status_discard_semantic_verify( + s, proof, "attribute-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + clean_status_mark_fsmonitor_config_valid( + istate, istate->fsmonitor_last_update_pending); + semantic_verify_proof_clear(*proof); + *proof = NULL; + fsmonitor_accept_pending_token(istate, closure->untracked_ready); + return WT_STATUS_TOKEN_CLOSURE_ACCEPTED; +} + static int wt_status_close_fsmonitor_token( - struct wt_status *s, unsigned int refresh_flags, - int require_untracked, int refreshed_before_closure) + struct wt_status *s, struct semantic_verify_proof *proof, + unsigned int refresh_flags, int require_untracked, + int refreshed_before_closure) { struct index_state *istate = s->repo->index; struct wt_status_token_closure closure = { .status = s, .refresh_flags = refresh_flags, + .require_untracked = require_untracked, .staged_untracked = STRING_LIST_INIT_DUP, .staged_ignored = STRING_LIST_INIT_DUP, }; + enum wt_status_token_closure_result result; refresh_fsmonitor(istate); if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || @@ -1169,6 +1284,8 @@ static int wt_status_close_fsmonitor_token( wt_status_attr_snapshot_matches(s) && !clean_status_worktree_manifest_needs_refresh(istate); + wt_status_discard_semantic_verify( + s, &proof, "provider-unavailable"); if (!refreshed_before_closure && attr_inputs_match) return refresh_index( istate, refresh_flags, &s->pathspec, @@ -1198,12 +1315,26 @@ static int wt_status_close_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); if (wt_status_refresh_invalidated_manifest(s)) goto fallback; + + if (proof) { + result = wt_status_close_semantic_fsmonitor_token( + &closure, &proof); + if (result == WT_STATUS_TOKEN_CLOSURE_ACCEPTED) + goto accepted; + if (result == WT_STATUS_TOKEN_CLOSURE_FALLBACK) + goto fallback; + wt_status_reset_attr_snapshot_if_changed(s); + if (wt_status_refresh_invalidated_manifest(s)) + goto fallback; + } + if (wt_status_close_ordinary_fsmonitor_token( &closure, refreshed_before_closure)) goto accepted; /* Keep the last valid token and fall back to complete scans. */ fallback: + wt_status_discard_semantic_verify(s, &proof, "fallback"); wt_status_discard_staged_untracked(&closure); fsmonitor_reject_pending_token(istate); if (fstat_is_reliable()) { @@ -1224,9 +1355,13 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked) { + struct semantic_verify_proof *proof; + wt_status_begin_attr_snapshot(s); + refresh_fsmonitor(s->repo->index); + proof = wt_status_prepare_semantic_verify(s, require_untracked); return wt_status_close_fsmonitor_token( - s, refresh_flags, require_untracked, 0); + s, proof, refresh_flags, require_untracked, 0); } static void wt_status_release_attr_snapshot(struct wt_status *s) @@ -1259,7 +1394,7 @@ void wt_status_collect(struct wt_status *s) wt_status_finish_untracked_cache_preload(s); wt_status_begin_attr_snapshot(s); wt_status_close_fsmonitor_token( - s, REFRESH_QUIET | REFRESH_UNMERGED, + s, NULL, REFRESH_QUIET | REFRESH_UNMERGED, s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode, 1); @@ -1287,7 +1422,7 @@ void wt_status_collect(struct wt_status *s) (used_untracked_cache || !s->repo->index->untracked || !s->repo->index->untracked->root)) { if (fsmonitor_pending_token_from_provider(s->repo->index)) - fsmonitor_accept_pending_token(s->repo->index); + fsmonitor_accept_pending_token(s->repo->index, 1); else fsmonitor_reject_pending_token(s->repo->index); } From df00b2920b3ab239a5d1d7435e64d4d24348ded0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 07:26:38 -0500 Subject: [PATCH 196/403] preload-index: bind APFS scans to a stable worktree root A pathname-based directory walk can cross into a replacement worktree or another mount after the scan begins. Metadata from that namespace cannot safely certify entries from the original worktree. Open the worktree directory with O_NOFOLLOW, accept only a local APFS root, and capture its device, filesystem identity, and stat data. Reopen the configured worktree at completion and compare its identity using the namespace helper supplied by S01/P08. Register the Darwin root helpers in Make, CMake, and Meson. Their presence does not yet activate bulk preload. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin-root.c | 100 ++++++++++++++++++++++++ compat/preload-index/bulk-darwin.h | 24 ++++++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 5 +- meson.build | 5 +- preload-index-bulk.h | 2 + 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 compat/preload-index/bulk-darwin-root.c create mode 100644 compat/preload-index/bulk-darwin.h diff --git a/compat/preload-index/bulk-darwin-root.c b/compat/preload-index/bulk-darwin-root.c new file mode 100644 index 00000000000000..f12f730761c99c --- /dev/null +++ b/compat/preload-index/bulk-darwin-root.c @@ -0,0 +1,100 @@ +#include "git-compat-util.h" + +#include + +#include "compat/preload-index/bulk-darwin.h" +#include "path-namespace.h" +#include "repository.h" +#include "preload-index-bulk.h" + +static int same_fsid(const fsid_t *a, const fsid_t *b) +{ + return !memcmp(a, b, sizeof(*a)); +} + +static int stat_local_apfs(int fd, struct stat *st, struct statfs *fs) +{ + if (fstat(fd, st) || fstatfs(fd, fs)) + return -1; + if (!S_ISDIR(st->st_mode) || !(fs->f_flags & MNT_LOCAL) || + strcmp(fs->f_fstypename, "apfs")) { + errno = EXDEV; + return -1; + } + return 0; +} + +int preload_bulk_darwin_fd_on_root_mount(struct preload_bulk_scan *scan, + int fd, struct stat *st_out) +{ + struct preload_bulk_darwin_data *data = scan->platform_data; + struct statfs fs; + struct stat st; + + if (stat_local_apfs(fd, &st, &fs)) + return -1; + if (st.st_dev != data->root_stat.st_dev || + !same_fsid(&fs.f_fsid, &data->root_fsid)) { + errno = EXDEV; + return -1; + } + if (st_out) + *st_out = st; + return 0; +} + +const char *preload_bulk_darwin_open_root(struct preload_bulk_scan *scan) +{ + struct preload_bulk_darwin_data *data; + struct statfs fs; + struct stat st; + + CALLOC_ARRAY(data, 1); + scan->platform_data = data; + scan->root_fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (scan->root_fd < 0 || + stat_local_apfs(scan->root_fd, &st, &fs)) + return "unsupported-filesystem"; + return NULL; +} + +const char *preload_bulk_darwin_snapshot_root(struct preload_bulk_scan *scan) +{ + struct preload_bulk_darwin_data *data = scan->platform_data; + struct statfs fs; + struct stat st; + + if (stat_local_apfs(scan->root_fd, &st, &fs)) + return "unsupported-filesystem"; + data->root_stat = st; + data->root_fsid = fs.f_fsid; + return NULL; +} + +const char *preload_bulk_darwin_validate_root(struct preload_bulk_scan *scan) +{ + struct preload_bulk_darwin_data *data = scan->platform_data; + struct stat root_after; + int fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + + if (fd < 0 || + preload_bulk_darwin_fd_on_root_mount(scan, fd, &root_after) || + !path_namespace_stat_equal(&data->root_stat, &root_after)) { + if (fd >= 0) + close(fd); + return "namespace-race"; + } + close(fd); + return NULL; +} + +void preload_bulk_darwin_release(struct preload_bulk_scan *scan) +{ + if (scan->root_fd >= 0) { + close(scan->root_fd); + scan->root_fd = -1; + } + FREE_AND_NULL(scan->platform_data); +} diff --git a/compat/preload-index/bulk-darwin.h b/compat/preload-index/bulk-darwin.h new file mode 100644 index 00000000000000..d96ed99240c3d7 --- /dev/null +++ b/compat/preload-index/bulk-darwin.h @@ -0,0 +1,24 @@ +#ifndef PRELOAD_INDEX_BULK_DARWIN_H +#define PRELOAD_INDEX_BULK_DARWIN_H + +#ifdef __APPLE__ + +#include + +struct preload_bulk_scan; + +struct preload_bulk_darwin_data { + struct stat root_stat; + fsid_t root_fsid; +}; + +int preload_bulk_darwin_fd_on_root_mount(struct preload_bulk_scan *scan, + int fd, struct stat *st_out); +const char *preload_bulk_darwin_open_root(struct preload_bulk_scan *scan); +const char *preload_bulk_darwin_snapshot_root(struct preload_bulk_scan *scan); +const char *preload_bulk_darwin_validate_root(struct preload_bulk_scan *scan); +void preload_bulk_darwin_release(struct preload_bulk_scan *scan); + +#endif /* __APPLE__ */ + +#endif /* PRELOAD_INDEX_BULK_DARWIN_H */ diff --git a/config.mak.uname b/config.mak.uname index af3f927d8b8a50..31f4524d1158ab 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -163,6 +163,7 @@ ifeq ($(uname_S),Darwin) HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/darwin/procinfo.o PRELOAD_INDEX_BULK_BACKEND = darwin + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-darwin-root.o ifeq ($(uname_M),arm64) HOMEBREW_PREFIX = /opt/homebrew diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index a9e4e6aaa9d10c..67fb3f2d9ba7c8 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -275,7 +275,10 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") - list(APPEND compat_SOURCES compat/darwin/procinfo.c) + add_compile_definitions(USE_ST_TIMESPEC) + list(APPEND compat_SOURCES + compat/darwin/procinfo.c + compat/preload-index/bulk-darwin-root.c) endif() if(CMAKE_SYSTEM_NAME STREQUAL "Windows") diff --git a/meson.build b/meson.build index 1f8667e551a9c9..9134428deb5d31 100644 --- a/meson.build +++ b/meson.build @@ -1344,7 +1344,10 @@ if host_machine.system() == 'linux' elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' - compat_sources += 'compat/darwin/procinfo.c' + compat_sources += [ + 'compat/darwin/procinfo.c', + 'compat/preload-index/bulk-darwin-root.c', + ] libgit_sources += [ 'preload-index-bulk-index.c', 'preload-index-bulk-thread.c', diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 64f5e9cc9a167d..9e8b085160a873 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -55,8 +55,10 @@ struct preload_bulk_backend { }; struct preload_bulk_scan { + struct repository *repo; struct index_state *istate; const struct preload_bulk_backend *backend; + void *platform_data; struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; From 327518e41932d6a8b2df03bb5605219b0da9327a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:46:57 -0500 Subject: [PATCH 197/403] wt-status: close tracked proofs before scanning untracked paths An untracked-cache preload can inspect cached excludes and directory state before tracked semantic adoption restores verified stat data. One provider response also cannot certify an untracked traversal performed after the tracked scan that response closes. Defer provider-backed untracked validation until the tracked proof has been applied and its first query has closed. Prime the untracked cache afterward, issue a second closing query, and recheck the full tracked proof before accepting either result. If the later query reports a change, invalidate both results, reprime during ordinary closure, and retry within the existing query bound. Factor the existing proof-current checks into the predicate used by proof application and deferred closure. Preserve automatic untracked preload when no provider is enabled or file identity is unreliable. Fall back to a complete scan if untracked validation or token closure fails. Add prerequisite-guarded scripted cases for successful deferred scans, failed untracked closure, and a change reported by the second closing query. Signed-off-by: Taylor Blau --- dir.c | 3 +- semantic-verify.c | 34 +++++++++++---- semantic-verify.h | 6 +++ t/t7519-status-fsmonitor.sh | 75 +++++++++++++++++++++++++++++---- wt-status.c | 82 ++++++++++++++++++++++++++++++++----- 5 files changed, 175 insertions(+), 25 deletions(-) diff --git a/dir.c b/dir.c index b2a4e4b2e5adc4..7e8b55638bdc8d 100644 --- a/dir.c +++ b/dir.c @@ -312,7 +312,8 @@ static void preload_fsmonitor_excludes_from_index( goto next; ce = preload->istate->cache[pos]; if (!S_ISREG(ce->ce_mode) || - !(ce->ce_flags & CE_FSMONITOR_VALID) || + (!(ce->ce_flags & CE_FSMONITOR_VALID) && + fstat_is_reliable()) || ce_skip_worktree(ce) || (ce->ce_flags & CE_REMOVE) || ce_intent_to_add(ce)) diff --git a/semantic-verify.c b/semantic-verify.c index 277b71f7e6d38f..cdd179fdef4215 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -259,6 +259,19 @@ int semantic_verify_start_token_is_current( istate, proof->epoch)); } +int semantic_verify_proof_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + return istate && proof && proof->istate == istate && + proof->cache_nr == istate->cache_nr && + !proof->namespace_unstable && + semantic_verify_root_is_stable(proof) && + (!proof->epoch_required || + clean_status_proof_epoch_content_matches( + istate, proof->epoch)); +} + void semantic_verify_get_stats(const struct semantic_verify_proof *proof, struct semantic_verify_stats *stats) { @@ -296,13 +309,7 @@ int semantic_verify_apply_after_closure( int poisoned = 0; size_t validated_updates = 0; - if (!istate || !proof || proof->istate != istate || - proof->cache_nr != istate->cache_nr || - proof->namespace_unstable || - !semantic_verify_root_is_stable(proof) || - (proof->epoch_required && - !clean_status_proof_epoch_content_matches( - istate, proof->epoch))) + if (!semantic_verify_proof_is_current(istate, proof)) return -1; if (proof->active_filters) { trace2_data_intmax("semantic_verify", istate->repo, @@ -399,6 +406,19 @@ int semantic_verify_apply_after_closure( return applied; } +int semantic_verify_accept_filter_scope( + struct index_state *istate, + const struct semantic_verify_proof *proof) +{ + if (!proof || !proof->filter_scope_checked) + return 0; + if (!proof->epoch_required || proof->active_filters || + !semantic_verify_proof_is_current(istate, proof)) + return -1; + clean_status_mark_filter_scope_valid(istate); + return 1; +} + void semantic_verify_proof_clear(struct semantic_verify_proof *proof) { if (!proof) diff --git a/semantic-verify.h b/semantic-verify.h index 68aecff252b864..1895a384e28f2d 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -64,11 +64,17 @@ int semantic_verify_prepare(struct index_state *istate, int semantic_verify_apply_after_closure( struct index_state *istate, const struct semantic_verify_proof *proof); +int semantic_verify_accept_filter_scope( + struct index_state *istate, + const struct semantic_verify_proof *proof); int semantic_verify_root_is_stable( const struct semantic_verify_proof *proof); int semantic_verify_start_token_is_current( struct index_state *istate, const struct semantic_verify_proof *proof); +int semantic_verify_proof_is_current( + struct index_state *istate, + const struct semantic_verify_proof *proof); void semantic_verify_proof_clear(struct semantic_verify_proof *proof); /* Introspection used by the semantic verifier test helper. */ diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index c4cbd1adfb382b..002d5f1a83c1fc 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -594,7 +594,8 @@ prepare_builtin_closure_repo () { ) } -test_expect_success UNTRACKED_CACHE 'builtin clean closure publishes its proof' ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin clean closure publishes its proof' ' test_when_finished "rm -rf builtin-closure-clean" && prepare_builtin_closure_repo builtin-closure-clean untracked && ( @@ -616,7 +617,8 @@ test_expect_success UNTRACKED_CACHE 'builtin clean closure publishes its proof' ) ' -test_expect_success 'builtin changed closure rescans before acceptance' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin changed closure rescans before acceptance' ' test_when_finished "rm -rf builtin-closure-changed" && prepare_builtin_closure_repo builtin-closure-changed && ( @@ -638,7 +640,8 @@ test_expect_success 'builtin changed closure rescans before acceptance' ' ) ' -test_expect_success 'builtin initial trivial response anchors a closure' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin initial trivial response anchors a closure' ' test_when_finished "rm -rf builtin-initial-trivial" && prepare_builtin_closure_repo builtin-initial-trivial && ( @@ -661,7 +664,8 @@ test_expect_success 'builtin initial trivial response anchors a closure' ' ) ' -test_expect_success 'builtin trivial closure can rescan and accept' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin trivial closure can rescan and accept' ' test_when_finished "rm -rf builtin-closure-trivial" && prepare_builtin_closure_repo builtin-closure-trivial && ( @@ -681,7 +685,8 @@ test_expect_success 'builtin trivial closure can rescan and accept' ' ) ' -test_expect_success 'builtin closure rejects three intervening changes' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin closure rejects three intervening changes' ' test_when_finished "rm -rf builtin-closure-exhausted" && prepare_builtin_closure_repo builtin-closure-exhausted && ( @@ -704,14 +709,15 @@ test_expect_success 'builtin closure rejects three intervening changes' ' ) ' -test_expect_success 'builtin closure query errors fall back completely' ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin closure query errors fall back completely' ' test_when_finished "rm -rf builtin-closure-error" && prepare_builtin_closure_repo builtin-closure-error untracked && ( cd builtin-closure-error && sane_unset GIT_TEST_SPLIT_INDEX && test_write_lines visible >visible && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCE \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && test_grep "^? visible$" .git/actual && @@ -1419,4 +1425,59 @@ test_expect_success HARDLINKS,!MINGW,!CYGWIN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'second closing-query change reprimes untracked cache' ' + test_when_finished "rm -rf second-query-changed" && + test_create_repo second-query-changed && + ( + cd second-query-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + printf "aaaa\n" >cached/tracked && + test_write_lines ignored >cached/junk.ignored && + git add .gitignore cached/.gitignore cached/tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + test-tool chmtime =-60 cached/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get cached/tracked) && + printf "bbbb\n" >cached/tracked && + test-tool chmtime =$mtime cached/tracked && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid cached/tracked && + test_grep ! FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_line_count = 1 .git/actual && + test_grep "^1 \.M .* cached/tracked$" .git/actual && + test_trace2_data status fsmonitor_token/semantic-closed 1 \ + <.git/status.trace && + test_trace2_data status \ + fsmonitor_token/untracked-after-semantic 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/apply_count 1 \ + <.git/status.trace && + test_trace2_data status \ + fsmonitor_token/untracked-after-retry 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index + ) +' + test_done diff --git a/wt-status.c b/wt-status.c index affbd91f7afb32..af6fbdbccd40e6 100644 --- a/wt-status.c +++ b/wt-status.c @@ -865,18 +865,36 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); wt_status_begin_attr_snapshot(s); + /* Record the provider token before either filesystem traversal. */ + refresh_fsmonitor(istate); if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) return; + dir_flags = wt_status_untracked_dir_flags(s); - if (has_fsmonitor) { + if (has_fsmonitor && + (!fsmonitor_has_pending_token(istate) || + !fstat_is_reliable())) { s->untracked_cache_preload = untracked_cache_preload_start_fsmonitor_excludes( istate, dir_flags); return; } + /* Restore verified stats before cached excludes inspect them. */ + if (fstat_is_reliable() && !istate->split_index && + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && + fsmonitor_pending_token_from_provider(istate) && + clean_status_fsmonitor_semantic_adoption_needed(istate) && + istate->untracked && istate->untracked->root) { + trace2_data_intmax("status", s->repo, + "fsmonitor_token/untracked-deferred", 1); + return; + } + if (has_fsmonitor) + return; + s->untracked_cache_preload = untracked_cache_preload_start_ordinary(istate, dir_flags); } @@ -958,7 +976,7 @@ static int wt_status_collect_untracked_1( } static struct semantic_verify_proof *wt_status_prepare_semantic_verify( - struct wt_status *s, int require_untracked) + struct wt_status *s) { struct index_state *istate = s->repo->index; struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; @@ -966,8 +984,6 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( int ret; if (!fstat_is_reliable() || istate->split_index || - require_untracked || - s->show_untracked_files != SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode || s->pathspec.nr || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || istate->sparse_index != INDEX_EXPANDED || @@ -1141,8 +1157,14 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); } - if (!closure->untracked_ready && closure->can_prime) + if (!closure->untracked_ready && closure->can_prime) { closure->untracked_ready = wt_status_stage_untracked(closure); + if (closure->queries) + trace2_data_intmax( + "status", s->repo, + "fsmonitor_token/untracked-after-retry", + closure->untracked_ready); + } while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { enum fsmonitor_token_result result; @@ -1216,6 +1238,8 @@ wt_status_close_semantic_fsmonitor_token( struct wt_status *s = closure->status; struct index_state *istate = s->repo->index; enum fsmonitor_token_result result; + int defer_untracked = + closure->can_prime && !closure->untracked_ready; int applied; if (!semantic_verify_start_token_is_current(istate, *proof)) { @@ -1224,9 +1248,10 @@ wt_status_close_semantic_fsmonitor_token( return WT_STATUS_TOKEN_CLOSURE_FALLBACK; } + /* The first query closes the tracked scan and its semantic proof. */ closure->queries++; result = fsmonitor_query_pending_token( - istate, closure->untracked_ready); + istate, defer_untracked ? 0 : closure->untracked_ready); if (result != FSMONITOR_TOKEN_CLEAN) { wt_status_discard_semantic_verify( s, proof, "token-reset"); @@ -1246,11 +1271,47 @@ wt_status_close_semantic_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); trace2_data_intmax("status", s->repo, "fsmonitor_token/semantic-closed", 1); - if (!wt_status_attr_snapshot_matches(s) || - clean_status_worktree_manifest_needs_refresh(istate)) { + if (!semantic_verify_proof_is_current(istate, *proof)) { wt_status_reset_attr_snapshot_if_changed(s); wt_status_discard_semantic_verify( - s, proof, "attribute-drift"); + s, proof, "closure-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + + if (defer_untracked) { + closure->untracked_ready = wt_status_stage_untracked(closure); + trace2_data_intmax( + "status", s->repo, + "fsmonitor_token/untracked-after-semantic", + closure->untracked_ready); + if (!closure->untracked_ready || + closure->queries >= FSMONITOR_TOKEN_MAX_QUERIES) + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + + /* A second query closes the subsequent untracked scan. */ + closure->queries++; + result = fsmonitor_query_pending_token( + istate, closure->untracked_ready); + if (result != FSMONITOR_TOKEN_CLEAN) { + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); + closure->untracked_ready = 0; + wt_status_discard_semantic_verify( + s, proof, "token-reset"); + if (fsmonitor_token_requires_rescan(result)) + return WT_STATUS_TOKEN_CLOSURE_RETRY; + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + if (!semantic_verify_proof_is_current(istate, *proof)) { + wt_status_reset_attr_snapshot_if_changed(s); + wt_status_discard_semantic_verify( + s, proof, "closure-drift"); + return WT_STATUS_TOKEN_CLOSURE_FALLBACK; + } + } + if (semantic_verify_accept_filter_scope(istate, *proof) < 0) { + wt_status_discard_semantic_verify( + s, proof, "filter-scope-drift"); return WT_STATUS_TOKEN_CLOSURE_FALLBACK; } @@ -1304,6 +1365,7 @@ static int wt_status_close_fsmonitor_token( } closure.can_prime = require_untracked && + istate->untracked && istate->untracked->root && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode; closure.untracked_ready = !istate->untracked || @@ -1359,7 +1421,7 @@ int wt_status_refresh_index(struct wt_status *s, wt_status_begin_attr_snapshot(s); refresh_fsmonitor(s->repo->index); - proof = wt_status_prepare_semantic_verify(s, require_untracked); + proof = wt_status_prepare_semantic_verify(s); return wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); } From 9cddda72ceb7bc32855854516fdcb6eddfef330d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 08:30:18 -0500 Subject: [PATCH 198/403] precompose: prepare Unicode configuration before parallel conversion precompose_string_if_needed() lazily reads core.precomposeUnicode on its first non-ASCII input. Concurrent directory workers must not race while initializing repository configuration. Add repo_precompose_utf8_prepare() to resolve that policy before workers start, and add repo_precompose_string_if_needed() for conversion against an explicit repository. Preserve precompose_string_if_needed() as the existing one-argument wrapper. Existing callers retain their behavior; only a caller that opts into explicit preparation separates configuration from parallel conversion. Signed-off-by: Taylor Blau --- compat/precompose_utf8.c | 24 +++++++++++++++++++++--- compat/precompose_utf8.h | 5 +++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/compat/precompose_utf8.c b/compat/precompose_utf8.c index 8077f6235b0cae..2be9f7577f9519 100644 --- a/compat/precompose_utf8.c +++ b/compat/precompose_utf8.c @@ -72,19 +72,32 @@ void probe_utf8_pathname_composition(void) strbuf_release(&path); } -const char *precompose_string_if_needed(const char *in) +void repo_precompose_utf8_prepare(struct repository *repo) +{ + struct repo_config_values *cfg = repo_config_values(repo); + + if (cfg->precomposed_unicode < 0 && + repo_config_get_bool(repo, "core.precomposeunicode", + &cfg->precomposed_unicode)) + cfg->precomposed_unicode = 0; +} + +const char *repo_precompose_string_if_needed(struct repository *repo, + const char *in) { size_t inlen; size_t outlen; - struct repo_config_values *cfg = repo_config_values(the_repository); + struct repo_config_values *cfg = repo_config_values(repo); if (!in) return NULL; if (has_non_ascii(in, (size_t)-1, &inlen)) { iconv_t ic_prec; char *out; + if (cfg->precomposed_unicode < 0) - repo_config_get_bool(the_repository, "core.precomposeunicode", &cfg->precomposed_unicode); + repo_config_get_bool(repo, "core.precomposeunicode", + &cfg->precomposed_unicode); if (cfg->precomposed_unicode != 1) return in; ic_prec = iconv_open(repo_encoding, path_encoding); @@ -104,6 +117,11 @@ const char *precompose_string_if_needed(const char *in) return in; } +const char *precompose_string_if_needed(const char *in) +{ + return repo_precompose_string_if_needed(the_repository, in); +} + const char *precompose_argv_prefix(int argc, const char **argv, const char *prefix) { int i = 0; diff --git a/compat/precompose_utf8.h b/compat/precompose_utf8.h index c7c3cc211e5031..6ec1fa973b7db9 100644 --- a/compat/precompose_utf8.h +++ b/compat/precompose_utf8.h @@ -29,8 +29,13 @@ typedef struct { struct dirent_prec_psx *dirent_nfc; } PREC_DIR; +struct repository; + const char *precompose_argv_prefix(int argc, const char **argv, const char *prefix); const char *precompose_string_if_needed(const char *in); +const char *repo_precompose_string_if_needed(struct repository *repo, + const char *in); +void repo_precompose_utf8_prepare(struct repository *repo); void probe_utf8_pathname_composition(void); PREC_DIR *precompose_utf8_opendir(const char *dirname); From bfafc5c9952b21af61879480ff825734bd61252d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 15:24:55 -0500 Subject: [PATCH 199/403] dir: reject oversized pattern files before allocation add_patterns() rejects pattern files larger than 100 MiB only after allocating and reading their complete contents. An oversized filesystem input can therefore exhaust the memory the limit is meant to protect, or terminate Git when GIT_ALLOC_LIMIT rejects the allocation. Check the size obtained from fstat() before allocating a filesystem pattern buffer. Preserve the existing warning, close the descriptor, and return the existing failure result. Keep the later size check for index-backed fallback data, whose size is unavailable before it is read. Strengthen the existing EXPENSIVE regression by reading its 101 MiB .gitignore under GIT_ALLOC_LIMIT=1m. The old ordering dies in xmallocz(); the early rejection preserves the expected warning without attempting the oversized allocation. Signed-off-by: Taylor Blau --- dir.c | 6 ++++++ t/t0008-ignores.sh | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dir.c b/dir.c index 95d8a1cce90f77..8b8beb1281bf2f 100644 --- a/dir.c +++ b/dir.c @@ -1175,6 +1175,12 @@ static int add_patterns(const char *fname, const char *base, int baselen, return r; } else { size = xsize_t(st.st_size); + if (size > PATTERN_MAX_FILE_SIZE) { + warning("ignoring excessively large pattern file: %s", + fname); + close(fd); + return -1; + } if (size == 0) { if (oid_stat) { fill_stat_data(&oid_stat->stat, &st); diff --git a/t/t0008-ignores.sh b/t/t0008-ignores.sh index ed95faf3272e60..949897c36aa9b2 100755 --- a/t/t0008-ignores.sh +++ b/t/t0008-ignores.sh @@ -959,7 +959,7 @@ test_expect_success EXPENSIVE 'large exclude file ignored in tree' ' test_when_finished "rm .gitignore" && find . -name .gitignore -exec rm "{}" ";" && dd if=/dev/zero of=.gitignore bs=101M count=1 && - git ls-files -o --exclude-standard 2>err && + GIT_ALLOC_LIMIT=1m git ls-files -o --exclude-standard 2>err && echo "warning: ignoring excessively large pattern file: .gitignore" >expect && test_cmp expect err ' From 36b68525f093a5d1dea9a17c45f1e4b9c6ef84e5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:46:20 -0500 Subject: [PATCH 200/403] commit: close fsmonitor tokens across pre-commit hooks An as-is commit refreshes its index before running the pre-commit hook. If the hook rewrites a tracked path without changing its size or mtime, the later in-process status must not certify the earlier refresh as though it covered the hook. For a nonsplit index using an IPC provider, perform the initial refresh through status token closure. After an invoked hook, release the saved attribute snapshot and reopen the last accepted provider token before status runs again. Reject unavailable token state and invalidate the manifest, tracked semantics, and untracked cache before falling back to a complete refresh. Pin the post-hook named index before persisting strong invalidation. Write refreshed state only while its held descriptor, pathname, stored trailer checksum, and in-memory index still match. Preserve a hook-replaced index and the existing reread. Split indexes, platforms without reliable file identity, and non-IPC providers retain their original initial refresh. Add prerequisite-guarded scripted cases for successful post-hook closure without an untracked cache, failed closure with complete worktree refresh, and a hook that updates the index itself. Signed-off-by: Taylor Blau --- builtin/commit.c | 61 ++++++++++++++++++++++++++++++++++++++++++------ fsmonitor-ll.h | 2 ++ fsmonitor.c | 15 ++++++++++++ wt-status.c | 15 ++++++++++++ wt-status.h | 1 + 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 29f339f89a2254..685959875418c2 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -14,12 +14,14 @@ #include "lockfile.h" #include "cache-tree.h" #include "clean-status.h" +#include "clean-status-index.h" #include "color.h" #include "dir.h" #include "editor.h" #include "environment.h" #include "diff.h" #include "commit.h" +#include "fsmonitor-settings.h" #include "add-interactive.h" #include "gettext.h" #include "revision.h" @@ -373,7 +375,8 @@ static void refresh_cache_or_die(int refresh_flags) } static const char *prepare_index(const char **argv, const char *prefix, - const struct commit *current_head, int is_status) + const struct commit *current_head, int is_status, + struct wt_status *s) { struct string_list partial = STRING_LIST_INIT_DUP; struct pathspec pathspec; @@ -506,7 +509,14 @@ static const char *prepare_index(const char **argv, const char *prefix, if (!only && !pathspec.nr) { repo_hold_locked_index(the_repository, &index_lock, LOCK_DIE_ON_ERROR); - refresh_cache_or_die(refresh_flags); + if (!fstat_is_reliable() || + the_repository->index->split_index || + fsm_settings__get_mode(the_repository) != + FSMONITOR_MODE_IPC) + refresh_cache_or_die(refresh_flags); + else if (wt_status_refresh_index( + s, refresh_flags | REFRESH_IN_PORCELAIN, 0)) + die_resolve_conflict("commit"); if (the_repository->index->cache_changed || !cache_tree_fully_valid(the_repository->index->cache_tree)) cache_tree_update(the_repository->index, WRITE_TREE_SILENT); @@ -797,13 +807,29 @@ static int prepare_to_commit(const char *index_file, const char *prefix, int clean_message_contents = (cleanup_mode != COMMIT_MSG_CLEANUP_NONE); int old_display_comment_prefix; int invoked_hook; + int hook_index_matches = 0; + struct clean_status_index_snapshot hook_index = { .fd = -1 }; /* This checks and barfs if author is badly specified */ determine_author_info(author_ident); - if (!no_verify && run_commit_hook(use_editor, index_file, &invoked_hook, - "pre-commit", NULL)) - return 0; + if (!no_verify) { + int hook_failed = run_commit_hook( + use_editor, index_file, &invoked_hook, + "pre-commit", NULL); + + if (invoked_hook && fstat_is_reliable()) + wt_status_invalidate_refresh(s); + if (invoked_hook && fstat_is_reliable() && + commit_style == COMMIT_AS_IS) + hook_index_matches = + !clean_status_index_snapshot_pin( + &hook_index, the_repository->index); + if (hook_failed) { + clean_status_index_snapshot_release(&hook_index); + return 0; + } + } if (squash_message) { /* @@ -1119,10 +1145,29 @@ static int prepare_to_commit(const char *index_file, const char *prefix, else fputs(_(empty_rebase_pick_advice), stderr); } + clean_status_index_snapshot_release(&hook_index); return 0; } if (!no_verify && invoked_hook) { + struct lock_file refresh_lock = LOCK_INIT; + + /* + * Preserve any strong invalidation recorded while status + * closed the post-hook token. The pinned source prevents this + * write from replacing an index updated by the hook. + */ + if (hook_index_matches && + repo_hold_locked_index(the_repository, &refresh_lock, 0) >= 0) { + if (clean_status_index_snapshot_still_matches( + &hook_index, the_repository->index)) + repo_update_index_if_able( + the_repository, &refresh_lock); + else + rollback_lock_file(&refresh_lock); + } + clean_status_index_snapshot_release(&hook_index); + /* * Re-read the index as the pre-commit-commit hook was invoked * and could have updated it. We must do this before we invoke @@ -1455,7 +1500,7 @@ static int dry_run_commit(const char **argv, const char *prefix, int committable; const char *index_file; - index_file = prepare_index(argv, prefix, current_head, 1); + index_file = prepare_index(argv, prefix, current_head, 1, s); committable = run_status(stdout, index_file, prefix, 0, s); rollback_index_files(); @@ -1871,7 +1916,7 @@ int cmd_commit(int argc, if (dry_run) return dry_run_commit(argv, prefix, current_head, &s); - index_file = prepare_index(argv, prefix, current_head, 0); + index_file = prepare_index(argv, prefix, current_head, 0, &s); /* Set up everything for writing the commit object. This includes running hooks, writing the trees, and interacting with the user. */ @@ -1881,6 +1926,7 @@ int cmd_commit(int argc, rollback_index_files(); goto cleanup; } + wt_status_collect_free_buffers(&s); /* Determine parents */ reflog_msg = getenv("GIT_REFLOG_ACTION"); @@ -2019,6 +2065,7 @@ int cmd_commit(int argc, NULL, NULL, NULL, NULL); cleanup: + wt_status_collect_free_buffers(&s); free_commit_extra_headers(extra); commit_list_free(parents); strbuf_release(&author_ident); diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index dc6998d5789a75..9e64d7d8571b87 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -69,6 +69,8 @@ int fsmonitor_invalidate_attributes_path(struct index_state *istate, /* Close a provider token which was obtained before a required scan. */ int fsmonitor_has_pending_token(const struct index_state *istate); int fsmonitor_pending_token_from_provider(const struct index_state *istate); +/* Reopen the last accepted IPC token after an in-process operation. */ +int fsmonitor_reopen_token(struct index_state *istate); enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready); void fsmonitor_accept_pending_token(struct index_state *istate, diff --git a/fsmonitor.c b/fsmonitor.c index 69ed171b040fd1..dd3e529db468f7 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1326,6 +1326,21 @@ int fsmonitor_pending_token_from_provider(const struct index_state *istate) istate->fsmonitor_pending_token_from_provider; } +int fsmonitor_reopen_token(struct index_state *istate) +{ + if (!fstat_is_reliable() || istate->split_index || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) + return 0; + if (istate->fsmonitor_last_update_pending) + return istate->fsmonitor_pending_token_from_provider; + if (!istate->fsmonitor_token_valid || !istate->fsmonitor_last_update) + return 0; + istate->fsmonitor_last_update_pending = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_pending_token_from_provider = 1; + return 1; +} + enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready) { diff --git a/wt-status.c b/wt-status.c index af6fbdbccd40e6..1e49bb55210e70 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1435,6 +1435,21 @@ static void wt_status_release_attr_snapshot(struct wt_status *s) s->attr_snapshot_failed = 0; } +void wt_status_invalidate_refresh(struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + + wt_status_release_attr_snapshot(s); + if (!s->pathspec.nr && !istate->split_index && + fsmonitor_reopen_token(istate)) + return; + if (fsmonitor_has_pending_token(istate)) + fsmonitor_reject_pending_token(istate); + clean_status_invalidate_current_manifest(istate); + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); +} + static int has_unmerged(struct wt_status *s) { int i; diff --git a/wt-status.h b/wt-status.h index 74798dd593aacb..0f7104b4c6ac5f 100644 --- a/wt-status.h +++ b/wt-status.h @@ -168,6 +168,7 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s); int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked); +void wt_status_invalidate_refresh(struct wt_status *s); /* * Collect all changes between the two trees. Changes will be displayed as if From cfb045dcc118d41f891ff03e7c78440636d19277 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:15 -0500 Subject: [PATCH 201/403] preload-index: validate packed APFS directory records getattrlistbulk() returns variable-length records with attribute sets and name offsets supplied by the filesystem. A truncated record, entry error, unexpected attributes, or invalid name reference cannot safely describe an index entry. Add a bounded decoder that checks record alignment and length, required attribute sets, entry errors, record-local name offsets, valid path components, and file metadata before accepting a record. Add six Darwin unit checks for valid file and directory records, entry errors, short records, unexpected attributes, and invalid names. Register the Darwin decoder with Make, CMake, and Meson. Register its six unit checks with Make and Meson. Existing preload behavior remains unchanged. Signed-off-by: Taylor Blau --- Makefile | 2 + compat/preload-index/bulk-darwin.c | 134 ++++++++++++++ compat/preload-index/bulk-darwin.h | 4 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-preload-index-bulk-darwin.c | 193 +++++++++++++++++++++ 7 files changed, 336 insertions(+) create mode 100644 compat/preload-index/bulk-darwin.c create mode 100644 t/unit-tests/u-preload-index-bulk-darwin.c diff --git a/Makefile b/Makefile index 51059dadddf171..a5a0dabc80e45f 100644 --- a/Makefile +++ b/Makefile @@ -1384,6 +1384,7 @@ LIB_OBJS += ws.o ifdef PRELOAD_INDEX_BULK_BACKEND PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-index.o PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o +PRELOAD_INDEX_BULK_OBJS += compat/preload-index/bulk-$(PRELOAD_INDEX_BULK_BACKEND).o PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) endif LIB_OBJS += $(PRELOAD_INDEX_BULK_OBJS) @@ -1558,6 +1559,7 @@ CLAR_TEST_SUITES += u-oidmap CLAR_TEST_SUITES += u-oidtree CLAR_TEST_SUITES += u-path-namespace CLAR_TEST_SUITES += u-prio-queue +CLAR_TEST_SUITES += u-preload-index-bulk-darwin CLAR_TEST_SUITES += u-reftable-basics CLAR_TEST_SUITES += u-reftable-block CLAR_TEST_SUITES += u-reftable-merged diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c new file mode 100644 index 00000000000000..d766d0600151b2 --- /dev/null +++ b/compat/preload-index/bulk-darwin.c @@ -0,0 +1,134 @@ +#include "git-compat-util.h" + +#include +#include + +#include "compat/preload-index/bulk-darwin.h" + +static const attrgroup_t required_common = + ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | + ATTR_CMN_DEVID | ATTR_CMN_OBJTYPE | + ATTR_CMN_CRTIME | ATTR_CMN_MODTIME | ATTR_CMN_CHGTIME | + ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK | + ATTR_CMN_FLAGS | ATTR_CMN_FILEID; +static const attrgroup_t required_dir = ATTR_DIR_MOUNTSTATUS; +static const attrgroup_t required_file = + ATTR_FILE_LINKCOUNT | ATTR_FILE_DATALENGTH; + +static int valid_component(const char *component, size_t len) +{ + return len && + !(len == 1 && component[0] == '.') && + !(len == 2 && component[0] == '.' && component[1] == '.'); +} + +struct preload_bulk_darwin_entry { + const char *name; + uint32_t record_len; + dev_t dev; + fsobj_type_t type; + struct timespec birthtime; + struct timespec mtime; + struct timespec ctime; + uid_t uid; + gid_t gid; + uint32_t access; + uint32_t flags; + uint32_t linkcount; + uint32_t mountstatus; + uint64_t fileid; + off_t size; +}; + +static int decode_entry(const char *record, size_t remaining, + struct preload_bulk_darwin_entry *entry) +{ + uint32_t entry_error = 0; + attribute_set_t returned; + attrreference_t name_ref; + const char *p, *end, *name_ref_at; + size_t name_ref_offset, name_offset, name_remaining; + + if (remaining < sizeof(entry->record_len) + sizeof(returned)) + return -1; + memcpy(&entry->record_len, record, sizeof(entry->record_len)); + if ((entry->record_len % sizeof(uint64_t)) || + entry->record_len < sizeof(entry->record_len) + sizeof(returned) || + entry->record_len > remaining) + return -1; + + p = record + sizeof(entry->record_len); + end = record + entry->record_len; + memcpy(&returned, p, sizeof(returned)); + p += sizeof(returned); + if (returned.commonattr != required_common || + returned.volattr || returned.forkattr) + return -1; + +#define TAKE_ATTR(value) do { \ + if ((size_t)(end - p) < sizeof(value)) \ + return -1; \ + memcpy(&(value), p, sizeof(value)); \ + p += sizeof(value); \ +} while (0) + TAKE_ATTR(entry_error); + if (entry_error) + return -1; + name_ref_at = p; + TAKE_ATTR(name_ref); + TAKE_ATTR(entry->dev); + TAKE_ATTR(entry->type); + TAKE_ATTR(entry->birthtime); + TAKE_ATTR(entry->mtime); + TAKE_ATTR(entry->ctime); + TAKE_ATTR(entry->uid); + TAKE_ATTR(entry->gid); + TAKE_ATTR(entry->access); + TAKE_ATTR(entry->flags); + TAKE_ATTR(entry->fileid); + + if (entry->type == VDIR) { + if (returned.dirattr != required_dir || + returned.fileattr) + return -1; + TAKE_ATTR(entry->mountstatus); + } else { + if (returned.dirattr || + (returned.fileattr & ~required_file)) + return -1; + TAKE_ATTR(entry->linkcount); + TAKE_ATTR(entry->size); + if ((entry->type == VREG || entry->type == VLNK) && + returned.fileattr != required_file) + return -1; + } +#undef TAKE_ATTR + + if (name_ref.attr_dataoffset < 0 || + (name_ref.attr_dataoffset % (int32_t)sizeof(uint32_t))) + return -1; + name_ref_offset = name_ref_at - record; + if ((uint32_t)name_ref.attr_dataoffset > + entry->record_len - name_ref_offset) + return -1; + name_offset = name_ref_offset + name_ref.attr_dataoffset; + name_remaining = entry->record_len - name_offset; + entry->name = record + name_offset; + if (!name_ref.attr_length || + name_ref.attr_length > name_remaining || + entry->name < p) + return -1; + if (entry->name[name_ref.attr_length - 1] || + memchr(entry->name, '\0', name_ref.attr_length - 1) || + !valid_component(entry->name, name_ref.attr_length - 1) || + memchr(entry->name, '/', name_ref.attr_length - 1)) + return -1; + return 0; +} + +int preload_bulk_darwin_decode_record(const char *record, size_t len) +{ + struct preload_bulk_darwin_entry entry; + + return decode_entry(record, len, &entry); +} diff --git a/compat/preload-index/bulk-darwin.h b/compat/preload-index/bulk-darwin.h index d96ed99240c3d7..c69886ac972268 100644 --- a/compat/preload-index/bulk-darwin.h +++ b/compat/preload-index/bulk-darwin.h @@ -12,6 +12,10 @@ struct preload_bulk_darwin_data { fsid_t root_fsid; }; +/* + * Exposed so that tests can validate kernel-supplied records directly. + */ +int preload_bulk_darwin_decode_record(const char *record, size_t len); int preload_bulk_darwin_fd_on_root_mount(struct preload_bulk_scan *scan, int fd, struct stat *st_out); const char *preload_bulk_darwin_open_root(struct preload_bulk_scan *scan); diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 67fb3f2d9ba7c8..e53ff08cd07b23 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -278,6 +278,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(USE_ST_TIMESPEC) list(APPEND compat_SOURCES compat/darwin/procinfo.c + compat/preload-index/bulk-darwin.c compat/preload-index/bulk-darwin-root.c) endif() diff --git a/meson.build b/meson.build index 9134428deb5d31..2bdff5bb374325 100644 --- a/meson.build +++ b/meson.build @@ -1346,6 +1346,7 @@ elif host_machine.system() == 'windows' elif host_machine.system() == 'darwin' compat_sources += [ 'compat/darwin/procinfo.c', + 'compat/preload-index/bulk-darwin.c', 'compat/preload-index/bulk-darwin-root.c', ] libgit_sources += [ diff --git a/t/meson.build b/t/meson.build index d8734eae5ff77f..d22603637bc5a6 100644 --- a/t/meson.build +++ b/t/meson.build @@ -11,6 +11,7 @@ clar_test_suites = [ 'unit-tests/u-oidmap.c', 'unit-tests/u-oidtree.c', 'unit-tests/u-path-namespace.c', + 'unit-tests/u-preload-index-bulk-darwin.c', 'unit-tests/u-prio-queue.c', 'unit-tests/u-reftable-basics.c', 'unit-tests/u-reftable-block.c', diff --git a/t/unit-tests/u-preload-index-bulk-darwin.c b/t/unit-tests/u-preload-index-bulk-darwin.c new file mode 100644 index 00000000000000..f20a7bbd704ac5 --- /dev/null +++ b/t/unit-tests/u-preload-index-bulk-darwin.c @@ -0,0 +1,193 @@ +#include "unit-test.h" + +#ifdef __APPLE__ + +#include +#include + +#include "compat/preload-index/bulk-darwin.h" + +#define REQUIRED_COMMON \ + (ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | \ + ATTR_CMN_DEVID | ATTR_CMN_OBJTYPE | \ + ATTR_CMN_CRTIME | ATTR_CMN_MODTIME | ATTR_CMN_CHGTIME | \ + ATTR_CMN_OWNERID | ATTR_CMN_GRPID | ATTR_CMN_ACCESSMASK | \ + ATTR_CMN_FLAGS | ATTR_CMN_FILEID) +#define REQUIRED_FILE (ATTR_FILE_LINKCOUNT | ATTR_FILE_DATALENGTH) + +struct test_record { + uint32_t record_len; + attribute_set_t returned; + uint32_t error; + attrreference_t name_ref; + dev_t dev; + fsobj_type_t type; + struct timespec birthtime; + struct timespec mtime; + struct timespec ctime; + uid_t uid; + gid_t gid; + uint32_t access; + uint32_t flags; + uint64_t fileid; + uint32_t linkcount; + off_t size; + char name[8]; +} __attribute__((packed)); + +static struct test_record make_record(void) +{ + struct test_record record = { + .record_len = sizeof(record), + .returned = { + .commonattr = REQUIRED_COMMON, + .fileattr = REQUIRED_FILE, + }, + .name_ref = { + .attr_dataoffset = offsetof(struct test_record, name) - + offsetof(struct test_record, name_ref), + .attr_length = 5, + }, + .dev = 1, + .type = VREG, + .uid = 1, + .gid = 1, + .access = 0644, + .fileid = 1, + .linkcount = 1, + .size = 1, + .name = "file", + }; + + return record; +} + +static void check_malformed(struct test_record *record, size_t len) +{ + cl_assert(preload_bulk_darwin_decode_record((char *)record, len) < 0); +} + +#endif /* __APPLE__ */ + +void test_preload_index_bulk_darwin__accepts_valid_record(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + cl_assert_equal_i(0, preload_bulk_darwin_decode_record((char *)&record, + sizeof(record))); +#endif +} + +void test_preload_index_bulk_darwin__accepts_valid_directory_record(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + record.returned.fileattr = 0; + record.returned.dirattr = ATTR_DIR_MOUNTSTATUS; + record.type = VDIR; + cl_assert_equal_i(0, preload_bulk_darwin_decode_record((char *)&record, + sizeof(record))); +#endif +} + +void test_preload_index_bulk_darwin__rejects_entry_error(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + record.error = EIO; + check_malformed(&record, sizeof(record)); +#endif +} + +void test_preload_index_bulk_darwin__rejects_short_record(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record = make_record(); + + check_malformed(&record, + sizeof(uint32_t) + sizeof(attribute_set_t) - 1); + + record.record_len = sizeof(record) + sizeof(uint64_t); + check_malformed(&record, sizeof(record)); + + record.record_len = sizeof(uint64_t); + check_malformed(&record, sizeof(record)); + + record.record_len = sizeof(record) - 1; + check_malformed(&record, sizeof(record)); + + record.record_len = (offsetof(struct test_record, type) + + sizeof(uint64_t) - 1) & + ~(sizeof(uint64_t) - 1); + check_malformed(&record, sizeof(record)); +#endif +} + +void test_preload_index_bulk_darwin__rejects_wrong_returned_attributes(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record; + + record = make_record(); + record.returned.commonattr &= ~ATTR_CMN_NAME; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.returned.volattr = 1; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.returned.fileattr &= ~ATTR_FILE_DATALENGTH; + check_malformed(&record, sizeof(record)); +#endif +} + +void test_preload_index_bulk_darwin__rejects_invalid_name_reference(void) +{ +#ifndef __APPLE__ + cl_skip(); +#else + struct test_record record; + + record = make_record(); + record.name_ref.attr_dataoffset = -4; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_dataoffset = 2; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_dataoffset = INT32_MAX; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_length = UINT32_MAX; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name_ref.attr_dataoffset = 0; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name[1] = '\0'; + check_malformed(&record, sizeof(record)); + + record = make_record(); + record.name[1] = '/'; + check_malformed(&record, sizeof(record)); +#endif +} From 711aea8d4aa3ede460dc47710263c2bd314462e4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:16 -0500 Subject: [PATCH 202/403] preload-index: walk APFS directories beneath a held root Descriptor limits can force a queued directory to be reopened after its parent was scanned. Reopening through an ordinary worktree pathname could follow a replacement directory or symlink into another namespace. Open immediate children with O_NOFOLLOW and reopen relative paths below the held root with O_NOFOLLOW_ANY. Visit only directories with tracked descendants on the original APFS mount, and compare parent and child identities before and after enumeration. Discard the complete scan after malformed records or changed directory identities. Leave multiply-linked tracked files to ordinary lstat. Allocate one 1 MiB record buffer per active worker; the backend is still not invoked from preload_index(), so existing behavior is unchanged. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 326 ++++++++++++++++++++++++++++ contrib/buildsystems/CMakeLists.txt | 3 +- preload-index-bulk-index.c | 13 ++ preload-index-bulk-thread.c | 31 ++- preload-index-bulk.h | 17 ++ 5 files changed, 383 insertions(+), 7 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index d766d0600151b2..22a0e16def307f 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -3,7 +3,16 @@ #include #include +#include "compat/precompose_utf8.h" #include "compat/preload-index/bulk-darwin.h" +#include "path-namespace.h" +#include "preload-index-bulk.h" + +#ifndef SF_FIRMLINK +#define SF_FIRMLINK 0x00800000 +#endif + +#define PRELOAD_INDEX_BULK_BUFFER_SIZE (1024 * 1024) static const attrgroup_t required_common = ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_ERROR | ATTR_CMN_NAME | @@ -22,6 +31,92 @@ static int valid_component(const char *component, size_t len) !(len == 2 && component[0] == '.' && component[1] == '.'); } +static int valid_relative_path(const char *path) +{ + const char *component = path; + + if (!strcmp(path, ".")) + return 1; + if (!*path || *path == '/') + return 0; + for (;;) { + const char *slash = strchr(component, '/'); + size_t len = slash ? (size_t)(slash - component) : + strlen(component); + + if (!valid_component(component, len)) + return 0; + if (!slash) + return 1; + component = slash + 1; + } +} + +static int preload_bulk_darwin_open_dir_at( + struct preload_bulk_worker *worker UNUSED, + int parent_fd, const char *name) +{ + if (!valid_component(name, strlen(name)) || strchr(name, '/')) { + errno = EINVAL; + return -1; + } + return openat(parent_fd, name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); +} + +static int preload_bulk_darwin_open_relative(struct preload_bulk_scan *scan, + const char *path) +{ + if (!valid_relative_path(path)) { + errno = EINVAL; + return -1; + } + +#ifdef O_NOFOLLOW_ANY + return openat(scan->root_fd, path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW_ANY | O_CLOEXEC); +#else + errno = ENOTSUP; + return -1; +#endif +} + +static mode_t vnode_mode(fsobj_type_t type) +{ + switch (type) { + case VREG: + return S_IFREG; + case VLNK: + return S_IFLNK; + default: + return 0; + } +} + +static int fill_file_stat(struct stat *st, dev_t dev, uint64_t fileid, + fsobj_type_t type, struct timespec mtime, + struct timespec ctime, uid_t uid, gid_t gid, + uint32_t access, uint32_t linkcount, off_t size) +{ + mode_t mode = vnode_mode(type); + + if (!mode || size < 0 || + ((access & S_IFMT) && (access & S_IFMT) != mode) || + (access & ~(S_IFMT | 07777))) + return -1; + memset(st, 0, sizeof(*st)); + st->st_dev = dev; + st->st_ino = fileid; + st->st_mode = mode | (access & 07777); + st->st_uid = uid; + st->st_gid = gid; + st->st_nlink = linkcount; + st->st_size = size; + st->st_mtimespec = mtime; + st->st_ctimespec = ctime; + return 0; +} + struct preload_bulk_darwin_entry { const char *name; uint32_t record_len; @@ -132,3 +227,234 @@ int preload_bulk_darwin_decode_record(const char *record, size_t len) return decode_entry(record, len, &entry); } + +static struct preload_bulk_dir_identity directory_identity( + const struct stat *st) +{ + struct preload_bulk_dir_identity result = { + .stat = *st, + .complete = 1, + }; + + return result; +} + +static int directory_identity_matches( + const struct preload_bulk_dir_identity *before, + const struct stat *after) +{ + if (before->complete) + return path_namespace_stat_equal(&before->stat, after); + return S_ISDIR(after->st_mode) && + before->stat.st_dev == after->st_dev && + before->stat.st_ino == after->st_ino && + before->stat.st_birthtimespec.tv_sec == + after->st_birthtimespec.tv_sec && + before->stat.st_birthtimespec.tv_nsec == + after->st_birthtimespec.tv_nsec && + before->stat.st_mtimespec.tv_sec == after->st_mtimespec.tv_sec && + before->stat.st_mtimespec.tv_nsec == + after->st_mtimespec.tv_nsec && + before->stat.st_ctimespec.tv_sec == after->st_ctimespec.tv_sec && + before->stat.st_ctimespec.tv_nsec == + after->st_ctimespec.tv_nsec; +} + +static int enumerate_directory(struct preload_bulk_worker *worker, + const struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_darwin_data *data = scan->platform_data; + struct attrlist attrs = { 0 }; + char *buf = worker->buffer; + size_t path_prefix_len; + + if (!buf) { + buf = xmalloc(PRELOAD_INDEX_BULK_BUFFER_SIZE); + worker->buffer = buf; + } + + attrs.bitmapcount = ATTR_BIT_MAP_COUNT; + attrs.commonattr = required_common; + attrs.dirattr = required_dir; + attrs.fileattr = required_file; + worker->dirs++; + strbuf_reset(&worker->path); + if (strcmp(task->path, ".")) { + strbuf_addstr(&worker->path, task->path); + strbuf_addch(&worker->path, '/'); + } + path_prefix_len = worker->path.len; + + for (;;) { + int nr = getattrlistbulk(fd, &attrs, buf, + PRELOAD_INDEX_BULK_BUFFER_SIZE, + FSOPT_NOFOLLOW | + FSOPT_PACK_INVAL_ATTRS); + char *record = buf; + + worker->bulk_calls++; + if (nr < 0) + return -1; + if (!nr) + return 0; + + for (int i = 0; i < nr; i++) { + struct preload_bulk_darwin_entry entry; + struct stat st; + const char *path_name; + size_t remaining; + int pos; + + remaining = buf + PRELOAD_INDEX_BULK_BUFFER_SIZE - record; + if (decode_entry(record, remaining, &entry)) + goto malformed; + worker->entries++; + + /* + * The caller prepares the repository's Unicode policy + * before starting workers, so this is read-only here. + */ + path_name = repo_precompose_string_if_needed(scan->repo, + entry.name); + strbuf_setlen(&worker->path, path_prefix_len); + strbuf_addstr(&worker->path, path_name); + if (path_name != entry.name) + free((char *)path_name); + + if (entry.type == VDIR) { + struct preload_bulk_dir_identity child_identity = { + .stat = { + .st_dev = entry.dev, + .st_ino = entry.fileid, + .st_birthtimespec = + entry.birthtime, + .st_mtimespec = entry.mtime, + .st_ctimespec = entry.ctime, + }, + }; + + if (!preload_bulk_index_has_tracked_descendants( + scan, worker->path.buf, + worker->path.len)) + goto next_record; + if (((entry.access & S_IFMT) && + (entry.access & S_IFMT) != S_IFDIR) || + (entry.access & ~(S_IFMT | 07777))) + goto malformed_record; + if (entry.dev != data->root_stat.st_dev || + entry.mountstatus || + (entry.flags & SF_FIRMLINK)) { + goto next_record; + } + preload_bulk_schedule_directory( + worker, fd, parent_identity, + &child_identity, entry.name, + worker->path.buf, + worker->path.len); + goto next_record; + } + + pos = preload_bulk_index_position(scan, worker->path.buf, + worker->path.len); + if (pos < 0) + goto next_record; + if (entry.dev != data->root_stat.st_dev) { + goto next_record; + } + if (entry.type != VREG && entry.type != VLNK) + goto next_record; + if (entry.linkcount != 1) + goto next_record; + if (fill_file_stat(&st, entry.dev, entry.fileid, + entry.type, entry.mtime, entry.ctime, + entry.uid, entry.gid, entry.access, + entry.linkcount, entry.size)) + goto malformed_record; + preload_bulk_record_tracked(worker, pos, &st); + +next_record: + record += entry.record_len; + continue; + +malformed_record: + worker->malformed++; + goto next_record; + } + } + +malformed: + worker->malformed++; + return -1; +} + +static int scan_directory(struct preload_bulk_worker *worker, + struct preload_bulk_task *task) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_dir_identity before_identity; + struct stat before, after; + int fd = task->fd; + int ret = -1; + + if (fd < 0) + fd = preload_bulk_darwin_open_relative(scan, task->path); + if (fd < 0) + goto out; + if (preload_bulk_darwin_fd_on_root_mount(scan, fd, &before)) { + if (errno != EXDEV) + goto out; + ret = 0; + goto out; + } + /* + * A child may have been replaced after its parent returned the bulk + * record, or while this task waited in the queue. + */ + if (task->has_child_identity && + !directory_identity_matches(&task->child_identity, &before)) { + worker->changed_dirs++; + ret = 0; + goto out; + } + before_identity = directory_identity(&before); + if (enumerate_directory(worker, task, fd, &before_identity)) + goto out; + if (fstat(fd, &after)) + goto out; + if (!directory_identity_matches(&before_identity, &after)) + worker->changed_dirs++; + ret = 0; + +out: + if (task->has_parent_identity) { + struct stat parent_after; + int parent_changed = fd < 0; + + /* + * Resolve ".." through the child descriptor, not the worktree + * path, so a rename cannot redirect this parent check. + */ + if (!parent_changed) + parent_changed = fstatat(fd, "..", &parent_after, + AT_SYMLINK_NOFOLLOW); + if (parent_changed || + !directory_identity_matches(&task->parent_identity, + &parent_after)) + worker->changed_dirs++; + } + if (fd >= 0) + close(fd); + return ret; +} + +static const struct preload_bulk_backend darwin_backend = { + .open_dir_at = preload_bulk_darwin_open_dir_at, + .scan_directory = scan_directory, +}; + +const struct preload_bulk_backend *preload_bulk_platform_backend(void) +{ + return &darwin_backend; +} diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index e53ff08cd07b23..e24ec739720dc5 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -275,9 +275,10 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") - add_compile_definitions(USE_ST_TIMESPEC) + add_compile_definitions(PRECOMPOSE_UNICODE USE_ST_TIMESPEC) list(APPEND compat_SOURCES compat/darwin/procinfo.c + compat/precompose_utf8.c compat/preload-index/bulk-darwin.c compat/preload-index/bulk-darwin-root.c) endif() diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index 3c8bfad7c2a631..623164822b5fdb 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -14,6 +14,19 @@ int preload_bulk_index_position(struct preload_bulk_scan *scan, return index_name_pos_sparse(scan->istate, path, path_len); } +int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, + const char *path, + size_t path_len) +{ + int pos; + + if (path_len > INT_MAX) + return 0; + pos = index_name_pos_sparse(scan->istate, path, path_len); + return preload_bulk_index_pos_has_tracked_descendants( + scan, path, path_len, pos); +} + int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos) diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 0a73d5a1fdeafd..8f57f143d4761b 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -176,6 +176,15 @@ static void *preload_bulk_worker_main(void *data) return NULL; } +static void release_workers(struct preload_bulk_scan *scan) +{ + for (int i = 0; i < scan->threads; i++) { + free(scan->workers[i].buffer); + strbuf_release(&scan->workers[i].path); + } + FREE_AND_NULL(scan->workers); +} + int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result) { @@ -188,8 +197,10 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, if (queue_init(&scan->queue)) return -1; CALLOC_ARRAY(scan->workers, scan->threads); - for (int i = 0; i < scan->threads; i++) + for (int i = 0; i < scan->threads; i++) { scan->workers[i].scan = scan; + strbuf_init(&scan->workers[i].path, 0); + } FLEX_ALLOC_STR(root_task, path, "."); if (!reserve_open_fd(&scan->queue)) @@ -199,8 +210,7 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, if (root_task->fd < 0) { release_open_fd(&scan->queue); free(root_task); - free(scan->workers); - scan->workers = NULL; + release_workers(scan); queue_release(&scan->queue); return -1; } @@ -222,11 +232,20 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, pthread_join(scan->workers[i].thread, NULL)) BUG("unable to join bulk preload worker"); + for (int i = 0; i < scan->threads; i++) { + struct preload_bulk_worker *worker = &scan->workers[i]; + + result->dirs += worker->dirs; + result->entries += worker->entries; + result->bulk_calls += worker->bulk_calls; + result->changed_dirs += worker->changed_dirs; + result->malformed += worker->malformed; + } result->threads = started_threads; - failed = scan->queue.failed; + failed = scan->queue.failed || result->malformed || + result->changed_dirs; - free(scan->workers); - scan->workers = NULL; + release_workers(scan); queue_release(&scan->queue); return failed ? -1 : 0; } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 9e8b085160a873..3272ce41ee81ec 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -3,6 +3,7 @@ #include "git-compat-util.h" #include "preload-index.h" +#include "strbuf.h" #include "thread-utils.h" struct preload_bulk_dir_identity { @@ -40,6 +41,13 @@ struct preload_bulk_scan; struct preload_bulk_worker { struct preload_bulk_scan *scan; pthread_t thread; + void *buffer; + struct strbuf path; + uint64_t dirs; + uint64_t entries; + uint64_t bulk_calls; + uint64_t changed_dirs; + uint64_t malformed; unsigned started : 1; }; @@ -67,6 +75,11 @@ struct preload_bulk_scan { }; struct preload_bulk_run_result { + uint64_t dirs; + uint64_t entries; + uint64_t bulk_calls; + uint64_t changed_dirs; + uint64_t malformed; int threads; }; @@ -77,6 +90,9 @@ void preload_bulk_schedule_directory( const char *name, const char *path, size_t path_len); int preload_bulk_index_position(struct preload_bulk_scan *scan, const char *path, size_t path_len); +int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, + const char *path, + size_t path_len); int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos); @@ -84,5 +100,6 @@ void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); +const struct preload_bulk_backend *preload_bulk_platform_backend(void); #endif /* PRELOAD_INDEX_BULK_H */ From 4d658b7247aa24bf351621ab8e687db6b3b18da4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:03 -0500 Subject: [PATCH 203/403] exclude: prove the contents of observed ignore sources A bulk untracked scan cannot reuse its result merely because an ignore file has familiar stat data. A file or its parent may be replaced while the scan runs, an absent source may appear, and repeated reads of the same source may observe different patterns. Record each source beneath its nearest available anchored parent, along with its path, symlink policy, presence, size, and blob identity. Check descriptor and parent identities while capturing an observation, then resolve the current parent again and compare the actual source bytes at validation. Coalesce equivalent observations and invalidate the proof immediately when observations conflict. Validation uses nonblocking opens, so replacing a source with a FIFO cannot hang. Equal contents remain acceptable even if the source or its parent has a different identity. This also preserves an empty /dev/null and an equivalent empty FIFO; changed or missing contents, unavailable anchored primitives, and failed parent callbacks invalidate the proof. Register the implementation and focused unit suite in both Make and Meson. The tests cover source and parent replacement, stable absence, repeated and conflicting observations, missing buffers, no-follow policy, /dev/null, FIFO replacement, and parent-opener failure. Signed-off-by: Taylor Blau --- Makefile | 2 + exclude-source-proof.c | 422 ++++++++++++++++++++++++++ exclude-source-proof.h | 37 +++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-exclude-source-proof.c | 404 ++++++++++++++++++++++++ 6 files changed, 867 insertions(+) create mode 100644 exclude-source-proof.c create mode 100644 exclude-source-proof.h create mode 100644 t/unit-tests/u-exclude-source-proof.c diff --git a/Makefile b/Makefile index 777cd9b05445df..9b6f8b5e518fe9 100644 --- a/Makefile +++ b/Makefile @@ -1181,6 +1181,7 @@ LIB_OBJS += ewah/bitmap.o LIB_OBJS += ewah/ewah_bitmap.o LIB_OBJS += ewah/ewah_io.o LIB_OBJS += ewah/ewah_rlw.o +LIB_OBJS += exclude-source-proof.o LIB_OBJS += exec-cmd.o LIB_OBJS += fetch-negotiator.o LIB_OBJS += fetch-object-info.o @@ -1578,6 +1579,7 @@ CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate +CLAR_TEST_SUITES += u-exclude-source-proof CLAR_TEST_SUITES += u-fsmonitor-attributes CLAR_TEST_SUITES += u-fsmonitor-clean-proof CLAR_TEST_SUITES += u-fsmonitor-response diff --git a/exclude-source-proof.c b/exclude-source-proof.c new file mode 100644 index 00000000000000..ec5194a1fa355e --- /dev/null +++ b/exclude-source-proof.c @@ -0,0 +1,422 @@ +#include "git-compat-util.h" +#include "exclude-source-proof.h" +#include "object-file.h" +#include "path-namespace.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "strmap.h" +#include "trace2.h" + +/* + * Each entry describes one path/policy observation. Filesystem identities + * are used only to make capture and validation coherent; the durable + * observation is the source's existence and bytes. + */ +struct exclude_source_proof_entry { + char *path; + size_t size; + struct object_id oid; + unsigned exists : 1; + unsigned nofollow : 1; +}; + +struct exclude_source_proof { + struct index_state *istate; + void *open_data; + exclude_source_open_parent_fn open_parent; + struct exclude_source_proof_entry *entries; + struct strintmap entries_by_path[2]; + size_t nr; + size_t alloc; + unsigned invalid : 1; +}; + +struct exclude_source_capture { + struct exclude_source_proof *proof; + char *path; + char *parent; + char *relative; + int parent_fd; + struct stat parent_stat; + unsigned nofollow : 1; +}; + +static char *source_parent(const char *path) +{ + const char *slash = strrchr(path, '/'); + + if (!slash) + return xstrdup("."); + if (slash == path) + return xstrdup("/"); + return xmemdupz(path, slash - path); +} + +static char *source_relative(const char *path, const char *parent) +{ + const char *relative; + size_t len; + + if (!strcmp(parent, ".")) + return xstrdup(path); + if (!strcmp(parent, "/")) { + relative = path + 1; + } else { + len = strlen(parent); + if (strncmp(path, parent, len) || path[len] != '/') + BUG("exclude source is not below its parent"); + relative = path + len + 1; + } + return xstrdup(*relative ? relative : "."); +} + +static int parent_up(char *parent) +{ + char *slash; + + if (!strcmp(parent, ".") || !strcmp(parent, "/")) + return 0; + slash = strrchr(parent, '/'); + if (!slash) { + parent[0] = '.'; + parent[1] = '\0'; + } else if (slash == parent) { + parent[1] = '\0'; + } else { + *slash = '\0'; + } + return 1; +} + +static int open_source_at(int parent_fd, const char *relative, int nofollow, + int nonblocking) +{ +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + int flags = O_RDONLY | O_CLOEXEC; + + if (nofollow) + flags |= O_NOFOLLOW; + if (nonblocking) + flags |= O_NONBLOCK; + return openat(parent_fd, relative, flags); +#else + (void)parent_fd; + (void)relative; + (void)nofollow; + (void)nonblocking; + errno = ENOSYS; + return -1; +#endif +} + +static int parent_identity_stable( + struct exclude_source_proof *proof, const char *parent, + int held_fd, const struct stat *expected) +{ + struct stat held, reopened; + int fd = proof->open_parent(proof->open_data, parent); + int stable = !fstat(held_fd, &held) && + fd >= 0 && !fstat(fd, &reopened) && + path_namespace_stat_equal(expected, &held) && + path_namespace_stat_equal(expected, &reopened); + + if (fd >= 0) + close(fd); + return stable; +} + +static int parent_stable(struct exclude_source_capture *capture) +{ + return parent_identity_stable( + capture->proof, capture->parent, capture->parent_fd, + &capture->parent_stat); +} + +static void capture_free(struct exclude_source_capture *capture) +{ + if (!capture) + return; + if (capture->parent_fd >= 0) + close(capture->parent_fd); + free(capture->path); + free(capture->parent); + free(capture->relative); + free(capture); +} + +static struct exclude_source_capture *capture_begin( + struct exclude_source_proof *proof, const char *path, + int nofollow, int invalidate) +{ + struct exclude_source_capture *capture; + + if (!proof || proof->invalid) + return NULL; + if (!path) { + if (invalidate) + proof->invalid = 1; + return NULL; + } + CALLOC_ARRAY(capture, 1); + capture->proof = proof; + capture->nofollow = nofollow; + capture->parent_fd = -1; + capture->path = xstrdup(path); + capture->parent = source_parent(path); + for (;;) { + capture->parent_fd = proof->open_parent(proof->open_data, + capture->parent); + if (capture->parent_fd >= 0) + break; + if (!is_missing_file_error(errno) || + !parent_up(capture->parent)) + break; + } + if (capture->parent_fd < 0 || + fstat(capture->parent_fd, &capture->parent_stat) || + !S_ISDIR(capture->parent_stat.st_mode)) { + if (invalidate) + proof->invalid = 1; + capture_free(capture); + return NULL; + } + capture->relative = source_relative(path, capture->parent); + return capture; +} + +struct exclude_source_proof *exclude_source_proof_create( + struct index_state *istate, void *open_data, + exclude_source_open_parent_fn open_parent) +{ + struct exclude_source_proof *proof; + + CALLOC_ARRAY(proof, 1); + proof->istate = istate; + proof->open_data = open_data; + proof->open_parent = open_parent; + strintmap_init_with_options(&proof->entries_by_path[0], -1, + NULL, 0); + strintmap_init_with_options(&proof->entries_by_path[1], -1, + NULL, 0); + if (!EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN || + !istate || !istate->repo || !istate->repo->hash_algo || + !open_parent) + proof->invalid = 1; + return proof; +} + +struct exclude_source_capture *exclude_source_capture_begin( + struct exclude_source_proof *proof, const char *path, + int nofollow) +{ + return capture_begin(proof, path, nofollow, 1); +} + +int exclude_source_capture_open(struct exclude_source_capture *capture) +{ + if (!capture) { + errno = EINVAL; + return -1; + } + return open_source_at(capture->parent_fd, capture->relative, + capture->nofollow, 0); +} + +int exclude_source_capture_absent(struct exclude_source_capture *capture) +{ +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + struct stat st; + + if (!capture) + return 0; + if (!fstatat(capture->parent_fd, capture->relative, &st, + AT_SYMLINK_NOFOLLOW)) + return 0; + return is_missing_file_error(errno); +#else + (void)capture; + return 0; +#endif +} + +static int source_matches(struct exclude_source_capture *capture, + const struct stat *expected) +{ + struct stat st; + int fd = open_source_at(capture->parent_fd, capture->relative, + capture->nofollow, 1); + int ret = fd >= 0 && !fstat(fd, &st) && + path_namespace_stat_equal(expected, &st); + + if (fd >= 0) + close(fd); + return ret; +} + +static int same_observation( + const struct exclude_source_proof_entry *entry, + int exists, size_t size, const struct object_id *oid) +{ + return entry->exists == exists && + (!exists || + (entry->size == size && oideq(&entry->oid, oid))); +} + +static void record_observation( + struct exclude_source_capture *capture, int exists, + size_t size, const struct object_id *oid) +{ + struct exclude_source_proof *proof = capture->proof; + struct strintmap *map = + &proof->entries_by_path[!!capture->nofollow]; + struct exclude_source_proof_entry *entry; + int index = strintmap_get(map, capture->path); + + if (index >= 0) { + if (!same_observation(&proof->entries[index], + exists, size, oid)) + proof->invalid = 1; + return; + } + + ALLOC_GROW(proof->entries, proof->nr + 1, proof->alloc); + entry = &proof->entries[proof->nr]; + memset(entry, 0, sizeof(*entry)); + entry->path = xstrdup(capture->path); + entry->nofollow = capture->nofollow; + entry->exists = exists; + if (exists) { + entry->size = size; + oidcpy(&entry->oid, oid); + } + strintmap_set(map, entry->path, proof->nr); + proof->nr++; +} + +void exclude_source_capture_record( + struct exclude_source_capture *capture, + int source_fd, + const struct stat *source_stat, + const void *buf, size_t size) +{ + struct exclude_source_proof *proof; + struct object_id oid; + struct stat final; + + if (!capture) + return; + proof = capture->proof; + if (proof->invalid) + return; + + if (!source_stat) { + if (!exclude_source_capture_absent(capture) || + !parent_stable(capture) || + !exclude_source_capture_absent(capture)) { + proof->invalid = 1; + return; + } + record_observation(capture, 0, 0, NULL); + return; + } + + if (source_fd < 0 || source_stat->st_size < 0 || + (!buf && size) || + xsize_t(source_stat->st_size) != size || + fstat(source_fd, &final) || + !path_namespace_stat_equal(source_stat, &final) || + !source_matches(capture, &final) || + !parent_stable(capture)) { + proof->invalid = 1; + return; + } + hash_object_file(proof->istate->repo->hash_algo, buf, size, + OBJ_BLOB, &oid); + record_observation(capture, 1, size, &oid); +} + +void exclude_source_capture_release(struct exclude_source_capture *capture) +{ + capture_free(capture); +} + +static int proof_entry_matches( + struct exclude_source_proof *proof, + const struct exclude_source_proof_entry *entry) +{ + struct exclude_source_capture *capture = + capture_begin(proof, entry->path, entry->nofollow, 0); + struct object_id oid; + struct stat before, after, final; + char *buf = NULL; + size_t size; + int fd = -1; + int ret = 0; + + if (!capture) + goto done; + if (!entry->exists) { + ret = exclude_source_capture_absent(capture) && + parent_stable(capture) && + exclude_source_capture_absent(capture); + goto done; + } + + fd = open_source_at(capture->parent_fd, capture->relative, + entry->nofollow, 1); + if (fd < 0 || fstat(fd, &before) || before.st_size < 0 || + xsize_t(before.st_size) != entry->size) + goto done; + size = entry->size; + buf = xmalloc(size ? size : 1); + if ((size_t)read_in_full(fd, buf, size) != size || + fstat(fd, &after) || + !path_namespace_stat_equal(&before, &after) || + !source_matches(capture, &after)) + goto done; + hash_object_file(proof->istate->repo->hash_algo, buf, size, + OBJ_BLOB, &oid); + if (!oideq(&oid, &entry->oid) || + !parent_stable(capture) || + fstat(fd, &final) || + !path_namespace_stat_equal(&after, &final) || + !source_matches(capture, &final)) + goto done; + ret = 1; +done: + free(buf); + if (fd >= 0) + close(fd); + capture_free(capture); + return ret; +} + +int exclude_source_proof_validate(struct exclude_source_proof *proof) +{ + int valid; + + if (!proof) + return 0; + valid = !proof->invalid; + for (size_t i = 0; valid && i < proof->nr; i++) + valid = proof_entry_matches(proof, &proof->entries[i]); + if (proof->istate && proof->istate->repo) { + trace2_data_intmax("exclude", proof->istate->repo, + "proof_entries", proof->nr); + trace2_data_intmax("exclude", proof->istate->repo, + "proof_valid", valid); + } + return valid; +} + +void exclude_source_proof_release(struct exclude_source_proof *proof) +{ + if (!proof) + return; + strintmap_clear(&proof->entries_by_path[0]); + strintmap_clear(&proof->entries_by_path[1]); + for (size_t i = 0; i < proof->nr; i++) + free(proof->entries[i].path); + free(proof->entries); + free(proof); +} diff --git a/exclude-source-proof.h b/exclude-source-proof.h new file mode 100644 index 00000000000000..1949afd3929a1c --- /dev/null +++ b/exclude-source-proof.h @@ -0,0 +1,37 @@ +#ifndef EXCLUDE_SOURCE_PROOF_H +#define EXCLUDE_SOURCE_PROOF_H + +#if (defined(__APPLE__) || defined(__linux__)) && \ + defined(O_CLOEXEC) && defined(O_NONBLOCK) && \ + defined(O_NOFOLLOW) && defined(O_DIRECTORY) && \ + defined(AT_SYMLINK_NOFOLLOW) +#define EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN 1 +#else +#define EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN 0 +#endif + +struct exclude_source_capture; +struct exclude_source_proof; +struct index_state; +struct stat; + +typedef int (*exclude_source_open_parent_fn)(void *data, const char *path); + +struct exclude_source_proof *exclude_source_proof_create( + struct index_state *istate, void *open_data, + exclude_source_open_parent_fn open_parent); +struct exclude_source_capture *exclude_source_capture_begin( + struct exclude_source_proof *proof, const char *path, + int nofollow); +int exclude_source_capture_open(struct exclude_source_capture *capture); +int exclude_source_capture_absent(struct exclude_source_capture *capture); +void exclude_source_capture_record( + struct exclude_source_capture *capture, + int source_fd, + const struct stat *source_stat, + const void *buf, size_t size); +void exclude_source_capture_release(struct exclude_source_capture *capture); +int exclude_source_proof_validate(struct exclude_source_proof *proof); +void exclude_source_proof_release(struct exclude_source_proof *proof); + +#endif /* EXCLUDE_SOURCE_PROOF_H */ diff --git a/meson.build b/meson.build index f878fbda16b44c..d4dc3b71f91841 100644 --- a/meson.build +++ b/meson.build @@ -378,6 +378,7 @@ libgit_sources = [ 'editor.c', 'entry.c', 'environment.c', + 'exclude-source-proof.c', 'ewah/bitmap.c', 'ewah/ewah_bitmap.c', 'ewah/ewah_io.c', diff --git a/t/meson.build b/t/meson.build index 1be1bcd7be8075..de510614fad57b 100644 --- a/t/meson.build +++ b/t/meson.build @@ -10,6 +10,7 @@ clar_test_suites = [ 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', + 'unit-tests/u-exclude-source-proof.c', 'unit-tests/u-fsmonitor-attributes.c', 'unit-tests/u-fsmonitor-clean-proof.c', 'unit-tests/u-fsmonitor-response.c', diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c new file mode 100644 index 00000000000000..26d5f16e6f2e9c --- /dev/null +++ b/t/unit-tests/u-exclude-source-proof.c @@ -0,0 +1,404 @@ +#include "unit-test.h" + +#include "dir.h" +#include "exclude-source-proof.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "wrapper.h" + +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + +static struct repository repo = { + .hash_algo = &hash_algos[GIT_HASH_SHA1], +}; +static struct index_state istate = { + .repo = &repo, +}; +static char *trash; +static int fail_open_parent; + +static int open_parent(void *data UNUSED, const char *path) +{ + if (fail_open_parent) { + errno = EACCES; + return -1; + } + return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); +} + +static struct exclude_source_proof *new_proof(void) +{ + return exclude_source_proof_create( + &istate, NULL, open_parent); +} + +static char *make_path(const char *name) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addf(&path, "%s/%s", trash, name); + return strbuf_detach(&path, NULL); +} + +static void record_file(struct exclude_source_proof *proof, const char *path) +{ + struct exclude_source_capture *capture = + exclude_source_capture_begin(proof, path, 0); + struct stat before, after; + char *buf; + size_t size; + ssize_t read_size; + int fd; + + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &before)); + cl_assert(before.st_size >= 0); + size = xsize_t(before.st_size); + buf = xmalloc(size ? size : 1); + read_size = read_in_full(fd, buf, size); + cl_assert(read_size >= 0 && (size_t)read_size == size); + cl_must_pass(fstat(fd, &after)); + exclude_source_capture_record(capture, fd, &after, buf, size); + exclude_source_capture_release(capture); + free(buf); + cl_must_pass(close(fd)); +} + +static void record_absence(struct exclude_source_proof *proof, + const char *path) +{ + struct exclude_source_capture *capture = + exclude_source_capture_begin(proof, path, 0); + + cl_assert(capture != NULL); + cl_assert(exclude_source_capture_absent(capture)); + exclude_source_capture_record(capture, -1, NULL, NULL, 0); + exclude_source_capture_release(capture); +} + +void test_exclude_source_proof__initialize(void) +{ + char template[] = "/tmp/exclude-source-proof-XXXXXX"; + + fail_open_parent = 0; + cl_assert(mkdtemp(template) != NULL); + trash = xstrdup(template); +} + +void test_exclude_source_proof__cleanup(void) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addstr(&path, trash); + cl_must_pass(remove_dir_recursively( + &path, REMOVE_DIR_PURGE_ORIGINAL_CWD)); + strbuf_release(&path); + FREE_AND_NULL(trash); +} + +void test_exclude_source_proof__accepts_same_content_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(unlink(source)); + write_file_buf(source, "content", 7); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_different_content_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(unlink(source)); + cl_assert(!exclude_source_proof_validate(proof)); + write_file_buf(source, "changed", 7); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__accepts_repeated_observation(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_missing_source_buffer(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + struct stat st; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + exclude_source_capture_record(capture, fd, &st, NULL, 7); + cl_assert(!exclude_source_proof_validate(proof)); + + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_conflicting_observations(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + write_file_buf(source, "changed", 7); + record_file(proof, source); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_open_failure(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + fail_open_parent = 1; + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__fails_closed_without_parent_opener(void) +{ + struct exclude_source_proof *proof = + exclude_source_proof_create(&istate, NULL, NULL); + + cl_assert(!exclude_source_capture_begin(proof, "/dev/null", 0)); + cl_assert(!exclude_source_proof_validate(proof)); + exclude_source_proof_release(proof); +} + +void test_exclude_source_proof__honors_nofollow(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + char *target = make_path("parent/target"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(target, "content", 7); + cl_must_pass(symlink("target", source)); + + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + + capture = exclude_source_capture_begin(proof, source, 1); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_assert(fd < 0 && errno == ELOOP); + exclude_source_capture_release(capture); + + exclude_source_proof_release(proof); + free(target); + free(source); + free(parent); +} + +void test_exclude_source_proof__opens_directory_sources(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + struct stat st; + char *parent = make_path("parent"); + char *source = make_path("parent/source/"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + cl_must_pass(mkdir(source, 0700)); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + cl_assert(S_ISDIR(st.st_mode)); + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + + capture = exclude_source_capture_begin(proof, "/", 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + cl_assert(S_ISDIR(st.st_mode)); + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__accepts_same_content_parent_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *old_parent = make_path("old-parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_must_pass(rename(parent, old_parent)); + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(old_parent); + free(parent); +} + +void test_exclude_source_proof__reresolves_absent_source_parent(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *missing = make_path("parent/missing"); + char *source = make_path("parent/missing/source"); + + cl_must_pass(mkdir(parent, 0700)); + cl_must_pass(mkdir(missing, 0700)); + record_absence(proof, source); + cl_must_pass(rmdir(missing)); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(mkdir(missing, 0700)); + cl_assert(exclude_source_proof_validate(proof)); + write_file_buf(source, "content", 7); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(missing); + free(parent); +} + +void test_exclude_source_proof__accepts_dev_null(void) +{ + struct exclude_source_proof *proof = new_proof(); + + record_file(proof, "/dev/null"); + cl_assert(exclude_source_proof_validate(proof)); + exclude_source_proof_release(proof); +} + +void test_exclude_source_proof__accepts_empty_fifo_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "", 0); + record_file(proof, source); + cl_must_pass(unlink(source)); + cl_must_pass(mkfifo(source, 0600)); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_nonempty_fifo_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + cl_must_pass(unlink(source)); + cl_must_pass(mkfifo(source, 0600)); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +#else + +#define EMPTY_TEST(name) void name(void) {} +#define SKIP_TEST(name) void name(void) { cl_skip(); } + +EMPTY_TEST(test_exclude_source_proof__initialize) +EMPTY_TEST(test_exclude_source_proof__cleanup) +SKIP_TEST(test_exclude_source_proof__accepts_same_content_replacement) +SKIP_TEST(test_exclude_source_proof__rejects_different_content_replacement) +SKIP_TEST(test_exclude_source_proof__accepts_repeated_observation) +SKIP_TEST(test_exclude_source_proof__rejects_missing_source_buffer) +SKIP_TEST(test_exclude_source_proof__rejects_conflicting_observations) +SKIP_TEST(test_exclude_source_proof__rejects_open_failure) +SKIP_TEST(test_exclude_source_proof__fails_closed_without_parent_opener) +SKIP_TEST(test_exclude_source_proof__honors_nofollow) +SKIP_TEST(test_exclude_source_proof__opens_directory_sources) +SKIP_TEST(test_exclude_source_proof__accepts_same_content_parent_replacement) +SKIP_TEST(test_exclude_source_proof__reresolves_absent_source_parent) +SKIP_TEST(test_exclude_source_proof__accepts_dev_null) +SKIP_TEST(test_exclude_source_proof__accepts_empty_fifo_replacement) +SKIP_TEST(test_exclude_source_proof__rejects_nonempty_fifo_replacement) + +#endif From cd0dde90223dfda8c274ad0b495d7e04a82b56c7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:06:20 -0500 Subject: [PATCH 204/403] preload-index: factor the ordinary preload eligibility predicate preload_thread() spells out which index entries require a filesystem lookup. A bulk preloader must begin with those same exclusions before applying its stricter publication rules. Extract the existing checks into preload_entry_needs_stat(), covering staged entries, gitlinks, up-to-date entries, skip-worktree entries, and fsmonitor-valid entries. Keep the ordinary preload loop and its ordering unchanged. Signed-off-by: Taylor Blau --- preload-index.c | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/preload-index.c b/preload-index.c index b222821b448526..10bd66affe169c 100644 --- a/preload-index.c +++ b/preload-index.c @@ -44,6 +44,15 @@ struct thread_data { int t2_nr_lstat; }; +static int preload_entry_needs_stat(const struct cache_entry *ce) +{ + return !ce_stage(ce) && + !S_ISGITLINK(ce->ce_mode) && + !ce_uptodate(ce) && + !ce_skip_worktree(ce) && + !(ce->ce_flags & CE_FSMONITOR_VALID); +} + static void *preload_thread(void *_data) { int nr, last_nr; @@ -61,15 +70,7 @@ static void *preload_thread(void *_data) struct cache_entry *ce = *cep++; struct stat st; - if (ce_stage(ce)) - continue; - if (S_ISGITLINK(ce->ce_mode)) - continue; - if (ce_uptodate(ce)) - continue; - if (ce_skip_worktree(ce)) - continue; - if (ce->ce_flags & CE_FSMONITOR_VALID) + if (!preload_entry_needs_stat(ce)) continue; if (p->progress && !(nr & 31)) { struct progress_data *pd = p->progress; From 87ed2489eefdd1d594d23d31b4f25c1aea87eeaa Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:31 -0500 Subject: [PATCH 205/403] dir: capture ignore sources beneath anchored parents The ordinary exclude reader opens configured, repository, and per-directory ignore files by pathname. That is sufficient for a one-off walk, but a concurrent replacement or newly created ignore file makes a retained bulk result unsafe. Attach the optional source proof from S13/P01 to dir_struct and capture the exact bytes or stable absence observed by add_patterns(). Preserve symlink-following for standard excludes and the existing no-follow policy for per-directory .gitignore files. Visit configured and repository sources even when absent so their later appearance invalidates the proof. Mark failed, oversized, short, and index-backed reads unprovable rather than treating their results as stable filesystem observations. Existing callers without a proof retain their original opens, error handling, pattern parsing, and oversized-source guard. Signed-off-by: Taylor Blau --- dir.c | 72 +++++++++++++++++++++++++++++++++--------- dir.h | 7 ++++ exclude-source-proof.c | 6 ++++ exclude-source-proof.h | 1 + 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/dir.c b/dir.c index 8b4d4cab260ed3..4e2e474e083871 100644 --- a/dir.c +++ b/dir.c @@ -15,6 +15,7 @@ #include "convert.h" #include "dir.h" #include "environment.h" +#include "exclude-source-proof.h" #include "gettext.h" #include "name-hash.h" #include "object-file.h" @@ -2017,40 +2018,63 @@ static void invalidate_directory(struct untracked_cache *uc, */ static int add_patterns(const char *fname, const char *base, int baselen, struct pattern_list *pl, struct index_state *istate, - unsigned flags, struct oid_stat *oid_stat) + unsigned flags, struct oid_stat *oid_stat, + struct exclude_source_proof *source_proof) { + struct exclude_source_capture *capture = + exclude_source_capture_begin(source_proof, fname, + !!(flags & PATTERN_NOFOLLOW)); struct stat st; int r; int fd; size_t size = 0; char *buf; - if (flags & PATTERN_NOFOLLOW) + if (capture) + fd = exclude_source_capture_open(capture); + else if (flags & PATTERN_NOFOLLOW) fd = open_nofollow(fname, O_RDONLY); else fd = open(fname, O_RDONLY); if (fd < 0 || fstat(fd, &st) < 0) { - if (fd < 0) + if (fd < 0) { warn_on_fopen_errors(fname); - else + if (capture && exclude_source_capture_absent(capture)) + exclude_source_capture_record(capture, -1, NULL, + NULL, 0); + else + exclude_source_capture_error(capture); + } else { + exclude_source_capture_error(capture); close(fd); - if (!istate) + } + if (!istate) { + exclude_source_capture_release(capture); return -1; + } r = read_skip_worktree_file_from_index(istate, fname, &size, &buf, oid_stat); + if (r == 1) + exclude_source_capture_error(capture); + exclude_source_capture_release(capture); + capture = NULL; if (r != 1) return r; } else { size = xsize_t(st.st_size); if (size > PATTERN_MAX_FILE_SIZE) { + exclude_source_capture_error(capture); + exclude_source_capture_release(capture); warning("ignoring excessively large pattern file: %s", fname); close(fd); return -1; } if (size == 0) { + exclude_source_capture_record(capture, fd, &st, NULL, 0); + exclude_source_capture_release(capture); if (oid_stat) { fill_stat_data(&oid_stat->stat, &st); oidcpy(&oid_stat->oid, the_hash_algo->empty_blob); @@ -2061,10 +2085,15 @@ static int add_patterns(const char *fname, const char *base, int baselen, } buf = xmallocz(size); if (read_in_full(fd, buf, size) != size) { + exclude_source_capture_error(capture); + exclude_source_capture_release(capture); free(buf); close(fd); return -1; } + exclude_source_capture_record(capture, fd, &st, buf, size); + exclude_source_capture_release(capture); + capture = NULL; buf[size++] = '\n'; close(fd); if (oid_stat) { @@ -2137,7 +2166,8 @@ int add_patterns_from_file_to_list(const char *fname, const char *base, struct index_state *istate, unsigned flags) { - return add_patterns(fname, base, baselen, pl, istate, flags, NULL); + return add_patterns(fname, base, baselen, pl, istate, flags, NULL, + NULL); } int add_patterns_from_blob_to_list( @@ -2182,10 +2212,11 @@ struct pattern_list *add_pattern_list(struct dir_struct *dir, /* * Used to set up core.excludesfile and .git/info/exclude lists. */ -static void add_patterns_from_file_1(struct dir_struct *dir, const char *fname, - struct oid_stat *oid_stat) +static int add_patterns_from_file_1(struct dir_struct *dir, const char *fname, + struct oid_stat *oid_stat, int gentle) { struct pattern_list *pl; + int ret; /* * catch setup_standard_excludes() that's called before * dir->untracked is assigned. That function behaves @@ -2194,14 +2225,17 @@ static void add_patterns_from_file_1(struct dir_struct *dir, const char *fname, if (!dir->untracked) dir->internal.unmanaged_exclude_files++; pl = add_pattern_list(dir, EXC_FILE, fname); - if (add_patterns(fname, "", 0, pl, NULL, 0, oid_stat) < 0) + ret = add_patterns(fname, "", 0, pl, NULL, 0, oid_stat, + dir->internal.exclude_source_proof); + if (ret < 0 && !gentle) die(_("cannot use %s as an exclude file"), fname); + return ret; } void add_patterns_from_file(struct dir_struct *dir, const char *fname) { dir->internal.unmanaged_exclude_files++; /* see validate_untracked_cache() */ - add_patterns_from_file_1(dir, fname, NULL); + add_patterns_from_file_1(dir, fname, NULL, 0); } int match_basename(const char *basename, int basenamelen, @@ -2651,7 +2685,8 @@ static void prep_exclude(struct dir_struct *dir, pl->src = strbuf_detach(&sb, NULL); if (add_patterns(pl->src, pl->src, stk->baselen, pl, istate, PATTERN_NOFOLLOW, - untracked ? &oid_stat : NULL) < 0 && + untracked ? &oid_stat : NULL, + dir->internal.exclude_source_proof) < 0 && untracked && is_null_oid(&oid_stat.oid)) { struct stat st; @@ -4453,16 +4488,23 @@ void setup_standard_excludes(struct dir_struct *dir) dir->exclude_per_dir = ".gitignore"; /* core.excludesfile defaulting to $XDG_CONFIG_HOME/git/ignore */ - if (excludes_file && !access_or_warn(excludes_file, R_OK, 0)) + if (excludes_file && + (dir->internal.exclude_source_proof || + !access_or_warn(excludes_file, R_OK, 0))) add_patterns_from_file_1(dir, excludes_file, - dir->untracked ? &dir->internal.ss_excludes_file : NULL); + dir->untracked ? + &dir->internal.ss_excludes_file : NULL, + !!dir->internal.exclude_source_proof); /* per repository user preference */ if (startup_info->have_repository) { const char *path = git_path_info_exclude(); - if (!access_or_warn(path, R_OK, 0)) + if (dir->internal.exclude_source_proof || + !access_or_warn(path, R_OK, 0)) add_patterns_from_file_1(dir, path, - dir->untracked ? &dir->internal.ss_info_exclude : NULL); + dir->untracked ? + &dir->internal.ss_info_exclude : NULL, + !!dir->internal.exclude_source_proof); } } diff --git a/dir.h b/dir.h index f6df0b54d271e9..23eed870a0e235 100644 --- a/dir.h +++ b/dir.h @@ -7,6 +7,7 @@ #include "statinfo.h" #include "strbuf.h" +struct exclude_source_proof; struct repository; /** @@ -364,6 +365,12 @@ struct dir_struct { unsigned visited_paths; unsigned visited_directories; unsigned untracked_cache_preloaded : 1; + + /* + * Optional borrowed proof that covers every exclusion source + * consulted by this traversal. + */ + struct exclude_source_proof *exclude_source_proof; } internal; }; diff --git a/exclude-source-proof.c b/exclude-source-proof.c index ec5194a1fa355e..022aa5a7ba1d5a 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -335,6 +335,12 @@ void exclude_source_capture_record( record_observation(capture, 1, size, &oid); } +void exclude_source_capture_error(struct exclude_source_capture *capture) +{ + if (capture) + capture->proof->invalid = 1; +} + void exclude_source_capture_release(struct exclude_source_capture *capture) { capture_free(capture); diff --git a/exclude-source-proof.h b/exclude-source-proof.h index 1949afd3929a1c..e2932f535fb7a4 100644 --- a/exclude-source-proof.h +++ b/exclude-source-proof.h @@ -30,6 +30,7 @@ void exclude_source_capture_record( int source_fd, const struct stat *source_stat, const void *buf, size_t size); +void exclude_source_capture_error(struct exclude_source_capture *capture); void exclude_source_capture_release(struct exclude_source_capture *capture); int exclude_source_proof_validate(struct exclude_source_proof *proof); void exclude_source_proof_release(struct exclude_source_proof *proof); From d4ad0c537c52ee96e68f8275297e08e60e76a5e5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 10:30:31 -0500 Subject: [PATCH 206/403] preload-index: enable opt-in APFS bulk preloading The APFS backend can enumerate tracked paths, but preload_index() never invokes it. Publishing incomplete or inconclusive directory-scan results would bypass the existing per-entry correctness checks. Run the bulk collector before ordinary threaded preload only for full-index requests with core.preloadIndexBulk and core.preloadIndex enabled and no active fsmonitor provider. Require local APFS, Darwin 20 or newer, and O_NOFOLLOW_ANY support; leave the option off by default. Retain an O_NOFOLLOW worktree-root descriptor, reopen relative paths with O_NOFOLLOW_ANY, and validate the root namespace again when the scan finishes. Publish only clean entries from a completed scan, and leave missing, changed, or multiply-linked entries to ordinary lstat. Register the backend in Make, CMake, and Meson. Add APFS integration coverage for configuration and test overrides, provider exclusion, descriptor pressure, prefix siblings, index states, sparse entries, Unicode names, and hardlink fallback. Signed-off-by: Taylor Blau --- Documentation/config/core.adoc | 10 ++ Makefile | 2 + compat/preload-index/bulk-darwin.c | 47 ++++- compat/preload-index/bulk-darwin.h | 1 + contrib/buildsystems/CMakeLists.txt | 6 +- meson.build | 2 + preload-index-bulk.c | 85 +++++++++ preload-index-bulk.h | 15 ++ preload-index.c | 152 +++++++++++++++- t/README | 3 + t/meson.build | 1 + t/t7529-preload-index-apfs.sh | 262 ++++++++++++++++++++++++++++ 12 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 preload-index-bulk.c create mode 100755 t/t7529-preload-index-apfs.sh diff --git a/Documentation/config/core.adoc b/Documentation/config/core.adoc index 340329edc38143..5f01b603e5761a 100644 --- a/Documentation/config/core.adoc +++ b/Documentation/config/core.adoc @@ -729,6 +729,16 @@ relatively high IO latencies. When enabled, Git will do the index comparison to the filesystem data in parallel, allowing overlapping IO's. Defaults to true. +core.preloadIndexBulk:: + On supported filesystems, scan working tree directories in bulk before + the parallel index preload. ++ +This replaces per-entry filesystem lookups with a physical directory scan, +but may cost more than normal preload depending on filesystem and cache +state. Inconclusive scans are discarded before continuing with the normal +preload. Currently this is supported on APFS and only has an effect when +`core.preloadIndex` is enabled. Defaults to false. + core.unsetenvvars:: Windows-only: comma-separated list of environment variables' names that need to be unset before spawning any other process. diff --git a/Makefile b/Makefile index a5a0dabc80e45f..94ad0f342c92b0 100644 --- a/Makefile +++ b/Makefile @@ -1382,8 +1382,10 @@ LIB_OBJS += wrapper.o LIB_OBJS += write-or-die.o LIB_OBJS += ws.o ifdef PRELOAD_INDEX_BULK_BACKEND +BASIC_CFLAGS += -DHAVE_PRELOAD_INDEX_BULK PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-index.o PRELOAD_INDEX_BULK_OBJS += preload-index-bulk-thread.o +PRELOAD_INDEX_BULK_OBJS += preload-index-bulk.o PRELOAD_INDEX_BULK_OBJS += compat/preload-index/bulk-$(PRELOAD_INDEX_BULK_BACKEND).o PRELOAD_INDEX_BULK_OBJS += $(PRELOAD_INDEX_BULK_PLATFORM_OBJS) endif diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 22a0e16def307f..9b9adba79b2ccd 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -1,6 +1,7 @@ #include "git-compat-util.h" #include +#include #include #include "compat/precompose_utf8.h" @@ -64,6 +65,27 @@ static int preload_bulk_darwin_open_dir_at( O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); } +int preload_bulk_darwin_supports_nofollow_any(void) +{ +#ifdef O_NOFOLLOW_ANY + struct utsname uts; + char *end; + unsigned long major; + + /* + * O_NOFOLLOW_ANY arrived in Darwin 20. Older kernels accept the + * same bit as O_ALERT without enforcing no-follow semantics. + */ + if (uname(&uts) || !isdigit((unsigned char)uts.release[0])) + return 0; + errno = 0; + major = strtoul(uts.release, &end, 10); + return !errno && end != uts.release && *end == '.' && major >= 20; +#else + return 0; +#endif +} + static int preload_bulk_darwin_open_relative(struct preload_bulk_scan *scan, const char *path) { @@ -449,12 +471,35 @@ static int scan_directory(struct preload_bulk_worker *worker, return ret; } +static const char *start_scan(struct preload_bulk_scan *scan) +{ + const char *error; + + repo_precompose_utf8_prepare(scan->repo); + error = preload_bulk_darwin_open_root(scan); + if (error) + return error; + return preload_bulk_darwin_snapshot_root(scan); +} + +static const char *finish_scan(struct preload_bulk_scan *scan) +{ + return preload_bulk_darwin_validate_root(scan); +} + static const struct preload_bulk_backend darwin_backend = { + .start = start_scan, + .finish = finish_scan, + .release = preload_bulk_darwin_release, .open_dir_at = preload_bulk_darwin_open_dir_at, .scan_directory = scan_directory, }; const struct preload_bulk_backend *preload_bulk_platform_backend(void) { - return &darwin_backend; +#ifdef O_NOFOLLOW_ANY + if (preload_bulk_darwin_supports_nofollow_any()) + return &darwin_backend; +#endif + return NULL; } diff --git a/compat/preload-index/bulk-darwin.h b/compat/preload-index/bulk-darwin.h index c69886ac972268..7c5c45ee947651 100644 --- a/compat/preload-index/bulk-darwin.h +++ b/compat/preload-index/bulk-darwin.h @@ -12,6 +12,7 @@ struct preload_bulk_darwin_data { fsid_t root_fsid; }; +int preload_bulk_darwin_supports_nofollow_any(void); /* * Exposed so that tests can validate kernel-supplied records directly. */ diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index e24ec739720dc5..87152a27fe7cb1 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -275,7 +275,8 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") - add_compile_definitions(PRECOMPOSE_UNICODE USE_ST_TIMESPEC) + add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE + USE_ST_TIMESPEC) list(APPEND compat_SOURCES compat/darwin/procinfo.c compat/precompose_utf8.c @@ -673,7 +674,8 @@ parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") list(APPEND libgit_SOURCES preload-index-bulk-index.c - preload-index-bulk-thread.c) + preload-index-bulk-thread.c + preload-index-bulk.c) endif() list(TRANSFORM libgit_SOURCES PREPEND "${CMAKE_SOURCE_DIR}/") diff --git a/meson.build b/meson.build index 2bdff5bb374325..74a1548af19a6f 100644 --- a/meson.build +++ b/meson.build @@ -1297,6 +1297,7 @@ endif if host_machine.system() == 'darwin' compat_sources += 'compat/precompose_utf8.c' + libgit_c_args += '-DHAVE_PRELOAD_INDEX_BULK' libgit_c_args += '-DPRECOMPOSE_UNICODE' libgit_c_args += '-DPROTECT_HFS_DEFAULT' endif @@ -1352,6 +1353,7 @@ elif host_machine.system() == 'darwin' libgit_sources += [ 'preload-index-bulk-index.c', 'preload-index-bulk-thread.c', + 'preload-index-bulk.c', ] else compat_sources += 'compat/stub/procinfo.c' diff --git a/preload-index-bulk.c b/preload-index-bulk.c new file mode 100644 index 00000000000000..31c370e2813e58 --- /dev/null +++ b/preload-index-bulk.c @@ -0,0 +1,85 @@ +#include "git-compat-util.h" +#include "preload-index-bulk.h" +#include "read-cache-ll.h" + +static int backend_available(const struct preload_bulk_backend *backend) +{ + return backend && backend->start && backend->finish && + backend->release && backend->open_dir_at && + backend->scan_directory; +} + +int preload_bulk_available(void) +{ + return backend_available(preload_bulk_platform_backend()); +} + +int preload_bulk_collect(struct index_state *istate, int threads, + struct preload_bulk_result *result) +{ + const struct preload_bulk_backend *backend = + preload_bulk_platform_backend(); + struct preload_bulk_scan scan = { + .repo = istate->repo, + .istate = istate, + .backend = backend, + .root_fd = -1, + .threads = threads, + }; + struct preload_bulk_run_result run_result = { 0 }; + const char *start_error, *finish_error = NULL; + int scan_error = -1; + int clean; + + memset(result, 0, sizeof(*result)); + result->outcome = "start-fallback"; + result->reason = "backend-unavailable"; + if (!backend_available(backend)) + return -1; + + CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); + start_error = backend->start(&scan); + if (!start_error) { + scan_error = preload_bulk_run_scan(&scan, &run_result); + finish_error = backend->finish(&scan); + } + + clean = !start_error && !scan_error && !finish_error && + !run_result.changed_dirs && + !run_result.malformed; + result->run = run_result; + if (start_error) { + result->outcome = "start-fallback"; + result->reason = start_error; + } else if (run_result.changed_dirs) { + result->outcome = "scan-fallback"; + result->reason = "filesystem-race"; + } else if (run_result.malformed) { + result->outcome = "scan-fallback"; + result->reason = "malformed-record"; + } else if (scan_error) { + result->outcome = "scan-fallback"; + result->reason = "scan-error"; + } else if (finish_error) { + result->outcome = "finish-fallback"; + result->reason = finish_error; + } else { + result->outcome = "complete"; + result->reason = NULL; + } + if (clean) { + result->tracked_state = scan.tracked_state; + result->nr = istate->cache_nr; + scan.tracked_state = NULL; + } + + backend->release(&scan); + free(scan.tracked_state); + return clean ? 0 : -1; +} + +void preload_bulk_result_release(struct preload_bulk_result *result) +{ + FREE_AND_NULL(result->tracked_state); + memset(result, 0, sizeof(*result)); +} diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 3272ce41ee81ec..45899e56e58a46 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -52,6 +52,9 @@ struct preload_bulk_worker { }; struct preload_bulk_backend { + const char *(*start)(struct preload_bulk_scan *scan); + const char *(*finish)(struct preload_bulk_scan *scan); + void (*release)(struct preload_bulk_scan *scan); int (*open_dir_at)(struct preload_bulk_worker *worker, int parent_fd, const char *name); /* @@ -83,6 +86,14 @@ struct preload_bulk_run_result { int threads; }; +struct preload_bulk_result { + unsigned char *tracked_state; + size_t nr; + const char *outcome; + const char *reason; + struct preload_bulk_run_result run; +}; + void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, @@ -101,5 +112,9 @@ void preload_bulk_record_tracked( int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); const struct preload_bulk_backend *preload_bulk_platform_backend(void); +int preload_bulk_collect(struct index_state *istate, int threads, + struct preload_bulk_result *result); +int preload_bulk_available(void); +void preload_bulk_result_release(struct preload_bulk_result *result); #endif /* PRELOAD_INDEX_BULK_H */ diff --git a/preload-index.c b/preload-index.c index 10bd66affe169c..f77760f05def9b 100644 --- a/preload-index.c +++ b/preload-index.c @@ -12,6 +12,9 @@ #include "gettext.h" #include "parse.h" #include "preload-index.h" +#ifdef HAVE_PRELOAD_INDEX_BULK +#include "preload-index-bulk.h" +#endif #include "progress.h" #include "read-cache.h" #include "thread-utils.h" @@ -28,6 +31,8 @@ */ #define MAX_PARALLEL (20) #define THREAD_COST (500) +#define BULK_MAX_PARALLEL (32) +#define BULK_ENTRIES_PER_THREAD (5000) struct progress_data { unsigned long n; @@ -104,6 +109,144 @@ static void *preload_thread(void *_data) return NULL; } +#ifdef HAVE_PRELOAD_INDEX_BULK +static int stat_data_is_zero(const struct stat_data *sd) +{ + return !sd->sd_ctime.sec && + !sd->sd_ctime.nsec && + !sd->sd_mtime.sec && + !sd->sd_mtime.nsec && + !sd->sd_dev && + !sd->sd_ino && + !sd->sd_uid && + !sd->sd_gid && + !sd->sd_size; +} + +static int preload_bulk_entry_is_useful(const struct cache_entry *ce) +{ + return preload_entry_needs_stat(ce) && + !ce_intent_to_add(ce) && + !(ce->ce_flags & (CE_VALID | CE_REMOVE)) && + (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) && + !stat_data_is_zero(&ce->ce_stat_data); +} + +static size_t preload_bulk_useful_candidates(struct index_state *index) +{ + size_t useful = 0; + + for (size_t i = 0; i < index->cache_nr; i++) + if (preload_bulk_entry_is_useful(index->cache[i])) + useful++; + return useful; +} + +static size_t preload_bulk_publish_clean( + struct index_state *index, + const struct preload_bulk_result *result) +{ + size_t applied = 0; + + if (result->nr != index->cache_nr) + BUG("bulk preload result does not match the index"); + + for (size_t i = 0; i < result->nr; i++) { + struct cache_entry *ce; + unsigned char state = result->tracked_state[i]; + + if (state != PRELOAD_BULK_TRACKED_CLEAN) + continue; + ce = index->cache[i]; + if (!preload_bulk_entry_is_useful(ce)) + continue; + ce_mark_uptodate(ce); + mark_fsmonitor_valid(index, ce); + applied++; + } + return applied; +} + +static int preload_bulk_threads(size_t useful) +{ + int cpus = online_cpus(); + int threads = DIV_ROUND_UP(useful, BULK_ENTRIES_PER_THREAD); + + if (threads < 1) + threads = 1; + if (cpus > 0) { + int cpu_limit = cpus > BULK_MAX_PARALLEL / 2 ? + BULK_MAX_PARALLEL : cpus * 2; + + if (threads > cpu_limit) + threads = cpu_limit; + } + if (threads > BULK_MAX_PARALLEL) + threads = BULK_MAX_PARALLEL; + return threads; +} + +static void preload_bulk_trace_result( + struct index_state *index, + const struct preload_bulk_result *result, + size_t applied) +{ + trace2_data_string("index", index->repo, "preload/bulk_result", + result->outcome); + if (result->reason) + trace2_data_string("index", index->repo, + "preload/bulk_reason", result->reason); + trace2_data_intmax("index", index->repo, "preload/bulk_applied", + applied); + trace2_data_intmax("index", index->repo, "preload/bulk_dirs", + result->run.dirs); + trace2_data_intmax("index", index->repo, "preload/bulk_entries", + result->run.entries); + trace2_data_intmax("index", index->repo, "preload/bulk_calls", + result->run.bulk_calls); + trace2_data_intmax("index", index->repo, "preload/bulk_workers", + result->run.threads); +} + +static void preload_bulk_try(struct index_state *index) +{ + struct preload_bulk_result result = { 0 }; + size_t useful; + size_t applied = 0; + int enabled = 0; + int control, threads; + + /* + * Let the test variable override configuration without bypassing + * any of the proof checks. + */ + control = git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", -1); + if (control < 0) + repo_config_get_bool(index->repo, "core.preloadindexbulk", + &enabled); + else + enabled = control; + if (!enabled || + fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_DISABLED || + !preload_bulk_available()) + return; + useful = preload_bulk_useful_candidates(index); + trace2_data_intmax("index", index->repo, "preload/bulk_useful", + useful); + trace2_data_intmax("index", index->repo, "preload/bulk_cache_nr", + index->cache_nr); + if (!useful) + return; + threads = preload_bulk_threads(useful); + trace2_region_enter("index", "preload/bulk", index->repo); + if (!preload_bulk_collect(index, threads, &result)) + applied = preload_bulk_publish_clean(index, &result); + preload_bulk_trace_result(index, &result, applied); + trace2_region_leave("index", "preload/bulk", index->repo); + preload_bulk_result_release(&result); +} +#endif + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags) @@ -116,7 +259,14 @@ void preload_index(struct index_state *index, repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); - if (!HAVE_THREADS || !core_preload_index) + if (!core_preload_index) + return; + +#ifdef HAVE_PRELOAD_INDEX_BULK + if (!pathspec || !pathspec->nr) + preload_bulk_try(index); +#endif + if (!HAVE_THREADS) return; threads = index->cache_nr / THREAD_COST; diff --git a/t/README b/t/README index 4252774f86c8ce..6ba21aead64858 100644 --- a/t/README +++ b/t/README @@ -422,6 +422,9 @@ overridden by the --no-path-walk command-line argument. GIT_TEST_PRELOAD_INDEX= exercises the preload-index code path by overriding the minimum number of cache entries required per thread. +GIT_TEST_PRELOAD_INDEX_BULK= overrides the +`core.preloadIndexBulk` setting. + GIT_TEST_INDEX_THREADS= enables exercising the multi-threaded loading of the index for the whole test suite by bypassing the default number of cache entries and thread minimums. Setting this to 1 will make the diff --git a/t/meson.build b/t/meson.build index d22603637bc5a6..2507f29aeec5ff 100644 --- a/t/meson.build +++ b/t/meson.build @@ -945,6 +945,7 @@ integration_tests = [ 't7526-commit-pathspec-file.sh', 't7527-builtin-fsmonitor.sh', 't7528-signed-commit-ssh.sh', + 't7529-preload-index-apfs.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh new file mode 100755 index 00000000000000..87b49869515151 --- /dev/null +++ b/t/t7529-preload-index-apfs.sh @@ -0,0 +1,262 @@ +#!/bin/sh + +test_description='APFS bulk index preload' + +. ./test-lib.sh + +test_lazy_prereq APFS_BULK_PRELOAD ' + test_have_prereq MACOS && + darwin_major=$(uname -r) && + darwin_major=${darwin_major%%.*} && + test "$darwin_major" -ge 20 && + /bin/df -t apfs "$TRASH_DIRECTORY" >/dev/null +' + +if ! test_have_prereq APFS_BULK_PRELOAD +then + skip_all='bulk index preload requires macOS on APFS' + test_done +fi + +setup_repo () { + repo=$1 && + git init "$repo" && + mkdir -p "$repo/nested/deep" "$repo/other" && + test_write_lines root >"$repo/root" && + test_write_lines root-peer >"$repo/root-peer" && + test_write_lines nested >"$repo/nested/tracked" && + test_write_lines nested-peer >"$repo/nested/peer" && + test_write_lines deep >"$repo/nested/deep/tracked" && + test_write_lines deep-peer >"$repo/nested/deep/peer" && + test_write_lines other >"$repo/other/tracked" && + test_write_lines other-peer >"$repo/other/peer" && + git -C "$repo" add . && + git -C "$repo" commit -m base && + git -C "$repo" config core.fsmonitor false && + test-tool chmtime -120 \ + "$repo/root" "$repo/root-peer" \ + "$repo/nested/tracked" "$repo/nested/peer" \ + "$repo/nested/deep/tracked" "$repo/nested/deep/peer" \ + "$repo/other/tracked" "$repo/other/peer" && + git -C "$repo" update-index --refresh +} + +ordinary_status () { + GIT_OPTIONAL_LOCKS=0 \ + git -C "$1" -c core.preloadIndex=false \ + status --porcelain=v2 >"$2" +} + +bulk_status () { + repo=$1 && + output=$2 && + bulk_trace=$TRASH_DIRECTORY/$3 && + rm -f "$bulk_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$bulk_trace" \ + git -C "$repo" status --porcelain=v2 >"$output" +} + +check_data () { + test_trace2_data index "$2" "$3" <"$TRASH_DIRECTORY/$1" +} + +check_lstat_data () { + test_have_prereq !PTHREADS || + check_data "$1" preload/sum_lstat "$2" +} + +compare_status () { + ordinary_status "$1" expect && + bulk_status "$1" actual "$2" && + test_cmp expect actual +} + +configured_bulk_status () { + repo=$1 && + output=$2 && + bulk_trace=$TRASH_DIRECTORY/$3 && + bulk=${4-true} && + preload=${5-true} && + rm -f "$bulk_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$bulk_trace" \ + git -C "$repo" \ + -c core.preloadIndex="$preload" \ + -c core.preloadIndexBulk="$bulk" \ + status --porcelain=v2 >"$output" +} + +test_expect_success 'bulk preload follows its configuration' ' + setup_repo opt-in && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/default.trace" \ + git -C opt-in status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" default.trace && + configured_bulk_status opt-in actual enabled.trace && + test_must_be_empty actual && + test_grep "\"key\":\"preload/bulk_result\"" enabled.trace && + configured_bulk_status opt-in actual preload-disabled.trace true false && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" preload-disabled.trace +' + +test_expect_success 'test variable overrides bulk preload configuration' ' + test_env GIT_TEST_PRELOAD_INDEX_BULK=0 \ + configured_bulk_status opt-in actual disabled.trace && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" disabled.trace && + test_env GIT_TEST_PRELOAD_INDEX_BULK=1 \ + configured_bulk_status opt-in actual forced.trace false && + test_must_be_empty actual && + check_data forced.trace preload/bulk_applied 8 +' + +test_expect_success 'bulk preload waits for fsmonitor provider closure' ' + write_script opt-in/.git/hooks/fsmonitor-test <<-\EOF && + printf "token\\0" + EOF + git -C opt-in config core.fsmonitor .git/hooks/fsmonitor-test && + configured_bulk_status opt-in actual fsmonitor.trace && + test_must_be_empty actual && + test_grep ! "\"key\":\"preload/bulk_result\"" fsmonitor.trace +' + +test_expect_success 'clean entries are published without lstat' ' + setup_repo clean && + bulk_status clean actual clean.trace && + test_must_be_empty actual && + check_data clean.trace preload/bulk_applied 8 && + check_lstat_data clean.trace 0 +' + +test_expect_success ULIMIT_FILE_DESCRIPTORS \ + 'bulk preload reopens directories under a low descriptor limit' ' + git init low-fd && + for i in $(test_seq 1 64) + do + mkdir "low-fd/$i" && + test_write_lines "$i" >"low-fd/$i/tracked" || + return 1 + done && + git -C low-fd add . && + git -C low-fd commit -m base && + test-tool chmtime -120 low-fd/*/tracked && + git -C low-fd update-index --refresh && + run_with_limited_open_files \ + bulk_status low-fd actual low-fd.trace && + test_must_be_empty actual && + check_data low-fd.trace preload/bulk_applied 64 +' + +test_expect_success 'prefix siblings do not hide tracked descendants' ' + git init prefix-order && + mkdir prefix-order/feather prefix-order/feather-db && + test_write_lines tracked >prefix-order/feather/tracked && + test_write_lines sibling >prefix-order/feather-db/tracked && + git -C prefix-order add . && + git -C prefix-order commit -m base && + test-tool chmtime -120 prefix-order/feather/tracked \ + prefix-order/feather-db/tracked && + git -C prefix-order update-index --refresh && + bulk_status prefix-order actual prefix-order.trace && + test_must_be_empty actual && + check_data prefix-order.trace preload/bulk_applied 2 && + check_lstat_data prefix-order.trace 0 +' + +test_expect_success SYMLINKS \ + 'modified, deleted, typechanged, and symlink entries agree' ' + setup_repo worktree-states && + ln -s root worktree-states/link && + git -C worktree-states add link && + git -C worktree-states commit -m symlink && + mtime=$(test-tool chmtime --get worktree-states/root) && + sleep 1 && + test_write_lines moot >worktree-states/root && + test-tool chmtime "=$mtime" worktree-states/root && + rm worktree-states/nested/tracked && + rm worktree-states/nested/deep/tracked && + mkdir worktree-states/nested/deep/tracked && + rm worktree-states/other/tracked worktree-states/other/peer && + rmdir worktree-states/other && + test_write_lines other >worktree-states/other && + rm worktree-states/link && + ln -s nested/deep/peer worktree-states/link && + compare_status worktree-states worktree-states.trace && + test_file_not_empty actual +' + +test_expect_success 'staged and unmerged entries agree' ' + setup_repo index-states && + test_write_lines staged >index-states/root && + test_write_lines added >index-states/added && + git -C index-states add root added && + git -C index-states rm nested/tracked && + base=$(git -C index-states rev-parse HEAD:nested/peer) && + ours=$(printf "ours\n" | + git -C index-states hash-object -w --stdin) && + theirs=$(printf "theirs\n" | + git -C index-states hash-object -w --stdin) && + { + printf "0 %s\tnested/peer\n" "$(test_oid zero)" && + printf "100644 %s 1\tnested/peer\n" "$base" && + printf "100644 %s 2\tnested/peer\n" "$ours" && + printf "100644 %s 3\tnested/peer\n" "$theirs" + } | git -C index-states update-index --index-info && + compare_status index-states index-states.trace && + test_file_not_empty actual +' + +test_expect_success 'sparse-index entries are left unexpanded' ' + setup_repo sparse && + git -C sparse sparse-checkout init --cone --sparse-index && + git -C sparse sparse-checkout set nested && + git -C sparse ls-files --sparse >before && + test_grep "^other/$" before && + test_write_lines changed >sparse/nested/tracked && + compare_status sparse sparse.trace && + git -C sparse ls-files --sparse >after && + test_cmp before after +' + +test_expect_success UTF8_NFD_TO_NFC \ + 'decomposed Unicode names agree' ' + setup_repo unicode && + nfc=$(printf "\303\244") && + nfd=$(printf "\141\314\210") && + git -C unicode config core.precomposeunicode true && + test_write_lines unicode >"unicode/$nfd" && + git -C unicode add "$nfc" && + git -C unicode commit -m unicode && + test-tool chmtime -120 "unicode/$nfd" && + git -C unicode update-index --refresh && + compare_status unicode unicode.trace && + test_must_be_empty actual && + check_data unicode.trace preload/bulk_applied 9 && + check_lstat_data unicode.trace 0 +' + +test_expect_success 'multiply-linked entries are left to lstat' ' + setup_repo hardlink && + # Mutate through a name outside the watched worktree, then restore + # mtime. The bulk scan must reject the multiply-linked entry. + ln hardlink/root hardlink-alias && + test-tool chmtime -120 hardlink/root && + git -C hardlink update-index --refresh && + mtime=$(test-tool chmtime --get hardlink/root) && + sleep 1 && + test_write_lines moot >hardlink-alias && + test-tool chmtime "=$mtime" hardlink/root && + compare_status hardlink hardlink.trace && + test_file_not_empty actual && + check_data hardlink.trace preload/bulk_applied 7 && + check_lstat_data hardlink.trace 1 +' + +test_done From 029af3f21d55f576dac328634288a8eedfac7abf Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:14:28 -0500 Subject: [PATCH 207/403] preload-index: stage proven untracked bulk results A tracked-file bulk preload can already walk directories that ordinary status later scans for untracked files. Sharing those observations requires a complete, independently validated result; a partial list must never suppress the conventional untracked traversal. Add an explicit backend capability and optional borrowed destination for visible paths. Serialize the existing ignore matcher across scan workers, collapse an untracked directory after its first visible descendant, and sort the provisional results. Publish them only after the directory scan and the anchored ignore-source proof both complete. Reject duplicate paths and discard incomplete or conflicting untracked results without discarding independently valid tracked observations. Report completeness, visible-path count, and fallback reason through Trace2, and release all temporary path and proof state. No existing backend advertises the new capability and no status caller requests it at this boundary. Ordinary tracked and untracked behavior therefore remains unchanged. Signed-off-by: Taylor Blau --- preload-index-bulk-thread.c | 2 + preload-index-bulk.c | 197 ++++++++++++++++++++++++++++++++++++ preload-index-bulk.h | 33 ++++++ preload-index.c | 17 ++++ read-cache-ll.h | 3 + 5 files changed, 252 insertions(+) diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index b1a8d430d8e21e..61702a5a5ba07a 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -244,6 +244,8 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, result->malformed += worker->malformed; } result->threads = started_threads; + result->untracked_complete = + scan->collect_untracked && !scan->queue.untracked_invalid; failed = scan->queue.failed || result->malformed || result->changed_dirs; diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 41b2398d3913f3..cd53761358af4f 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -1,8 +1,23 @@ #include "git-compat-util.h" +#include "abspath.h" +#include "dir.h" +#include "exclude-source-proof.h" #include "name-hash.h" #include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" +#include "trace2.h" + +struct preload_bulk_untracked_root { + struct preload_bulk_untracked_root *next; + /* + * Normal-mode status reports an untracked directory after finding + * one visible descendant. Share that decision among workers below + * the directory. + */ + unsigned visible : 1; + char path[FLEX_ARRAY]; +}; static int backend_available(const struct preload_bulk_backend *backend) { @@ -11,6 +26,15 @@ static int backend_available(const struct preload_bulk_backend *backend) backend->scan_directory; } +static int open_exclude_parent(void *data, const char *path) +{ + struct preload_bulk_scan *scan = data; + + if (is_absolute_path(path)) + return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + return scan->backend->open_proof_parent(scan, path); +} + int preload_bulk_available(void) { return backend_available(preload_bulk_platform_backend()); @@ -35,9 +59,120 @@ int preload_bulk_test_barrier(struct preload_bulk_scan *scan, return result; } +int preload_bulk_path_is_excluded(struct preload_bulk_worker *worker, + const char *path, int dtype) +{ + struct preload_bulk_scan *scan = worker->scan; + int result; + + if (!scan->exclude_dir) + BUG("bulk preload has no exclude state"); + pthread_mutex_lock(&scan->exclude_mutex); + result = is_excluded(scan->exclude_dir, scan->istate, path, &dtype); + pthread_mutex_unlock(&scan->exclude_mutex); + return result; +} + +void preload_bulk_invalidate_untracked( + struct preload_bulk_worker *worker) +{ + struct preload_bulk_queue *queue = &worker->scan->queue; + + pthread_mutex_lock(&queue->mutex); + queue->untracked_invalid = 1; + pthread_mutex_unlock(&queue->mutex); +} + +int preload_bulk_untracked_is_invalid( + struct preload_bulk_worker *worker) +{ + struct preload_bulk_queue *queue = &worker->scan->queue; + int invalid; + + pthread_mutex_lock(&queue->mutex); + invalid = queue->untracked_invalid; + pthread_mutex_unlock(&queue->mutex); + return invalid; +} + +struct preload_bulk_untracked_root *preload_bulk_untracked_root_new( + struct preload_bulk_worker *worker, const char *path, + size_t path_len) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_untracked_root *root; + + FLEX_ALLOC_MEM(root, path, path, path_len + 1); + root->path[path_len] = '/'; + root->path[path_len + 1] = '\0'; + + pthread_mutex_lock(&scan->queue.mutex); + root->next = scan->untracked_roots; + scan->untracked_roots = root; + pthread_mutex_unlock(&scan->queue.mutex); + return root; +} + +int preload_bulk_untracked_root_is_visible( + struct preload_bulk_worker *worker MAYBE_UNUSED, + const struct preload_bulk_untracked_root *root) +{ + int visible; + + if (!root) + return 0; + pthread_mutex_lock(&worker->scan->queue.mutex); + visible = root->visible; + pthread_mutex_unlock(&worker->scan->queue.mutex); + return visible; +} + +void preload_bulk_record_untracked( + struct preload_bulk_worker *worker, + struct preload_bulk_untracked_root *root, + const char *path) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_queue *queue = &scan->queue; + int record = 1; + + pthread_mutex_lock(&queue->mutex); + if (queue->untracked_invalid) + record = 0; + else if (root) { + if (root->visible) + record = 0; + else + root->visible = 1; + } + if (record) + string_list_append(&scan->untracked, + root ? root->path : path); + pthread_mutex_unlock(&queue->mutex); +} + +static int collect_untracked_paths(struct preload_bulk_scan *scan, + struct preload_bulk_result *result) +{ + /* + * Do not publish provisional output until all closing validations + * have succeeded. + */ + string_list_sort(&scan->untracked); + for (size_t i = 1; i < scan->untracked.nr; i++) + if (!strcmp(scan->untracked.items[i - 1].string, + scan->untracked.items[i].string)) + return -1; + result->untracked = scan->untracked; + scan->untracked = (struct string_list)STRING_LIST_INIT_DUP; + return 0; +} + int preload_bulk_collect(struct index_state *istate, int threads, struct preload_bulk_result *result) { + struct dir_struct exclude_dir = DIR_INIT; + struct exclude_source_proof *exclude_proof = NULL; const struct preload_bulk_backend *backend = preload_bulk_platform_backend(); struct preload_bulk_scan scan = { @@ -46,13 +181,16 @@ int preload_bulk_collect(struct index_state *istate, int threads, .backend = backend, .root_fd = -1, .threads = threads, + .untracked = STRING_LIST_INIT_DUP, }; struct preload_bulk_run_result run_result = { 0 }; const char *start_error, *finish_error = NULL; + const char *untracked_reason = NULL; int scan_error = -1; int clean; memset(result, 0, sizeof(*result)); + result->untracked.strdup_strings = 1; result->outcome = "start-fallback"; result->reason = "backend-unavailable"; if (!backend_available(backend)) @@ -69,6 +207,20 @@ int preload_bulk_collect(struct index_state *istate, int threads, scan.case_insensitive = prepare_index_casefolding(istate); scan.can_skip_unseen_preload = 1; } + scan.collect_untracked = + !!istate->preload_untracked && + backend->collects_untracked && + backend->open_proof_parent; + if (istate->preload_untracked && !scan.collect_untracked) + untracked_reason = "backend-unsupported"; + if (scan.collect_untracked) { + scan.exclude_dir = &exclude_dir; +#if HAVE_THREADS + if (pthread_mutex_init(&scan.exclude_mutex, NULL)) { + return -1; + } +#endif + } if (git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) { scan.test_barrier_path = getenv( @@ -82,16 +234,44 @@ int preload_bulk_collect(struct index_state *istate, int threads, CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); if (!start_error) { + if (scan.collect_untracked) { + exclude_proof = exclude_source_proof_create( + istate, &scan, open_exclude_parent); + exclude_dir.internal.exclude_source_proof = + exclude_proof; + setup_standard_excludes(&exclude_dir); + } scan_error = preload_bulk_run_scan(&scan, &run_result); if (!scan_error) scan_error = preload_bulk_test_barrier(&scan, ""); finish_error = backend->finish(&scan); + if (!scan_error && !finish_error && + run_result.untracked_complete) { + int exclude_proof_valid; + + trace2_region_enter( + "index", "preload/bulk_excludes", istate->repo); + exclude_proof_valid = + exclude_source_proof_validate(exclude_proof); + if (!exclude_proof_valid) { + run_result.untracked_complete = 0; + untracked_reason = "exclude-race"; + } + trace2_region_leave( + "index", "preload/bulk_excludes", istate->repo); + } } clean = !start_error && !scan_error && !finish_error && !run_result.changed_dirs && !run_result.malformed; + if (clean && run_result.untracked_complete && + collect_untracked_paths(&scan, result)) { + run_result.untracked_complete = 0; + untracked_reason = "duplicate-path"; + } result->run = run_result; + result->untracked_reason = untracked_reason; if (start_error) { result->outcome = "start-fallback"; result->reason = start_error; @@ -116,10 +296,26 @@ int preload_bulk_collect(struct index_state *istate, int threads, result->nr = istate->cache_nr; result->can_skip_unseen_preload = scan.can_skip_unseen_preload; + result->untracked_complete = run_result.untracked_complete; scan.tracked_state = NULL; } backend->release(&scan); + while (scan.untracked_roots) { + struct preload_bulk_untracked_root *next = + scan.untracked_roots->next; + + free(scan.untracked_roots); + scan.untracked_roots = next; + } + string_list_clear(&scan.untracked, 0); + if (scan.exclude_dir) { +#if HAVE_THREADS + pthread_mutex_destroy(&scan.exclude_mutex); +#endif + dir_clear(&exclude_dir); + exclude_source_proof_release(exclude_proof); + } free(scan.tracked_state); return clean ? 0 : -1; } @@ -127,5 +323,6 @@ int preload_bulk_collect(struct index_state *istate, int threads, void preload_bulk_result_release(struct preload_bulk_result *result) { FREE_AND_NULL(result->tracked_state); + string_list_clear(&result->untracked, 0); memset(result, 0, sizeof(*result)); } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index fff436d23f8d4b..317a9cb244275d 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -4,8 +4,12 @@ #include "git-compat-util.h" #include "preload-index.h" #include "strbuf.h" +#include "string-list.h" #include "thread-utils.h" +struct dir_struct; +struct preload_bulk_untracked_root; + struct preload_bulk_dir_identity { struct stat stat; unsigned complete : 1; @@ -34,6 +38,7 @@ struct preload_bulk_queue { size_t open_fds; size_t open_fd_limit; int failed; + unsigned untracked_invalid : 1; }; struct preload_bulk_scan; @@ -52,9 +57,12 @@ struct preload_bulk_worker { }; struct preload_bulk_backend { + unsigned collects_untracked : 1; const char *(*start)(struct preload_bulk_scan *scan); const char *(*finish)(struct preload_bulk_scan *scan); void (*release)(struct preload_bulk_scan *scan); + int (*open_proof_parent)(struct preload_bulk_scan *scan, + const char *path); int (*open_dir_at)(struct preload_bulk_worker *worker, int parent_fd, const char *name); /* @@ -76,8 +84,13 @@ struct preload_bulk_scan { struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; + struct dir_struct *exclude_dir; + pthread_mutex_t exclude_mutex; + struct preload_bulk_untracked_root *untracked_roots; + struct string_list untracked; int root_fd; int threads; + unsigned collect_untracked : 1; unsigned case_insensitive : 1; unsigned can_skip_unseen_preload : 1; }; @@ -89,6 +102,7 @@ struct preload_bulk_run_result { uint64_t changed_dirs; uint64_t malformed; int threads; + unsigned untracked_complete : 1; }; struct preload_bulk_result { @@ -96,8 +110,11 @@ struct preload_bulk_result { size_t nr; const char *outcome; const char *reason; + const char *untracked_reason; struct preload_bulk_run_result run; unsigned can_skip_unseen_preload : 1; + struct string_list untracked; + unsigned untracked_complete : 1; }; void preload_bulk_schedule_directory( @@ -120,6 +137,22 @@ void preload_bulk_record_tracked_descendants_fallback( int preload_bulk_record_tracked_alias_fallback( struct preload_bulk_worker *worker, const char *path, size_t path_len); +int preload_bulk_path_is_excluded(struct preload_bulk_worker *worker, + const char *path, int dtype); +void preload_bulk_invalidate_untracked( + struct preload_bulk_worker *worker); +int preload_bulk_untracked_is_invalid( + struct preload_bulk_worker *worker); +struct preload_bulk_untracked_root *preload_bulk_untracked_root_new( + struct preload_bulk_worker *worker, const char *path, + size_t path_len); +int preload_bulk_untracked_root_is_visible( + struct preload_bulk_worker *worker, + const struct preload_bulk_untracked_root *root); +void preload_bulk_record_untracked( + struct preload_bulk_worker *worker, + struct preload_bulk_untracked_root *root, + const char *path); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); const struct preload_bulk_backend *preload_bulk_platform_backend(void); diff --git a/preload-index.c b/preload-index.c index 72d37ae93e67d0..fbe83f8e7a1a89 100644 --- a/preload-index.c +++ b/preload-index.c @@ -251,6 +251,10 @@ static void preload_bulk_trace_result( if (result->reason) trace2_data_string("index", index->repo, "preload/bulk_reason", result->reason); + if (result->untracked_reason) + trace2_data_string("index", index->repo, + "preload/bulk_untracked_reason", + result->untracked_reason); trace2_data_intmax("index", index->repo, "preload/bulk_applied", applied); trace2_data_intmax("index", index->repo, "preload/bulk_dirs", @@ -271,6 +275,12 @@ static void preload_bulk_trace_result( "preload/bulk_content_check", content_check); trace2_data_intmax("index", index->repo, "preload/bulk_fallback", fallback); + trace2_data_intmax("index", index->repo, + "preload/bulk_untracked_complete", + result->untracked_complete); + trace2_data_intmax("index", index->repo, + "preload/bulk_untracked_count", + result->untracked.nr); } static unsigned char *preload_bulk_try(struct index_state *index) @@ -315,6 +325,11 @@ static unsigned char *preload_bulk_try(struct index_state *index) tracked_state = result.tracked_state; result.tracked_state = NULL; } + if (result.untracked_complete && index->preload_untracked) { + *index->preload_untracked = result.untracked; + result.untracked = + (struct string_list)STRING_LIST_INIT_DUP; + } trace2_region_leave("index", "preload/bulk", index->repo); preload_bulk_result_release(&result); return tracked_state; @@ -353,6 +368,8 @@ void preload_index(struct index_state *index, int core_preload_index = 1; preload_index_bulk_result_clear(index); + if (index->preload_untracked) + string_list_clear(index->preload_untracked, 0); repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); if (!core_preload_index) diff --git a/read-cache-ll.h b/read-cache-ll.h index 312b1f9a83e957..85881551735987 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -144,6 +144,7 @@ static inline unsigned create_ce_flags(unsigned stage) struct split_index; struct clean_status_state; struct untracked_cache; +struct string_list; struct progress; struct pattern_list; @@ -197,6 +198,8 @@ struct index_state { struct untracked_cache *untracked; unsigned char *preload_bulk_tracked_state; size_t preload_bulk_tracked_nr; + /* Borrowed for the duration of preload_index(). */ + struct string_list *preload_untracked; char *fsmonitor_last_update; char *fsmonitor_last_update_pending; char *fsmonitor_untracked_token; From 0fd4a93758eb6df52b9dc82ded3f677bd67a4a9d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 10:31:40 -0500 Subject: [PATCH 208/403] t7529: cover APFS preload directory and root replacement A queued directory can be replaced after its parent is enumerated, and the configured worktree root can change after the bulk walk completes. Neither race may leave previously collected observations published. Add a test-only barrier after opening a selected directory or after the complete walk. Arm it only when the existing bulk-preload test override is enabled, and use it to replace a queued child or the worktree root while status is paused. Require both integration cases to discard all bulk observations and produce the same output as ordinary status. These tests check namespace identity; they do not assert a scan-wide snapshot of individual files. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 2 + preload-index-bulk.c | 31 ++++++++++++ preload-index-bulk.h | 5 ++ t/README | 9 ++++ t/t7529-preload-index-apfs.sh | 79 ++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 9b9adba79b2ccd..f5fb62af26f4d3 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -424,6 +424,8 @@ static int scan_directory(struct preload_bulk_worker *worker, fd = preload_bulk_darwin_open_relative(scan, task->path); if (fd < 0) goto out; + if (preload_bulk_test_barrier(scan, task->path)) + goto out; if (preload_bulk_darwin_fd_on_root_mount(scan, fd, &before)) { if (errno != EXDEV) goto out; diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 31c370e2813e58..97fbd4b6ef22ff 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -14,6 +15,25 @@ int preload_bulk_available(void) return backend_available(preload_bulk_platform_backend()); } +int preload_bulk_test_barrier(struct preload_bulk_scan *scan, + const char *path) +{ + struct strbuf buf = STRBUF_INIT; + int result; + + if (!scan->test_barrier_path || + strcmp(scan->test_barrier_path, path)) + return 0; + if (!scan->test_barrier_ready || !scan->test_barrier_resume) + return -1; + + write_file(scan->test_barrier_ready, "ready"); + result = strbuf_read_file(&buf, scan->test_barrier_resume, 1) > 0 ? + 0 : -1; + strbuf_release(&buf); + return result; +} + int preload_bulk_collect(struct index_state *istate, int threads, struct preload_bulk_result *result) { @@ -37,10 +57,21 @@ int preload_bulk_collect(struct index_state *istate, int threads, if (!backend_available(backend)) return -1; + if (git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) { + scan.test_barrier_path = getenv( + "GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH"); + scan.test_barrier_ready = getenv( + "GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY"); + scan.test_barrier_resume = getenv( + "GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME"); + } + CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); if (!start_error) { scan_error = preload_bulk_run_scan(&scan, &run_result); + if (!scan_error) + scan_error = preload_bulk_test_barrier(&scan, ""); finish_error = backend->finish(&scan); } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 45899e56e58a46..61bce71a454268 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -70,6 +70,9 @@ struct preload_bulk_scan { struct index_state *istate; const struct preload_bulk_backend *backend; void *platform_data; + const char *test_barrier_path; + const char *test_barrier_ready; + const char *test_barrier_resume; struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; @@ -115,6 +118,8 @@ const struct preload_bulk_backend *preload_bulk_platform_backend(void); int preload_bulk_collect(struct index_state *istate, int threads, struct preload_bulk_result *result); int preload_bulk_available(void); +int preload_bulk_test_barrier(struct preload_bulk_scan *scan, + const char *path); void preload_bulk_result_release(struct preload_bulk_result *result); #endif /* PRELOAD_INDEX_BULK_H */ diff --git a/t/README b/t/README index 6ba21aead64858..e063266240fda9 100644 --- a/t/README +++ b/t/README @@ -425,6 +425,15 @@ by overriding the minimum number of cache entries required per thread. GIT_TEST_PRELOAD_INDEX_BULK= overrides the `core.preloadIndexBulk` setting. +GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH=, +GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY=, and +GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME=, when +GIT_TEST_PRELOAD_INDEX_BULK is enabled, pause a bulk preload before +scanning the named directory. An empty directory path pauses after the +complete walk. Git writes `ready` to the ready path, then waits until it +can read from the resume path. Tests which set one barrier variable must +set all three. + GIT_TEST_INDEX_THREADS= enables exercising the multi-threaded loading of the index for the whole test suite by bypassing the default number of cache entries and thread minimums. Setting this to 1 will make the diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index 87b49869515151..a54b049e75c5b2 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -127,6 +127,61 @@ test_expect_success 'bulk preload waits for fsmonitor provider closure' ' test_grep ! "\"key\":\"preload/bulk_result\"" fsmonitor.trace ' +cleanup_race () { + exec 9>&- + if test -n "$status_pid" + then + kill "$status_pid" 2>/dev/null || : + wait "$status_pid" 2>/dev/null || : + fi + status_pid= && + rm -f "$ready" "$resume" +} + +wait_for_ready () { + for i in $(test_seq 1 1000) + do + test "$(cat "$ready" 2>/dev/null)" = ready && return 0 + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +start_raced_status () { + repo=$1 && + barrier=$2 && + ready=$TRASH_DIRECTORY/$repo.ready && + resume=$TRASH_DIRECTORY/$repo.resume && + race_trace=$TRASH_DIRECTORY/$repo.trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH="$barrier" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY="$ready" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + git -C "$repo" status --porcelain=v2 >actual 9>&- & + status_pid=$! + } && + wait_for_ready +} + +finish_raced_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= && + ordinary_status "$1" expect && + test_cmp expect actual && + test_trace2_data index preload/bulk_applied 0 <"$race_trace" +} + test_expect_success 'clean entries are published without lstat' ' setup_repo clean && bulk_status clean actual clean.trace && @@ -259,4 +314,28 @@ test_expect_success 'multiply-linked entries are left to lstat' ' check_lstat_data hardlink.trace 1 ' +test_expect_success PIPE 'queued child replacement discards observations' ' + setup_repo child-race && + test_when_finished cleanup_race && + start_raced_status child-race nested/deep && + mv child-race/nested/deep child-race/nested/deep-away && + mkdir child-race/nested/deep && + test_write_lines dirty >child-race/nested/deep/tracked && + test_write_lines deep-peer >child-race/nested/deep/peer && + finish_raced_status child-race && + test_file_not_empty actual +' + +test_expect_success PIPE,SYMLINKS \ + 'worktree root replacement discards observations' ' + setup_repo root-race && + test_when_finished cleanup_race && + start_raced_status root-race "" && + mv root-race root-race-away && + ln -s root-race-away root-race && + test_write_lines dirty >root-race-away/root && + finish_raced_status root-race && + test_file_not_empty actual +' + test_done From a58ee81d58a8f509644ccd01148283cdb8e9e2a5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:26:44 -0500 Subject: [PATCH 209/403] preload-index: collect visible paths during the APFS walk The APFS tracked preload encounters untracked entries but ordinarily discards them. Repeating the entire directory walk to rediscover those entries costs work even when the existing scan can establish their visibility. Teach the APFS backend to advertise visible-path collection and supply its root-anchored exclude-parent opener. Classify regular files and symlinks with the normal exclusion machinery, and follow untracked directories only until their first visible descendant establishes the single directory entry that normal-mode status reports. Keep the top-level Git directory and tracked gitlinks out of the untracked result. Case aliases, embedded repositories, and foreign mounts invalidate provisional untracked observations while retaining separately valid tracked results. A replaced directory increments changed_dirs, so scan-wide validation discards the entire bulk result. Carry the collapsed-directory root through queued workers. Ordinary status remains unchanged until a caller explicitly requests the new backend capability. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 106 ++++++++++++++++++++++++++--- preload-index-bulk-index.c | 7 ++ preload-index-bulk-thread.c | 4 ++ preload-index-bulk.h | 4 ++ 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 882cc64a41f64b..7e65c5a24c78eb 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -6,6 +6,7 @@ #include "compat/precompose_utf8.h" #include "compat/preload-index/bulk-darwin.h" +#include "dir.h" #include "path-namespace.h" #include "preload-index-bulk.h" @@ -329,6 +330,10 @@ static int enumerate_directory(struct preload_bulk_worker *worker, size_t remaining; int pos; + if (scan->collect_untracked && + preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) + return 0; remaining = buf + PRELOAD_INDEX_BULK_BUFFER_SIZE - record; if (decode_entry(record, remaining, &entry)) goto malformed; @@ -344,6 +349,17 @@ static int enumerate_directory(struct preload_bulk_worker *worker, strbuf_addstr(&worker->path, path_name); if (path_name != entry.name) free((char *)path_name); + if (scan->collect_untracked) { + if (!strcmp(task->path, ".") && + !fspathcmp(worker->path.buf, ".git")) + goto next_record; + if (strcmp(task->path, ".") && + !fspathcmp(entry.name, ".git")) { + preload_bulk_invalidate_untracked( + worker); + goto next_record; + } + } pos = preload_bulk_index_position(scan, worker->path.buf, worker->path.len); @@ -358,20 +374,51 @@ static int enumerate_directory(struct preload_bulk_worker *worker, .st_ctimespec = entry.ctime, }, }; + struct preload_bulk_untracked_root *untracked_root = + task->untracked_root; + int has_tracked_descendants; if (pos >= 0) { + if (scan->collect_untracked && + preload_bulk_index_entry_is_gitlink( + scan, pos)) + goto next_record; preload_bulk_record_tracked_fallback( worker, pos); goto next_record; } - if (!preload_bulk_index_pos_has_tracked_descendants( - scan, worker->path.buf, - worker->path.len, pos)) { - preload_bulk_record_tracked_alias_fallback( - worker, worker->path.buf, - worker->path.len); + has_tracked_descendants = + preload_bulk_index_pos_has_tracked_descendants( + scan, worker->path.buf, + worker->path.len, pos); + if (!has_tracked_descendants && + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len)) { + if (scan->collect_untracked) + preload_bulk_invalidate_untracked( + worker); goto next_record; } + if (!has_tracked_descendants) { + if (!scan->collect_untracked || + preload_bulk_untracked_is_invalid( + worker)) + goto next_record; + if (preload_bulk_untracked_root_is_visible( + worker, untracked_root)) + goto next_record; + if (preload_bulk_path_is_excluded( + worker, worker->path.buf, + DT_DIR)) + goto next_record; + if (!untracked_root) + untracked_root = + preload_bulk_untracked_root_new( + worker, + worker->path.buf, + worker->path.len); + } if (((entry.access & S_IFMT) && (entry.access & S_IFMT) != S_IFDIR) || (entry.access & ~(S_IFMT | 07777))) @@ -382,20 +429,50 @@ static int enumerate_directory(struct preload_bulk_worker *worker, preload_bulk_record_tracked_descendants_fallback( worker, worker->path.buf, worker->path.len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked( + worker); goto next_record; } preload_bulk_schedule_directory( worker, fd, parent_identity, - &child_identity, entry.name, + &child_identity, untracked_root, + entry.name, worker->path.buf, worker->path.len); goto next_record; } if (pos < 0) { - preload_bulk_record_tracked_alias_fallback( - worker, worker->path.buf, - worker->path.len); + int found_alias = + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); + + if (scan->collect_untracked && + !preload_bulk_untracked_is_invalid( + worker)) { + int dtype; + + if (found_alias) { + preload_bulk_invalidate_untracked( + worker); + goto next_record; + } + if (entry.type == VREG) + dtype = DT_REG; + else if (entry.type == VLNK) + dtype = DT_LNK; + else + goto next_record; + if (!preload_bulk_path_is_excluded( + worker, worker->path.buf, + dtype)) + preload_bulk_record_untracked( + worker, + task->untracked_root, + worker->path.buf); + } goto next_record; } if (entry.dev != data->root_stat.st_dev) { @@ -457,6 +534,8 @@ static int scan_directory(struct preload_bulk_worker *worker, path_len = strlen(task->path); preload_bulk_record_tracked_descendants_fallback( worker, task->path, path_len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); ret = 0; goto out; } @@ -471,7 +550,10 @@ static int scan_directory(struct preload_bulk_worker *worker, goto out; } before_identity = directory_identity(&before); - if (enumerate_directory(worker, task, fd, &before_identity)) + if ((!scan->collect_untracked || + !preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) && + enumerate_directory(worker, task, fd, &before_identity)) goto out; if (fstat(fd, &after)) goto out; @@ -518,9 +600,11 @@ static const char *finish_scan(struct preload_bulk_scan *scan) } static const struct preload_bulk_backend darwin_backend = { + .collects_untracked = 1, .start = start_scan, .finish = finish_scan, .release = preload_bulk_darwin_release, + .open_proof_parent = preload_bulk_darwin_open_relative, .open_dir_at = preload_bulk_darwin_open_dir_at, .scan_directory = scan_directory, }; diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index bd26b72ccb2986..b09b69582397a8 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "name-hash.h" +#include "object.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -108,6 +109,12 @@ static int size_change_is_definitive(const struct cache_entry *ce, DATA_CHANGED); } +int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, + int pos) +{ + return pos >= 0 && S_ISGITLINK(scan->istate->cache[pos]->ce_mode); +} + void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st) { diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 61702a5a5ba07a..793bb79f6a670a 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -53,6 +53,7 @@ void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, const struct preload_bulk_dir_identity *child_identity, + struct preload_bulk_untracked_root *untracked_root, const char *name, const char *path, size_t path_len) { struct preload_bulk_scan *scan = worker->scan; @@ -67,6 +68,7 @@ void preload_bulk_schedule_directory( task->child_identity = *child_identity; task->has_child_identity = 1; } + task->untracked_root = untracked_root; task->fd = -1; if (reserve_open_fd(&scan->queue)) { task->reserved_fd = 1; @@ -79,6 +81,8 @@ void preload_bulk_schedule_directory( if (saved_errno == EXDEV) { preload_bulk_record_tracked_descendants_fallback( worker, path, path_len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); free(task); return; } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 317a9cb244275d..2934d31b8674c1 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -19,6 +19,7 @@ struct preload_bulk_task { struct preload_bulk_task *next; struct preload_bulk_dir_identity parent_identity; struct preload_bulk_dir_identity child_identity; + struct preload_bulk_untracked_root *untracked_root; int fd; unsigned reserved_fd : 1; unsigned has_parent_identity : 1; @@ -121,12 +122,15 @@ void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, const struct preload_bulk_dir_identity *child_identity, + struct preload_bulk_untracked_root *untracked_root, const char *name, const char *path, size_t path_len); int preload_bulk_index_position(struct preload_bulk_scan *scan, const char *path, size_t path_len); int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos); +int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, + int pos); void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st); void preload_bulk_record_tracked_fallback( From 9a02cea1bb70b7d3941c959ea3292e1d131643ac Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:27:01 -0500 Subject: [PATCH 210/403] status: reuse complete APFS untracked preload results Normal-mode status walks the worktree for untracked paths even after the opted-in APFS preloader has visited the same directories. Reusing that walk is incorrect if status requests different reporting semantics or the bulk scan cannot prove that its exclusion sources remain valid. Request visible paths only for an expanded index in normal untracked mode without a pathspec, ignored output, or an untracked cache. The bulk path requires core.preloadIndex, core.preloadIndexBulk, and a disabled fsmonitor. Transfer paths only after the bulk scan, worktree-namespace checks, and anchored exclusion-source proof finish successfully. Complete the ordinary tracked refresh, clear the borrowed index destination, and skip the second directory walk only for a complete untracked result. Retain ordinary traversal for unsupported backends, incomplete proofs, case aliases, embedded repositories, changed exclusion sources, and ineligible reporting modes. A failed untracked proof preserves independently valid tracked results; a changed directory instead invalidates the entire bulk scan. Extend the APFS tests to compare output with ordinary status. Cover collapsed directories, ignored-only and empty directories, special files, activation guards, case aliases, nested repositories, tracked submodules, separate Git directories, changed configured and repository exclusions, a newly appearing configured exclusion, bidirectional per-directory changes, hard-linked exclusions, and tracked-file replacement. Signed-off-by: Taylor Blau --- preload-index.c | 2 + read-cache-ll.h | 3 +- t/t7529-preload-index-apfs.sh | 264 ++++++++++++++++++++++++++++++++++ wt-status.c | 21 ++- wt-status.h | 2 + 5 files changed, 289 insertions(+), 3 deletions(-) diff --git a/preload-index.c b/preload-index.c index fbe83f8e7a1a89..d7c7f99896c28e 100644 --- a/preload-index.c +++ b/preload-index.c @@ -327,6 +327,7 @@ static unsigned char *preload_bulk_try(struct index_state *index) } if (result.untracked_complete && index->preload_untracked) { *index->preload_untracked = result.untracked; + index->preload_untracked_complete = 1; result.untracked = (struct string_list)STRING_LIST_INIT_DUP; } @@ -368,6 +369,7 @@ void preload_index(struct index_state *index, int core_preload_index = 1; preload_index_bulk_result_clear(index); + index->preload_untracked_complete = 0; if (index->preload_untracked) string_list_clear(index->preload_untracked, 0); repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); diff --git a/read-cache-ll.h b/read-cache-ll.h index 85881551735987..32496bade97a6f 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -190,7 +190,8 @@ struct index_state { fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, - fsmonitor_pending_token_from_provider : 1; + fsmonitor_pending_token_from_provider : 1, + preload_untracked_complete : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index c7c399045f2e1e..b726b7559b9b3d 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -74,6 +74,23 @@ compare_status () { test_cmp expect actual } +compare_fallback_status () { + repo=$1 && + fallback_trace=$TRASH_DIRECTORY/$2 && + shift 2 && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$repo" -c core.preloadIndex=false \ + status --porcelain=v2 "$@" >expect && + rm -f "$fallback_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$fallback_trace" \ + git -C "$repo" status --porcelain=v2 "$@" >actual && + test_cmp expect actual && + test_grep "\"category\":\"read_directory\"" "$fallback_trace" +} + configured_bulk_status () { repo=$1 && output=$2 && @@ -182,11 +199,23 @@ finish_raced_status () { test_trace2_data index preload/bulk_applied 0 <"$race_trace" } +finish_raced_untracked_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= && + ordinary_status "$1" expect && + test_cmp expect actual && + test_trace2_data index preload/bulk_applied "$2" <"$race_trace" +} + test_expect_success 'clean entries are published without lstat' ' setup_repo clean && bulk_status clean actual clean.trace && test_must_be_empty actual && check_data clean.trace preload/bulk_applied 8 && + check_data clean.trace preload/bulk_untracked_complete 1 && + check_data clean.trace preload/bulk_untracked_count 0 && check_lstat_data clean.trace 0 ' @@ -257,6 +286,229 @@ test_expect_success CASE_INSENSITIVE_FS \ } ' +test_expect_success 'visible paths are returned by the bulk walk' ' + setup_repo visible-output && + test_write_lines "*.ignored" >visible-output/.gitignore && + git -C visible-output add .gitignore && + git -C visible-output commit -m ignore && + test_write_lines root >visible-output/root-untracked && + test_write_lines nested >visible-output/nested/untracked && + mkdir -p visible-output/collapsed/deep \ + visible-output/ignored-only/deep \ + visible-output/empty && + test_write_lines collapsed >visible-output/collapsed/deep/file && + test_write_lines ignored >visible-output/ignored-only/deep/file.ignored && + compare_status visible-output visible-output.trace && + test_grep "^? root-untracked$" actual && + test_grep "^? nested/untracked$" actual && + test_grep "^? collapsed/$" actual && + test_grep ! "ignored-only" actual && + test_grep ! "empty" actual && + check_data visible-output.trace preload/bulk_untracked_complete 1 && + check_data visible-output.trace preload/bulk_untracked_count 3 && + test_grep ! "\"category\":\"read_directory\"" \ + visible-output.trace +' + +test_expect_success PIPE 'special files are ignored' ' + setup_repo special-file && + mkfifo special-file/fifo && + compare_status special-file special-file.trace && + test_must_be_empty actual && + check_data special-file.trace preload/bulk_untracked_complete 1 && + check_data special-file.trace preload/bulk_untracked_count 0 +' + +test_expect_success 'activation guards retain ordinary traversal' ' + setup_repo activation-guards && + test_write_lines "*.ignored" >activation-guards/.gitignore && + git -C activation-guards add .gitignore && + git -C activation-guards commit -m ignore && + test_write_lines visible >activation-guards/visible && + test_write_lines ignored >activation-guards/file.ignored && + compare_fallback_status activation-guards all.trace \ + --untracked-files=all && + compare_fallback_status activation-guards ignored.trace \ + --ignored && + compare_fallback_status activation-guards pathspec.trace \ + -- nested && + git -C activation-guards update-index --untracked-cache && + compare_fallback_status activation-guards untracked-cache.trace +' + +test_expect_success CASE_INSENSITIVE_FS 'case aliases fall back' ' + setup_repo untracked-case-alias && + mv untracked-case-alias/root untracked-case-alias/ROOT && + compare_status untracked-case-alias untracked-case-alias.trace && + check_data untracked-case-alias.trace preload/bulk_untracked_complete 0 && + check_data untracked-case-alias.trace preload/bulk_untracked_count 0 +' + +test_expect_success 'nested repositories fall back' ' + setup_repo nested-repo && + test_write_lines "embedded/**" >nested-repo/.gitignore && + git -C nested-repo add .gitignore && + git -C nested-repo commit -m ignore && + mkdir nested-repo/embedded && + git -C nested-repo/embedded init && + test_write_lines ignored >nested-repo/embedded/file && + compare_status nested-repo nested-repo.trace && + test_grep "^? embedded/$" actual && + check_data nested-repo.trace preload/bulk_untracked_complete 0 +' + +test_expect_success 'tracked submodules retain collected paths' ' + git init submodule-child && + git -C submodule-child commit --allow-empty -m base && + setup_repo submodule-parent && + git -C submodule-parent -c protocol.file.allow=always \ + submodule add ../submodule-child embedded && + git -C submodule-parent commit -m submodule && + git -C submodule-parent update-index --refresh && + test_write_lines visible >submodule-parent/visible && + compare_status submodule-parent submodule-parent.trace && + test_grep "^? visible$" actual && + check_data submodule-parent.trace preload/bulk_untracked_complete 1 +' + +test_expect_success 'exclude changes discard collected paths' ' + setup_repo exclude-race && + exclude=$TRASH_DIRECTORY/exclude-race.patterns && + test_write_lines visible >"$exclude" && + git -C exclude-race config core.excludesFile "$exclude" && + test_write_lines visible >exclude-race/visible && + test_when_finished cleanup_race && + start_raced_status exclude-race "" && + >"$exclude" && + finish_raced_untracked_status exclude-race 8 && + test_grep "^? visible$" actual && + check_data exclude-race.trace preload/bulk_untracked_complete 0 && + check_data exclude-race.trace preload/bulk_untracked_reason exclude-race +' + +test_expect_success 'info exclude changes discard collected paths' ' + setup_repo info-exclude && + test_write_lines visible >info-exclude/.git/info/exclude && + test_write_lines visible >info-exclude/visible && + test_when_finished cleanup_race && + start_raced_status info-exclude "" && + >info-exclude/.git/info/exclude && + finish_raced_untracked_status info-exclude 8 && + test_grep "^? visible$" actual && + check_data info-exclude.trace preload/bulk_untracked_complete 0 && + check_data info-exclude.trace \ + preload/bulk_untracked_reason exclude-race +' + +test_expect_success 'new exclusion source discards collected paths' ' + setup_repo absent-exclude && + exclude_dir=$TRASH_DIRECTORY/absent-exclude-config && + exclude=$exclude_dir/ignore && + rm -rf "$exclude_dir" && + git -C absent-exclude config core.excludesFile "$exclude" && + test_write_lines visible >absent-exclude/visible && + test_when_finished cleanup_race && + test_when_finished "rm -rf \"$exclude_dir\"" && + start_raced_status absent-exclude "" && + mkdir "$exclude_dir" && + test_write_lines visible >"$exclude" && + finish_raced_untracked_status absent-exclude 8 && + test_must_be_empty actual && + check_data absent-exclude.trace preload/bulk_untracked_complete 0 && + check_data absent-exclude.trace \ + preload/bulk_untracked_reason exclude-race +' + +test_expect_success 'separate-git-dir excludes are proven' ' + setup_repo separate-info && + mv separate-info/.git separate-info.git && + printf "gitdir: ../separate-info.git\n" >separate-info/.git && + test_write_lines visible >separate-info.git/info/exclude && + test_write_lines visible >separate-info/visible && + compare_status separate-info separate-info.trace && + test_must_be_empty actual && + check_data separate-info.trace preload/bulk_untracked_complete 1 +' + +test_expect_success 'nested per-directory excludes are closed both ways' ' + test_when_finished cleanup_race && + for direction in visible-to-ignored ignored-to-visible + do + repo=per-dir-$direction && + setup_repo "$repo" && + git -C "$repo" config core.trustctime false && + case "$direction" in + visible-to-ignored) + initial=nomatch && + updated=visible + ;; + ignored-to-visible) + initial=visible && + updated=nomatch + ;; + esac && + test_write_lines "$initial" >"$repo/nested/.gitignore" && + git -C "$repo" add nested/.gitignore && + git -C "$repo" commit -m ignore && + git -C "$repo" update-index --assume-unchanged \ + nested/.gitignore && + test_write_lines visible >"$repo/nested/visible" && + mtime=$(test-tool chmtime --get \ + "$repo/nested/.gitignore") && + start_raced_status "$repo" "" && + test_write_lines "$updated" >"$repo/nested/.gitignore" && + test-tool chmtime "=$mtime" "$repo/nested/.gitignore" && + finish_raced_untracked_status "$repo" 8 && + check_data "$repo.trace" \ + preload/bulk_untracked_complete 0 && + check_data "$repo.trace" \ + preload/bulk_untracked_reason exclude-race && + case "$direction" in + visible-to-ignored) + test_must_be_empty actual + ;; + ignored-to-visible) + test_grep "^? nested/visible$" actual + ;; + esac || + return 1 + done +' + +test_expect_success 'multiply-linked per-directory excludes are proven' ' + setup_repo linked-exclude && + test_write_lines visible >linked-exclude/.gitignore && + git -C linked-exclude add .gitignore && + git -C linked-exclude commit -m ignore && + test_write_lines visible >linked-exclude/visible && + ln linked-exclude/.gitignore linked-exclude-alias && + test_when_finished "rm -f linked-exclude-alias" && + compare_status linked-exclude linked-exclude.trace && + test_must_be_empty actual && + check_data linked-exclude.trace preload/bulk_untracked_complete 1 && + check_data linked-exclude.trace preload/bulk_applied 8 && + check_data linked-exclude.trace preload/bulk_untracked_count 0 +' + +test_expect_success 'multiply-linked exclude changes discard paths' ' + setup_repo linked-exclude-race && + test_write_lines visible >linked-exclude-race/.gitignore && + git -C linked-exclude-race add .gitignore && + git -C linked-exclude-race commit -m ignore && + test_write_lines visible >linked-exclude-race/visible && + ln linked-exclude-race/.gitignore linked-exclude-race-alias && + test_when_finished "rm -f linked-exclude-race-alias" && + test_when_finished cleanup_race && + start_raced_status linked-exclude-race "" && + test_write_lines nomatch >linked-exclude-race-alias && + finish_raced_untracked_status linked-exclude-race 8 && + test_grep "^? visible$" actual && + check_data linked-exclude-race.trace \ + preload/bulk_untracked_complete 0 && + check_data linked-exclude-race.trace \ + preload/bulk_untracked_reason exclude-race +' + test_expect_success ULIMIT_FILE_DESCRIPTORS \ 'bulk preload reopens directories under a low descriptor limit' ' git init low-fd && @@ -314,6 +566,18 @@ test_expect_success SYMLINKS \ test_file_not_empty actual ' +test_expect_success 'tracked-file replacement directories are pruned' ' + setup_repo replacement-dir && + rm replacement-dir/root && + mkdir -p replacement-dir/root/deep/embedded && + test_write_lines hidden >replacement-dir/root/deep/untracked && + git -C replacement-dir/root/deep/embedded init && + compare_status replacement-dir replacement-dir.trace && + test_line_count = 1 actual && + check_data replacement-dir.trace preload/bulk_fallback 1 && + check_data replacement-dir.trace preload/bulk_untracked_complete 1 +' + test_expect_success 'staged and unmerged entries agree' ' setup_repo index-states && test_write_lines staged >index-states/root && diff --git a/wt-status.c b/wt-status.c index b9e9e97d1cc5e4..38a2743b9db34d 100644 --- a/wt-status.c +++ b/wt-status.c @@ -946,6 +946,10 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) return; } + if (s->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !istate->untracked && + istate->sparse_index == INDEX_EXPANDED) + istate->preload_untracked = &s->untracked; /* Restore verified stats before cached excludes inspect them. */ if (fstat_is_reliable() && !istate->split_index && fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && @@ -996,6 +1000,10 @@ static int wt_status_collect_untracked_1( if (!s->show_untracked_files) return 0; + if (s->untracked_from_preload && + !istate->untracked && + !s->show_ignored_mode) + return 0; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -1482,13 +1490,22 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked) { + struct index_state *istate = s->repo->index; struct semantic_verify_proof *proof; + int ret; wt_status_begin_attr_snapshot(s); - refresh_fsmonitor(s->repo->index); + refresh_fsmonitor(istate); proof = wt_status_prepare_semantic_verify(s); - return wt_status_close_fsmonitor_token( + ret = wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); + if (istate->preload_untracked == &s->untracked) { + s->untracked_from_preload = + istate->preload_untracked_complete; + istate->preload_untracked = NULL; + istate->preload_untracked_complete = 0; + } + return ret; } static void wt_status_release_attr_snapshot(struct wt_status *s) diff --git a/wt-status.h b/wt-status.h index 0f7104b4c6ac5f..99b005cb8b5cea 100644 --- a/wt-status.h +++ b/wt-status.h @@ -141,6 +141,8 @@ struct wt_status { int committable; int workdir_dirty; unsigned untracked_from_token_closure : 1; + unsigned untracked_from_preload : 1; + unsigned bulk_update_index_stat : 1; const char *index_file; FILE *fp; const char *prefix; From 152483c13c56c1c47e8efb4fe2d5ba5f56eeb052 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:54:41 -0500 Subject: [PATCH 211/403] preload-index: retain proven deletions from complete bulk scans An APFS bulk scan already observes which expanded-index paths are present, but its preload consumer treats an unseen tracked entry as something ordinary preload must stat again. Missing subtrees therefore trigger redundant speculative lookups. Case-folded aliases, mount crossings, unsupported vnodes, and multiply-linked entries must not be mistaken for deletions. Retain a complete scan's per-entry state through threaded preload and classify an unseen useful entry as definitively deleted only when the expanded index makes that conclusion safe. Initialize case-folded name hashes before workers start, and record explicit per-entry fallback for aliases, mount boundaries, tracked directories, and unsupported vnode types. Leave multiply-linked tracked files to ordinary lstat. Leave collapsed sparse indexes and incomplete scans on the existing ordinary path. The APFS tests compare bulk and ordinary status for missing paths, content mismatches, aliases, unsupported tracked types, and hardlinks. Their Trace2 assertions verify that proven deletions avoid speculative lstat while authoritative refresh and fallback entries retain ordinary checks. Retaining per-entry state through preload extends its temporary lifetime; release it when preload finishes. Refresh the case-alias fixture after its case-only renames so ordinary and bulk status compare the same index baseline even when a rename crosses a filesystem timestamp boundary. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 42 ++++++++++-- name-hash.c | 8 +++ name-hash.h | 2 + preload-index-bulk-index.c | 101 ++++++++++++++++++++------- preload-index-bulk-thread.c | 2 + preload-index-bulk.c | 15 +++++ preload-index-bulk.h | 14 +++- preload-index.c | 105 +++++++++++++++++++++++++---- preload-index.h | 1 + t/t7529-preload-index-apfs.sh | 59 +++++++++++++++- 10 files changed, 299 insertions(+), 50 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index f5fb62af26f4d3..882cc64a41f64b 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -345,6 +345,8 @@ static int enumerate_directory(struct preload_bulk_worker *worker, if (path_name != entry.name) free((char *)path_name); + pos = preload_bulk_index_position(scan, worker->path.buf, + worker->path.len); if (entry.type == VDIR) { struct preload_bulk_dir_identity child_identity = { .stat = { @@ -357,10 +359,19 @@ static int enumerate_directory(struct preload_bulk_worker *worker, }, }; - if (!preload_bulk_index_has_tracked_descendants( + if (pos >= 0) { + preload_bulk_record_tracked_fallback( + worker, pos); + goto next_record; + } + if (!preload_bulk_index_pos_has_tracked_descendants( scan, worker->path.buf, - worker->path.len)) + worker->path.len, pos)) { + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); goto next_record; + } if (((entry.access & S_IFMT) && (entry.access & S_IFMT) != S_IFDIR) || (entry.access & ~(S_IFMT | 07777))) @@ -368,6 +379,9 @@ static int enumerate_directory(struct preload_bulk_worker *worker, if (entry.dev != data->root_stat.st_dev || entry.mountstatus || (entry.flags & SF_FIRMLINK)) { + preload_bulk_record_tracked_descendants_fallback( + worker, worker->path.buf, + worker->path.len); goto next_record; } preload_bulk_schedule_directory( @@ -378,17 +392,27 @@ static int enumerate_directory(struct preload_bulk_worker *worker, goto next_record; } - pos = preload_bulk_index_position(scan, worker->path.buf, - worker->path.len); - if (pos < 0) + if (pos < 0) { + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); goto next_record; + } if (entry.dev != data->root_stat.st_dev) { + preload_bulk_record_tracked_fallback( + worker, pos); goto next_record; } - if (entry.type != VREG && entry.type != VLNK) + if (entry.type != VREG && entry.type != VLNK) { + preload_bulk_record_tracked_fallback( + worker, pos); goto next_record; - if (entry.linkcount != 1) + } + if (entry.linkcount != 1) { + preload_bulk_record_tracked_fallback( + worker, pos); goto next_record; + } if (fill_file_stat(&st, entry.dev, entry.fileid, entry.type, entry.mtime, entry.ctime, entry.uid, entry.gid, entry.access, @@ -417,6 +441,7 @@ static int scan_directory(struct preload_bulk_worker *worker, struct preload_bulk_scan *scan = worker->scan; struct preload_bulk_dir_identity before_identity; struct stat before, after; + size_t path_len; int fd = task->fd; int ret = -1; @@ -429,6 +454,9 @@ static int scan_directory(struct preload_bulk_worker *worker, if (preload_bulk_darwin_fd_on_root_mount(scan, fd, &before)) { if (errno != EXDEV) goto out; + path_len = strlen(task->path); + preload_bulk_record_tracked_descendants_fallback( + worker, task->path, path_len); ret = 0; goto out; } diff --git a/name-hash.c b/name-hash.c index 83757db8746230..47c659d6c75374 100644 --- a/name-hash.c +++ b/name-hash.c @@ -619,6 +619,14 @@ static void lazy_init_name_hash(struct index_state *istate) trace_performance_leave("initialize name hash"); } +int prepare_index_casefolding(struct index_state *istate) +{ + if (!repo_ignore_case(istate->repo)) + return 0; + lazy_init_name_hash(istate); + return 1; +} + /* * A test routine for t/helper/ sources. * diff --git a/name-hash.h b/name-hash.h index 0cbfc4286316b2..cc7e752ab1e27d 100644 --- a/name-hash.h +++ b/name-hash.h @@ -10,6 +10,8 @@ int index_dir_find(struct index_state *istate, const char *name, int namelen, #define index_dir_exists(i, n, l) index_dir_find((i), (n), (l), NULL) +/* Prepare the name and directory hashes for concurrent case-folded lookups. */ +int prepare_index_casefolding(struct index_state *istate); void adjust_dirname_case(struct index_state *istate, char *name); struct cache_entry *index_file_exists(struct index_state *istate, const char *name, int namelen, int igncase); diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index 623164822b5fdb..0d129b619cf2fc 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "name-hash.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -14,17 +15,23 @@ int preload_bulk_index_position(struct preload_bulk_scan *scan, return index_name_pos_sparse(scan->istate, path, path_len); } -int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, - const char *path, - size_t path_len) +static int first_tracked_descendant(struct index_state *istate, + const char *path, size_t path_len, + int pos) { - int pos; + while ((unsigned int)pos < istate->cache_nr) { + const struct cache_entry *ce = istate->cache[pos]; - if (path_len > INT_MAX) - return 0; - pos = index_name_pos_sparse(scan->istate, path, path_len); - return preload_bulk_index_pos_has_tracked_descendants( - scan, path, path_len, pos); + if (ce_namelen(ce) <= path_len || + memcmp(ce->name, path, path_len)) + return -1; + if (ce->name[path_len] == '/') + return pos; + if ((unsigned char)ce->name[path_len] > '/') + return -1; + pos++; + } + return -1; } int preload_bulk_index_pos_has_tracked_descendants( @@ -32,27 +39,11 @@ int preload_bulk_index_pos_has_tracked_descendants( int pos) { struct index_state *istate = scan->istate; - const struct cache_entry *ce; if (pos >= 0) return 0; pos = -pos - 1; - while ((unsigned int)pos < istate->cache_nr) { - ce = istate->cache[pos]; - if (ce_namelen(ce) < path_len || - memcmp(ce->name, path, path_len)) - return 0; - if (ce_namelen(ce) == path_len) { - pos++; - continue; - } - if (ce->name[path_len] == '/') - return 1; - if ((unsigned char)ce->name[path_len] > '/') - return 0; - pos++; - } - return 0; + return first_tracked_descendant(istate, path, path_len, pos) >= 0; } static int record_tracked_state(struct preload_bulk_worker *worker, int pos, @@ -112,3 +103,61 @@ void preload_bulk_record_tracked( PRELOAD_BULK_TRACKED_CLEAN; record_tracked_state(worker, pos, state); } + +void preload_bulk_record_tracked_fallback( + struct preload_bulk_worker *worker, int pos) +{ + if (!tracked_entry_is_eligible(worker->scan->istate->cache[pos])) + return; + record_tracked_state(worker, pos, + PRELOAD_BULK_TRACKED_FALLBACK); +} + +void preload_bulk_record_tracked_descendants_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len) +{ + struct index_state *istate = worker->scan->istate; + int pos = preload_bulk_index_position(worker->scan, path, path_len); + + if (pos < 0) + pos = -pos - 1; + else + pos++; + while ((pos = first_tracked_descendant(istate, path, path_len, pos)) >= 0) { + preload_bulk_record_tracked_fallback(worker, pos); + pos++; + } +} + +int preload_bulk_record_tracked_alias_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len) +{ + struct preload_bulk_scan *scan = worker->scan; + struct index_state *istate = scan->istate; + struct cache_entry *ce; + struct strbuf canonical = STRBUF_INIT; + int found = 0; + int pos; + + if (!scan->case_insensitive || path_len > INT_MAX) + return 0; + if (index_dir_find(istate, path, path_len, &canonical)) { + found = 1; + preload_bulk_record_tracked_descendants_fallback( + worker, canonical.buf, canonical.len); + goto out; + } + ce = index_file_exists(istate, path, path_len, 1); + if (!ce) + goto out; + found = 1; + pos = index_name_pos_sparse(istate, ce->name, ce_namelen(ce)); + if (pos >= 0) + preload_bulk_record_tracked_fallback(worker, pos); + +out: + strbuf_release(&canonical); + return found; +} diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 8f57f143d4761b..b1a8d430d8e21e 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -77,6 +77,8 @@ void preload_bulk_schedule_directory( task->reserved_fd = 0; release_open_fd(&scan->queue); if (saved_errno == EXDEV) { + preload_bulk_record_tracked_descendants_fallback( + worker, path, path_len); free(task); return; } diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 97fbd4b6ef22ff..41b2398d3913f3 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "name-hash.h" #include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" @@ -56,6 +57,18 @@ int preload_bulk_collect(struct index_state *istate, int threads, result->reason = "backend-unavailable"; if (!backend_available(backend)) return -1; + if (istate->sparse_index == INDEX_EXPANDED) { + /* + * Workers may need case-folding lookups for names returned by + * the filesystem. Build the lazy hash before they start. + * + * A collapsed sparse index cannot expand itself concurrently + * from the worker threads. Leave its unseen entries to the + * existing preload path, which expands them on the main thread. + */ + scan.case_insensitive = prepare_index_casefolding(istate); + scan.can_skip_unseen_preload = 1; + } if (git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) { scan.test_barrier_path = getenv( @@ -101,6 +114,8 @@ int preload_bulk_collect(struct index_state *istate, int threads, if (clean) { result->tracked_state = scan.tracked_state; result->nr = istate->cache_nr; + result->can_skip_unseen_preload = + scan.can_skip_unseen_preload; scan.tracked_state = NULL; } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 61bce71a454268..fff436d23f8d4b 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -78,6 +78,8 @@ struct preload_bulk_scan { unsigned char *tracked_state; int root_fd; int threads; + unsigned case_insensitive : 1; + unsigned can_skip_unseen_preload : 1; }; struct preload_bulk_run_result { @@ -95,6 +97,7 @@ struct preload_bulk_result { const char *outcome; const char *reason; struct preload_bulk_run_result run; + unsigned can_skip_unseen_preload : 1; }; void preload_bulk_schedule_directory( @@ -104,14 +107,19 @@ void preload_bulk_schedule_directory( const char *name, const char *path, size_t path_len); int preload_bulk_index_position(struct preload_bulk_scan *scan, const char *path, size_t path_len); -int preload_bulk_index_has_tracked_descendants(struct preload_bulk_scan *scan, - const char *path, - size_t path_len); int preload_bulk_index_pos_has_tracked_descendants( struct preload_bulk_scan *scan, const char *path, size_t path_len, int pos); void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st); +void preload_bulk_record_tracked_fallback( + struct preload_bulk_worker *worker, int pos); +void preload_bulk_record_tracked_descendants_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len); +int preload_bulk_record_tracked_alias_fallback( + struct preload_bulk_worker *worker, const char *path, + size_t path_len); int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result); const struct preload_bulk_backend *preload_bulk_platform_backend(void); diff --git a/preload-index.c b/preload-index.c index ee7045b6b7a98c..5dadcee0a50471 100644 --- a/preload-index.c +++ b/preload-index.c @@ -45,6 +45,9 @@ struct thread_data { struct index_state *index; struct pathspec pathspec; struct progress_data *progress; +#ifdef HAVE_PRELOAD_INDEX_BULK + const unsigned char *bulk_state; +#endif int offset, nr; int t2_nr_lstat; }; @@ -65,6 +68,9 @@ static void *preload_thread(void *_data) struct index_state *index = p->index; struct cache_entry **cep = index->cache + p->offset; struct cache_def cache = CACHE_DEF_INIT; +#ifdef HAVE_PRELOAD_INDEX_BULK + const unsigned char *bulk_state = p->bulk_state; +#endif nr = p->nr; if (nr + p->offset > index->cache_nr) @@ -74,9 +80,18 @@ static void *preload_thread(void *_data) do { struct cache_entry *ce = *cep++; struct stat st; +#ifdef HAVE_PRELOAD_INDEX_BULK + unsigned char state = bulk_state ? + *bulk_state++ : PRELOAD_BULK_TRACKED_UNSEEN; +#endif if (!preload_entry_needs_stat(ce)) continue; +#ifdef HAVE_PRELOAD_INDEX_BULK + if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) + continue; +#endif if (p->progress && !(nr & 31)) { struct progress_data *pd = p->progress; @@ -143,9 +158,10 @@ static size_t preload_bulk_useful_candidates(struct index_state *index) return useful; } -static size_t preload_bulk_publish_clean( +static size_t preload_bulk_apply_result( struct index_state *index, - const struct preload_bulk_result *result) + struct preload_bulk_result *result, + int *has_deferred) { size_t applied = 0; @@ -153,12 +169,27 @@ static size_t preload_bulk_publish_clean( BUG("bulk preload result does not match the index"); for (size_t i = 0; i < result->nr; i++) { - struct cache_entry *ce; + struct cache_entry *ce = index->cache[i]; unsigned char state = result->tracked_state[i]; + /* + * A complete scan which did not observe a useful entry proves + * that the entry is absent. Avoid repeating the same lookup in + * speculative preload. A status consumer may use this result + * directly; other callers retain the authoritative refresh. + */ + if (result->can_skip_unseen_preload && + state == PRELOAD_BULK_TRACKED_UNSEEN && + preload_bulk_entry_is_useful(ce)) { + state = PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED; + result->tracked_state[i] = state; + } + if ((state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) && + preload_entry_needs_stat(ce)) + *has_deferred = 1; if (state != PRELOAD_BULK_TRACKED_CLEAN) continue; - ce = index->cache[i]; if (!preload_bulk_entry_is_useful(ce)) continue; ce_mark_uptodate(ce); @@ -192,6 +223,23 @@ static void preload_bulk_trace_result( const struct preload_bulk_result *result, size_t applied) { + uint64_t content_check = 0, definitive_deleted = 0, fallback = 0; + + for (size_t i = 0; i < result->nr; i++) { + switch (result->tracked_state[i]) { + case PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED: + definitive_deleted++; + break; + case PRELOAD_BULK_TRACKED_CONTENT_CHECK: + content_check++; + break; + case PRELOAD_BULK_TRACKED_FALLBACK: + fallback++; + break; + default: + break; + } + } trace2_data_string("index", index->repo, "preload/bulk_result", result->outcome); if (result->reason) @@ -207,13 +255,22 @@ static void preload_bulk_trace_result( result->run.bulk_calls); trace2_data_intmax("index", index->repo, "preload/bulk_workers", result->run.threads); + trace2_data_intmax("index", index->repo, + "preload/bulk_definitive_deleted", + definitive_deleted); + trace2_data_intmax("index", index->repo, + "preload/bulk_content_check", content_check); + trace2_data_intmax("index", index->repo, + "preload/bulk_fallback", fallback); } -static void preload_bulk_try(struct index_state *index) +static unsigned char *preload_bulk_try(struct index_state *index) { struct preload_bulk_result result = { 0 }; + unsigned char *tracked_state = NULL; size_t useful; size_t applied = 0; + int has_deferred = 0; int enabled = 0; int control, threads; @@ -230,21 +287,28 @@ static void preload_bulk_try(struct index_state *index) if (!enabled || fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_DISABLED || !preload_bulk_available()) - return; + return NULL; useful = preload_bulk_useful_candidates(index); trace2_data_intmax("index", index->repo, "preload/bulk_useful", useful); trace2_data_intmax("index", index->repo, "preload/bulk_cache_nr", index->cache_nr); if (!useful) - return; + return NULL; threads = preload_bulk_threads(useful); trace2_region_enter("index", "preload/bulk", index->repo); - if (!preload_bulk_collect(index, threads, &result)) - applied = preload_bulk_publish_clean(index, &result); + if (!preload_bulk_collect(index, threads, &result)) { + applied = preload_bulk_apply_result(index, &result, + &has_deferred); + } preload_bulk_trace_result(index, &result, applied); + if (has_deferred) { + tracked_state = result.tracked_state; + result.tracked_state = NULL; + } trace2_region_leave("index", "preload/bulk", index->repo); preload_bulk_result_release(&result); + return tracked_state; } #endif @@ -255,6 +319,9 @@ void preload_index(struct index_state *index, int threads, i, work, offset; struct thread_data data[MAX_PARALLEL]; struct progress_data pd; +#ifdef HAVE_PRELOAD_INDEX_BULK + unsigned char *bulk_state = NULL; +#endif int t2_sum_lstat = 0; int core_preload_index = 1; @@ -265,16 +332,24 @@ void preload_index(struct index_state *index, #ifdef HAVE_PRELOAD_INDEX_BULK if (!pathspec || !pathspec->nr) - preload_bulk_try(index); + bulk_state = preload_bulk_try(index); +#endif + if (!HAVE_THREADS) { +#ifdef HAVE_PRELOAD_INDEX_BULK + free(bulk_state); #endif - if (!HAVE_THREADS) return; + } threads = index->cache_nr / THREAD_COST; if ((index->cache_nr > 1) && (threads < 2) && git_env_bool("GIT_TEST_PRELOAD_INDEX", 0)) threads = 2; - if (threads < 2) + if (threads < 2) { +#ifdef HAVE_PRELOAD_INDEX_BULK + free(bulk_state); +#endif return; + } trace2_region_enter("index", "preload", NULL); @@ -298,6 +373,9 @@ void preload_index(struct index_state *index, int err; p->index = index; +#ifdef HAVE_PRELOAD_INDEX_BULK + p->bulk_state = bulk_state ? bulk_state + offset : NULL; +#endif if (pathspec) copy_pathspec(&p->pathspec, pathspec); p->offset = offset; @@ -317,6 +395,9 @@ void preload_index(struct index_state *index, t2_sum_lstat += p->t2_nr_lstat; } stop_progress(&pd.progress); +#ifdef HAVE_PRELOAD_INDEX_BULK + free(bulk_state); +#endif if (pathspec) { /* earlier we made deep copies for each thread to work with */ diff --git a/preload-index.h b/preload-index.h index 4b21e22b6afb19..64906fe5ac74a2 100644 --- a/preload-index.h +++ b/preload-index.h @@ -8,6 +8,7 @@ struct repository; enum preload_bulk_tracked_state { PRELOAD_BULK_TRACKED_UNSEEN = 0, PRELOAD_BULK_TRACKED_CLEAN, + PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED, PRELOAD_BULK_TRACKED_CONTENT_CHECK, PRELOAD_BULK_TRACKED_FALLBACK, }; diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index a54b049e75c5b2..193f616447c8a4 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -190,6 +190,59 @@ test_expect_success 'clean entries are published without lstat' ' check_lstat_data clean.trace 0 ' +test_expect_success 'known mismatches are not restated during preload' ' + setup_repo dirty && + test_write_lines changed-content >dirty/root && + compare_status dirty dirty.trace && + test_file_not_empty actual && + check_data dirty.trace preload/bulk_applied 7 && + check_data dirty.trace preload/bulk_content_check 1 && + check_lstat_data dirty.trace 0 && + check_data dirty.trace refresh/sum_lstat 1 +' + +test_expect_success 'missing entries bypass speculative lstat' ' + setup_repo missing && + rm missing/root && + rm -rf missing/nested && + compare_status missing missing.trace && + test_line_count = 5 actual && + check_data missing.trace preload/bulk_applied 3 && + check_data missing.trace preload/bulk_definitive_deleted 5 && + check_lstat_data missing.trace 0 && + check_data missing.trace refresh/sum_lstat 5 +' + +test_expect_success PIPE \ + 'tracked directories and unsupported vnodes fall back' ' + setup_repo tracked-types && + rm tracked-types/root tracked-types/root-peer && + mkdir tracked-types/root && + mkfifo tracked-types/root-peer && + compare_status tracked-types tracked-types.trace && + test_line_count = 2 actual && + check_data tracked-types.trace preload/bulk_applied 6 && + check_data tracked-types.trace preload/bulk_fallback 2 && + check_lstat_data tracked-types.trace 2 && + check_data tracked-types.trace refresh/sum_lstat 2 +' + +test_expect_success CASE_INSENSITIVE_FS \ + 'case aliases retain parallel preload' ' + setup_repo case-alias && + mv case-alias/root case-alias/ROOT && + mv case-alias/nested case-alias/NESTED && + git -C case-alias update-index --refresh && + compare_status case-alias case-alias.trace && + test_must_be_empty actual && + check_data case-alias.trace preload/bulk_applied 3 && + check_lstat_data case-alias.trace 5 && + { + test_have_prereq !PTHREADS || + check_data case-alias.trace refresh/sum_lstat 0 + } +' + test_expect_success ULIMIT_FILE_DESCRIPTORS \ 'bulk preload reopens directories under a low descriptor limit' ' git init low-fd && @@ -300,7 +353,7 @@ test_expect_success UTF8_NFD_TO_NFC \ test_expect_success 'multiply-linked entries are left to lstat' ' setup_repo hardlink && # Mutate through a name outside the watched worktree, then restore - # mtime. The bulk scan must reject the multiply-linked entry. + # mtime. The bulk scan must leave the multiply-linked entry to lstat. ln hardlink/root hardlink-alias && test-tool chmtime -120 hardlink/root && git -C hardlink update-index --refresh && @@ -311,7 +364,9 @@ test_expect_success 'multiply-linked entries are left to lstat' ' compare_status hardlink hardlink.trace && test_file_not_empty actual && check_data hardlink.trace preload/bulk_applied 7 && - check_lstat_data hardlink.trace 1 + check_data hardlink.trace preload/bulk_fallback 1 && + check_lstat_data hardlink.trace 1 && + check_data hardlink.trace refresh/sum_lstat 1 ' test_expect_success PIPE 'queued child replacement discards observations' ' From bd11d987883c38f9d28391ee6dbd161a3f87bc93 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:32:42 -0500 Subject: [PATCH 212/403] preload-index: translate complete Linux statx observations A Linux directory scan cannot substitute its results for lstat() when file identity, timestamps, or mount membership are missing. Depending on libc's statx declarations would also tie the implementation to the age of the installed Linux headers. Define the required statx syscall ABI locally and request complete basic statistics and a mount identifier. Reject invalid nanosecond fields, foreign mounts, and device, inode, link-count, owner, size, or timestamp values that cannot be represented in struct stat. Register the metadata module in the Make, CMake, and Meson Linux builds. The native Linux boundary build compiles it with DEVELOPER=1, but this patch does not select a backend or change the fallback. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-stat.c | 140 +++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 73 +++++++++++++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 6 +- meson.build | 5 +- 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 compat/preload-index/bulk-linux-stat.c create mode 100644 compat/preload-index/bulk-linux.h diff --git a/compat/preload-index/bulk-linux-stat.c b/compat/preload-index/bulk-linux-stat.c new file mode 100644 index 00000000000000..a8594d7230f9d0 --- /dev/null +++ b/compat/preload-index/bulk-linux-stat.c @@ -0,0 +1,140 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +int preload_bulk_linux_statx_raw(int dirfd, const char *path, int flags, + struct preload_linux_statx *stx) +{ + memset(stx, 0, sizeof(*stx)); + return syscall(SYS_statx, dirfd, path, flags, + PRELOAD_STATX_BASIC_STATS | PRELOAD_STATX_MNT_ID, stx); +} + +int preload_bulk_linux_statx_complete( + const struct preload_linux_statx *stx) +{ + return (stx->mask & + (PRELOAD_STATX_BASIC_STATS | PRELOAD_STATX_MNT_ID)) == + (PRELOAD_STATX_BASIC_STATS | PRELOAD_STATX_MNT_ID) && + stx->mtime.tv_nsec < 1000000000 && + stx->ctime.tv_nsec < 1000000000; +} + +int preload_bulk_linux_statx_same(const struct preload_linux_statx *a, + const struct preload_linux_statx *b) +{ + return preload_bulk_linux_statx_complete(a) && + preload_bulk_linux_statx_complete(b) && + a->mnt_id == b->mnt_id && + a->dev_major == b->dev_major && + a->dev_minor == b->dev_minor && + a->ino == b->ino && a->mode == b->mode && + a->nlink == b->nlink && a->uid == b->uid && + a->gid == b->gid && a->size == b->size && + a->mtime.tv_sec == b->mtime.tv_sec && + a->mtime.tv_nsec == b->mtime.tv_nsec && + a->ctime.tv_sec == b->ctime.tv_sec && + a->ctime.tv_nsec == b->ctime.tv_nsec; +} + +static int statx_to_stat(const struct preload_linux_statx *stx, + struct stat *st) +{ + dev_t dev; + + if (!preload_bulk_linux_statx_complete(stx)) + return -1; + memset(st, 0, sizeof(*st)); + dev = makedev(stx->dev_major, stx->dev_minor); + if (major(dev) != stx->dev_major || minor(dev) != stx->dev_minor) + return -1; + st->st_dev = dev; + st->st_ino = stx->ino; + if ((uint64_t)st->st_ino != stx->ino) + return -1; + st->st_mode = stx->mode; + st->st_nlink = stx->nlink; + if ((uint64_t)st->st_nlink != stx->nlink) + return -1; + st->st_uid = stx->uid; + st->st_gid = stx->gid; + if ((uint64_t)st->st_uid != stx->uid || + (uint64_t)st->st_gid != stx->gid) + return -1; + st->st_size = stx->size; + if (st->st_size < 0 || (uint64_t)st->st_size != stx->size) + return -1; + st->st_mtim.tv_sec = stx->mtime.tv_sec; + st->st_mtim.tv_nsec = stx->mtime.tv_nsec; + st->st_ctim.tv_sec = stx->ctime.tv_sec; + st->st_ctim.tv_nsec = stx->ctime.tv_nsec; + if ((int64_t)st->st_mtim.tv_sec != stx->mtime.tv_sec || + (int64_t)st->st_ctim.tv_sec != stx->ctime.tv_sec) + return -1; + return 0; +} + +int preload_bulk_linux_entry_stat(struct preload_bulk_worker *worker, + int dirfd, const char *name, + struct preload_linux_statx *stx, + struct stat *st) +{ + struct preload_bulk_linux_data *data = + worker->scan->platform_data; + + if (preload_bulk_linux_statx_raw( + dirfd, name, + PRELOAD_AT_SYMLINK_NOFOLLOW | PRELOAD_AT_NO_AUTOMOUNT, + stx)) + return -1; + if (!preload_bulk_linux_statx_complete(stx)) { + errno = EOPNOTSUPP; + return -1; + } + if (stx->mnt_id != data->root_mnt_id) { + errno = EXDEV; + return -1; + } + if (statx_to_stat(stx, st)) { + errno = EOVERFLOW; + return -1; + } + return 0; +} + +int preload_bulk_linux_fd_stat(struct preload_bulk_worker *worker, int fd, + struct preload_linux_statx *stx, + struct stat *st) +{ + struct preload_bulk_linux_data *data = + worker->scan->platform_data; + + if (preload_bulk_linux_statx_raw(fd, "", PRELOAD_AT_EMPTY_PATH, + stx)) + return -1; + if (!preload_bulk_linux_statx_complete(stx)) { + errno = EOPNOTSUPP; + return -1; + } + if (stx->mnt_id != data->root_mnt_id) { + errno = EXDEV; + return -1; + } + if (statx_to_stat(stx, st)) { + errno = EOVERFLOW; + return -1; + } + return 0; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h new file mode 100644 index 00000000000000..e4530ac0fda8e6 --- /dev/null +++ b/compat/preload-index/bulk-linux.h @@ -0,0 +1,73 @@ +#ifndef PRELOAD_INDEX_BULK_LINUX_H +#define PRELOAD_INDEX_BULK_LINUX_H + +#ifdef __linux__ + +#include + +#define PRELOAD_AT_NO_AUTOMOUNT 0x800 +#define PRELOAD_AT_EMPTY_PATH 0x1000 +#define PRELOAD_AT_SYMLINK_NOFOLLOW 0x100 +#define PRELOAD_STATX_BASIC_STATS 0x000007ffU +#define PRELOAD_STATX_MNT_ID 0x00001000U + +struct preload_linux_statx_timestamp { + int64_t tv_sec; + uint32_t tv_nsec; + int32_t reserved; +}; + +struct preload_linux_statx { + uint32_t mask; + uint32_t blksize; + uint64_t attributes; + uint32_t nlink; + uint32_t uid; + uint32_t gid; + uint16_t mode; + uint16_t spare0; + uint64_t ino; + uint64_t size; + uint64_t blocks; + uint64_t attributes_mask; + struct preload_linux_statx_timestamp atime; + struct preload_linux_statx_timestamp btime; + struct preload_linux_statx_timestamp ctime; + struct preload_linux_statx_timestamp mtime; + uint32_t rdev_major; + uint32_t rdev_minor; + uint32_t dev_major; + uint32_t dev_minor; + uint64_t mnt_id; + uint32_t dio_mem_align; + uint32_t dio_offset_align; + uint64_t spare3[12]; +}; + +struct preload_bulk_linux_data { + uint64_t root_mnt_id; +}; + +struct preload_bulk_worker; + +#if defined(SYS_getdents64) && defined(SYS_statx) + +int preload_bulk_linux_statx_raw(int dirfd, const char *path, int flags, + struct preload_linux_statx *stx); +int preload_bulk_linux_statx_complete( + const struct preload_linux_statx *stx); +int preload_bulk_linux_statx_same(const struct preload_linux_statx *a, + const struct preload_linux_statx *b); +int preload_bulk_linux_entry_stat(struct preload_bulk_worker *worker, + int dirfd, const char *name, + struct preload_linux_statx *stx, + struct stat *st); +int preload_bulk_linux_fd_stat(struct preload_bulk_worker *worker, int fd, + struct preload_linux_statx *stx, + struct stat *st); + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ + +#endif /* PRELOAD_INDEX_BULK_LINUX_H */ diff --git a/config.mak.uname b/config.mak.uname index 31f4524d1158ab..f59b4bfa4aea4a 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,6 +63,7 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o + COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. ifneq ($(findstring .el7.,$(uname_R)),) diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 87152a27fe7cb1..aec2421a64da6b 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -273,7 +273,11 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) - list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c) + list(APPEND compat_SOURCES + unix-socket.c + unix-stream-server.c + compat/linux/procinfo.c + compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE USE_ST_TIMESPEC) diff --git a/meson.build b/meson.build index d4dc3b71f91841..4f34fdf9ab7985 100644 --- a/meson.build +++ b/meson.build @@ -1360,7 +1360,10 @@ elif host_machine.system() == 'windows' endif if host_machine.system() == 'linux' - compat_sources += 'compat/linux/procinfo.c' + compat_sources += [ + 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux-stat.c', + ] elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' From 201805403007d66f33bf0b2dee1fae9c98e290e0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:55:11 -0500 Subject: [PATCH 213/403] preload-index: classify definitive tracked-file size changes S11/P01 retains scan results for later processing, but treats every changed stat observation as a pending content check. A nonzero cached size that differs from the observed size already proves that a tracked file changed. Racy timestamps, zero cached sizes, type changes, and the Windows symlink-size sentinel cannot establish that conclusion. Classify an entry as definitively modified only when its mode and type remain comparable, its cached size is nonzero, and match_stat_data() reports an actual data-size difference. Pass that terminal state through the existing preload result, skip its speculative restat, and emit a separate definitive-modification Trace2 count. Preserve ordinary content verification for every ambiguous observation. Update the APFS dirty-file test to require the new terminal classification, no speculative lstat, and the still-required authoritative refresh. This verifies the new state at its first consumer without claiming that status already consumes it directly. Signed-off-by: Taylor Blau --- preload-index-bulk-index.c | 30 ++++++++++++++++++++++++++++-- preload-index.c | 11 ++++++++++- preload-index.h | 1 + t/t7529-preload-index-apfs.sh | 4 ++-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index 0d129b619cf2fc..bd26b72ccb2986 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -86,6 +86,28 @@ static int tracked_entry_is_eligible(const struct cache_entry *ce) (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)); } +/* + * Match ie_modified(): a nonzero cached size mismatch is a conclusive + * content change. Zero sizes and the historical Windows symlink sentinel + * still require an ordinary content check. Recompute the stat-data match + * because CE_MATCH_RACY_IS_DIRTY may make ie_match_stat() report a data + * change without a size mismatch. + */ +static int size_change_is_definitive(const struct cache_entry *ce, + const struct stat *st, + unsigned int changed) +{ + if (changed & (MODE_CHANGED | TYPE_CHANGED)) + return 0; +#ifdef GIT_WINDOWS_NATIVE + if (S_ISLNK(st->st_mode) && ce->ce_stat_data.sd_size == MAX_PATH) + return 0; +#endif + return ce->ce_stat_data.sd_size && + (match_stat_data(&ce->ce_stat_data, (struct stat *)st) & + DATA_CHANGED); +} + void preload_bulk_record_tracked( struct preload_bulk_worker *worker, int pos, const struct stat *st) { @@ -99,8 +121,12 @@ void preload_bulk_record_tracked( changed = ie_match_stat( scan->istate, ce, (struct stat *)st, CE_MATCH_RACY_IS_DIRTY | CE_MATCH_IGNORE_FSMONITOR); - state = changed ? PRELOAD_BULK_TRACKED_CONTENT_CHECK : - PRELOAD_BULK_TRACKED_CLEAN; + if (!changed) + state = PRELOAD_BULK_TRACKED_CLEAN; + else if (size_change_is_definitive(ce, st, changed)) + state = PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED; + else + state = PRELOAD_BULK_TRACKED_CONTENT_CHECK; record_tracked_state(worker, pos, state); } diff --git a/preload-index.c b/preload-index.c index 5dadcee0a50471..9e082af764a82d 100644 --- a/preload-index.c +++ b/preload-index.c @@ -89,6 +89,7 @@ static void *preload_thread(void *_data) continue; #ifdef HAVE_PRELOAD_INDEX_BULK if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) continue; #endif @@ -185,6 +186,7 @@ static size_t preload_bulk_apply_result( result->tracked_state[i] = state; } if ((state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) && preload_entry_needs_stat(ce)) *has_deferred = 1; @@ -223,10 +225,14 @@ static void preload_bulk_trace_result( const struct preload_bulk_result *result, size_t applied) { - uint64_t content_check = 0, definitive_deleted = 0, fallback = 0; + uint64_t content_check = 0, definitive_modified = 0; + uint64_t definitive_deleted = 0, fallback = 0; for (size_t i = 0; i < result->nr; i++) { switch (result->tracked_state[i]) { + case PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED: + definitive_modified++; + break; case PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED: definitive_deleted++; break; @@ -255,6 +261,9 @@ static void preload_bulk_trace_result( result->run.bulk_calls); trace2_data_intmax("index", index->repo, "preload/bulk_workers", result->run.threads); + trace2_data_intmax("index", index->repo, + "preload/bulk_definitive_modified", + definitive_modified); trace2_data_intmax("index", index->repo, "preload/bulk_definitive_deleted", definitive_deleted); diff --git a/preload-index.h b/preload-index.h index 64906fe5ac74a2..01d90e06bb6b3f 100644 --- a/preload-index.h +++ b/preload-index.h @@ -8,6 +8,7 @@ struct repository; enum preload_bulk_tracked_state { PRELOAD_BULK_TRACKED_UNSEEN = 0, PRELOAD_BULK_TRACKED_CLEAN, + PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED, PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED, PRELOAD_BULK_TRACKED_CONTENT_CHECK, PRELOAD_BULK_TRACKED_FALLBACK, diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index 193f616447c8a4..5ad04921ebfda8 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -190,13 +190,13 @@ test_expect_success 'clean entries are published without lstat' ' check_lstat_data clean.trace 0 ' -test_expect_success 'known mismatches are not restated during preload' ' +test_expect_success 'definitive size changes are not restated' ' setup_repo dirty && test_write_lines changed-content >dirty/root && compare_status dirty dirty.trace && test_file_not_empty actual && check_data dirty.trace preload/bulk_applied 7 && - check_data dirty.trace preload/bulk_content_check 1 && + check_data dirty.trace preload/bulk_definitive_modified 1 && check_lstat_data dirty.trace 0 && check_data dirty.trace refresh/sum_lstat 1 ' From 2415dab732c9515652ae56b313d08ef2d3163d26 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:33:21 -0500 Subject: [PATCH 214/403] preload-index: anchor Linux directory opens to the worktree A directory name observed during enumeration may resolve outside the original worktree after a rename, symlink replacement, magic-link traversal, or mount change. Path-based reopening would then inspect an unverified namespace. Introduce descriptor-relative Linux directory-open helpers. Prefer openat2() with beneath-root resolution and reject symlinks, magic links, and mount crossings when that syscall is available. Otherwise reject empty, absolute, dot-dot, and malformed paths. Open root-relative paths one component at a time and verify each mount. Keep direct child opens descriptor-relative; directory scanning verifies their mounts before enumeration. Register the opening module with Make, CMake, and Meson. The native Linux boundary build compiles it with DEVELOPER=1, but backend selection remains unchanged. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-open.c | 149 +++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 20 ++++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + 5 files changed, 172 insertions(+) create mode 100644 compat/preload-index/bulk-linux-open.c diff --git a/compat/preload-index/bulk-linux-open.c b/compat/preload-index/bulk-linux-open.c new file mode 100644 index 00000000000000..02cd5b55b87f43 --- /dev/null +++ b/compat/preload-index/bulk-linux-open.c @@ -0,0 +1,149 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static int valid_component(const char *component, size_t len) +{ + return len && + !(len == 1 && component[0] == '.') && + !(len == 2 && component[0] == '.' && component[1] == '.'); +} + +static int verify_mount(struct preload_bulk_scan *scan, int fd) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + struct preload_linux_statx stx; + + if (preload_bulk_linux_statx_raw(fd, "", PRELOAD_AT_EMPTY_PATH, + &stx)) + return -1; + if (!preload_bulk_linux_statx_complete(&stx)) { + errno = EOPNOTSUPP; + return -1; + } + if (stx.mnt_id != data->root_mnt_id) { + errno = EXDEV; + return -1; + } + return 0; +} + +#ifdef SYS_openat2 +int preload_bulk_linux_openat2_raw(int dirfd, const char *path) +{ + struct preload_linux_open_how how = { + .flags = O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, + .resolve = PRELOAD_RESOLVE_BENEATH | + PRELOAD_RESOLVE_NO_SYMLINKS | + PRELOAD_RESOLVE_NO_MAGICLINKS | + PRELOAD_RESOLVE_NO_XDEV, + }; + + return syscall(SYS_openat2, dirfd, path, &how, sizeof(how)); +} +#endif + +static int open_one_fallback(struct preload_bulk_scan *scan, int parent_fd, + const char *name, int check_mount) +{ + int fd = openat(parent_fd, name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + + if (fd < 0) + return -1; + if (check_mount && verify_mount(scan, fd)) { + int saved_errno = errno; + + close(fd); + errno = saved_errno; + return -1; + } + return fd; +} + +int preload_bulk_linux_open_dir_at( + struct preload_bulk_worker *worker, int parent_fd, + const char *name) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_linux_data *data = scan->platform_data; + + if (!valid_component(name, strlen(name)) || strchr(name, '/')) { + errno = EINVAL; + return -1; + } +#ifdef SYS_openat2 + if (data->use_openat2) + return preload_bulk_linux_openat2_raw(parent_fd, name); +#else + (void)data; +#endif + /* scan_directory() verifies the opened descriptor's mount ID. */ + return open_one_fallback(scan, parent_fd, name, 0); +} + +int preload_bulk_linux_open_relative(struct preload_bulk_scan *scan, + const char *path) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + const char *component = path; + int fd; + + if (!*path || *path == '/' || path[strlen(path) - 1] == '/') { + errno = EINVAL; + return -1; + } +#ifdef SYS_openat2 + if (data->use_openat2) + return preload_bulk_linux_openat2_raw(scan->root_fd, path); +#else + (void)data; +#endif + fd = fcntl(scan->root_fd, F_DUPFD_CLOEXEC, 0); + if (fd < 0) + return -1; + if (verify_mount(scan, fd)) { + int saved_errno = errno; + + close(fd); + errno = saved_errno; + return -1; + } + if (!strcmp(path, ".")) + return fd; + while (*component) { + const char *slash = strchr(component, '/'); + size_t len = slash ? (size_t)(slash - component) : + strlen(component); + char *name; + int next; + + if (!valid_component(component, len)) { + close(fd); + errno = EINVAL; + return -1; + } + name = xmemdupz(component, len); + next = open_one_fallback(scan, fd, name, 1); + free(name); + close(fd); + if (next < 0) + return -1; + fd = next; + if (!slash) + break; + component = slash + 1; + } + return fd; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index e4530ac0fda8e6..e26ee469350b23 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -10,6 +10,10 @@ #define PRELOAD_AT_SYMLINK_NOFOLLOW 0x100 #define PRELOAD_STATX_BASIC_STATS 0x000007ffU #define PRELOAD_STATX_MNT_ID 0x00001000U +#define PRELOAD_RESOLVE_NO_XDEV 0x01 +#define PRELOAD_RESOLVE_NO_MAGICLINKS 0x02 +#define PRELOAD_RESOLVE_NO_SYMLINKS 0x04 +#define PRELOAD_RESOLVE_BENEATH 0x08 struct preload_linux_statx_timestamp { int64_t tv_sec; @@ -44,10 +48,18 @@ struct preload_linux_statx { uint64_t spare3[12]; }; +struct preload_linux_open_how { + uint64_t flags; + uint64_t mode; + uint64_t resolve; +}; + struct preload_bulk_linux_data { uint64_t root_mnt_id; + int use_openat2; }; +struct preload_bulk_scan; struct preload_bulk_worker; #if defined(SYS_getdents64) && defined(SYS_statx) @@ -66,6 +78,14 @@ int preload_bulk_linux_fd_stat(struct preload_bulk_worker *worker, int fd, struct preload_linux_statx *stx, struct stat *st); +#ifdef SYS_openat2 +int preload_bulk_linux_openat2_raw(int dirfd, const char *path); +#endif +int preload_bulk_linux_open_dir_at(struct preload_bulk_worker *worker, + int parent_fd, const char *name); +int preload_bulk_linux_open_relative(struct preload_bulk_scan *scan, + const char *path); + #endif /* SYS_getdents64 && SYS_statx */ #endif /* __linux__ */ diff --git a/config.mak.uname b/config.mak.uname index f59b4bfa4aea4a..293d084e01e318 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,6 +63,7 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o + COMPAT_OBJS += compat/preload-index/bulk-linux-open.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index aec2421a64da6b..815b7512af0d64 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -277,6 +277,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") unix-socket.c unix-stream-server.c compat/linux/procinfo.c + compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE diff --git a/meson.build b/meson.build index 4f34fdf9ab7985..ba17de2a8d8e50 100644 --- a/meson.build +++ b/meson.build @@ -1362,6 +1362,7 @@ endif if host_machine.system() == 'linux' compat_sources += [ 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-stat.c', ] elif host_machine.system() == 'windows' From 1675be3fe91877bda70ecc0a7f217efa044b6bb3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:55:56 -0500 Subject: [PATCH 215/403] status: consume definitive bulk changes exactly once A complete APFS preload can already prove that tracked entries are deleted or have definitive size changes. Ordinary status nevertheless refreshes those entries and later asks worktree diff to rediscover them. Skipping refresh without preserving ambiguous content checks would either duplicate work or misreport metadata-only changes. Request terminal-result deferral explicitly from porcelain status and retain a complete per-entry result on its index. Let refresh defer proven modifications and deletions while marking ambiguous entries for the ordinary content check. Insert terminal changes into the normal status change list before running worktree diff, temporarily mark only those entries up to date, and restore their flags afterward. Clear retained results before another preload and release them with the index. Other refresh callers keep their existing behavior. Extend the APFS tests to assert direct modified and deleted results and no redundant refresh stats. Add a metadata-only mismatch that must still reach worktree diff and produce clean porcelain output. Retained terminal state trades additional temporary memory for removing the second classification of proven changes. Signed-off-by: Taylor Blau --- builtin/commit.c | 3 +- preload-index.c | 25 ++++++++-- preload-index.h | 1 + read-cache-ll.h | 3 ++ read-cache.c | 17 ++++++- t/t7529-preload-index-apfs.sh | 18 ++++++- wt-status.c | 93 +++++++++++++++++++++++++++++------ 7 files changed, 139 insertions(+), 21 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index e2f4d08b347707..fa64ba01f2a5e7 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1629,7 +1629,8 @@ struct repository *repo UNUSED) repo_read_index(the_repository); wt_status_start_untracked_cache_preload(&s); wt_status_refresh_index( - &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag, + &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag | + REFRESH_DEFER_BULK_DIRTY, s.show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s.show_ignored_mode); diff --git a/preload-index.c b/preload-index.c index 9e082af764a82d..72d37ae93e67d0 100644 --- a/preload-index.c +++ b/preload-index.c @@ -319,8 +319,26 @@ static unsigned char *preload_bulk_try(struct index_state *index) preload_bulk_result_release(&result); return tracked_state; } + +static void preload_bulk_finish_state(struct index_state *index, + unsigned char **state, + unsigned int refresh_flags) +{ + if ((refresh_flags & REFRESH_DEFER_BULK_DIRTY) && *state) { + index->preload_bulk_tracked_state = *state; + index->preload_bulk_tracked_nr = index->cache_nr; + *state = NULL; + } + FREE_AND_NULL(*state); +} #endif +void preload_index_bulk_result_clear(struct index_state *index) +{ + FREE_AND_NULL(index->preload_bulk_tracked_state); + index->preload_bulk_tracked_nr = 0; +} + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags) @@ -334,6 +352,7 @@ void preload_index(struct index_state *index, int t2_sum_lstat = 0; int core_preload_index = 1; + preload_index_bulk_result_clear(index); repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); if (!core_preload_index) @@ -345,7 +364,7 @@ void preload_index(struct index_state *index, #endif if (!HAVE_THREADS) { #ifdef HAVE_PRELOAD_INDEX_BULK - free(bulk_state); + preload_bulk_finish_state(index, &bulk_state, refresh_flags); #endif return; } @@ -355,7 +374,7 @@ void preload_index(struct index_state *index, threads = 2; if (threads < 2) { #ifdef HAVE_PRELOAD_INDEX_BULK - free(bulk_state); + preload_bulk_finish_state(index, &bulk_state, refresh_flags); #endif return; } @@ -405,7 +424,7 @@ void preload_index(struct index_state *index, } stop_progress(&pd.progress); #ifdef HAVE_PRELOAD_INDEX_BULK - free(bulk_state); + preload_bulk_finish_state(index, &bulk_state, refresh_flags); #endif if (pathspec) { diff --git a/preload-index.h b/preload-index.h index 01d90e06bb6b3f..bb6deb6130cc2f 100644 --- a/preload-index.h +++ b/preload-index.h @@ -20,5 +20,6 @@ void preload_index(struct index_state *index, int repo_read_index_preload(struct repository *, const struct pathspec *pathspec, unsigned refresh_flags); +void preload_index_bulk_result_clear(struct index_state *index); #endif /* PRELOAD_INDEX_H */ diff --git a/read-cache-ll.h b/read-cache-ll.h index d44a946ed04d36..99b95d8e8d5325 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -194,6 +194,8 @@ struct index_state { struct hashmap dir_hash; struct object_id oid; struct untracked_cache *untracked; + unsigned char *preload_bulk_tracked_state; + size_t preload_bulk_tracked_nr; char *fsmonitor_last_update; char *fsmonitor_last_update_pending; char *fsmonitor_untracked_token; @@ -474,6 +476,7 @@ int fake_lstat(const struct cache_entry *ce, struct stat *st); #define REFRESH_IN_PORCELAIN (1 << 5) /* user friendly output, not "needs update" */ #define REFRESH_PROGRESS (1 << 6) /* show progress bar if stderr is tty */ #define REFRESH_IGNORE_SKIP_WORKTREE (1 << 7) /* ignore skip_worktree entries */ +#define REFRESH_DEFER_BULK_DIRTY (1 << 8) /* leave bulk results to diff */ int refresh_index(struct index_state *, unsigned int flags, const struct pathspec *pathspec, char *seen, const char *header_msg); /* * Refresh the index and write it to disk. diff --git a/read-cache.c b/read-cache.c index cf3600f2a337d6..f606bf81ddaca2 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1550,7 +1550,7 @@ int refresh_index(struct index_state *istate, unsigned int flags, * cache entries quickly then in the single threaded loop below, * we only have to do the special cases that are left. */ - preload_index(istate, pathspec, 0); + preload_index(istate, pathspec, flags & REFRESH_DEFER_BULK_DIRTY); trace2_region_enter("index", "refresh", NULL); for (i = 0; i < istate->cache_nr; i++) { @@ -1594,6 +1594,20 @@ int refresh_index(struct index_state *istate, unsigned int flags, if (filtered) continue; + if ((flags & REFRESH_DEFER_BULK_DIRTY) && + istate->preload_bulk_tracked_nr == istate->cache_nr) { + unsigned char state = + istate->preload_bulk_tracked_state[i]; + + if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK) { + ce->ce_flags |= CE_CONTENT_CHECK_REQUIRED; + continue; + } + if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) + continue; + } + new_entry = refresh_cache_ent(istate, ce, options, &cache_errno, &changed, &t2_did_lstat, &t2_did_scan); @@ -2473,6 +2487,7 @@ void release_index(struct index_state *istate) free(istate->fsmonitor_last_update); free(istate->fsmonitor_last_update_pending); free(istate->fsmonitor_untracked_token); + free(istate->preload_bulk_tracked_state); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index 5ad04921ebfda8..c7c399045f2e1e 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -197,8 +197,10 @@ test_expect_success 'definitive size changes are not restated' ' test_file_not_empty actual && check_data dirty.trace preload/bulk_applied 7 && check_data dirty.trace preload/bulk_definitive_modified 1 && + test_trace2_data status preload/direct_modified 1 \ + <"$TRASH_DIRECTORY/dirty.trace" && check_lstat_data dirty.trace 0 && - check_data dirty.trace refresh/sum_lstat 1 + check_data dirty.trace refresh/sum_lstat 0 ' test_expect_success 'missing entries bypass speculative lstat' ' @@ -209,8 +211,20 @@ test_expect_success 'missing entries bypass speculative lstat' ' test_line_count = 5 actual && check_data missing.trace preload/bulk_applied 3 && check_data missing.trace preload/bulk_definitive_deleted 5 && + test_trace2_data status preload/direct_deleted 5 \ + <"$TRASH_DIRECTORY/missing.trace" && check_lstat_data missing.trace 0 && - check_data missing.trace refresh/sum_lstat 5 + check_data missing.trace refresh/sum_lstat 0 +' + +test_expect_success 'metadata-only mismatches are checked by diff' ' + setup_repo metadata && + test-tool chmtime +60 metadata/root && + compare_status metadata metadata.trace && + test_must_be_empty actual && + check_data metadata.trace preload/bulk_content_check 1 && + check_lstat_data metadata.trace 0 && + check_data metadata.trace refresh/sum_lstat 0 ' test_expect_success PIPE \ diff --git a/wt-status.c b/wt-status.c index 57e2321275dc07..7ea206bdc9609b 100644 --- a/wt-status.c +++ b/wt-status.c @@ -14,6 +14,7 @@ #include "hex.h" #include "object-name.h" #include "path.h" +#include "preload-index.h" #include "revision.h" #include "diffcore.h" #include "quote.h" @@ -458,6 +459,19 @@ static char short_submodule_status(struct wt_status_change_data *d) return d->worktree_status; } +static struct wt_status_change_data *wt_status_get_change( + struct wt_status *s, const char *path) +{ + struct string_list_item *it = string_list_insert(&s->change, path); + struct wt_status_change_data *d = it->util; + + if (!d) { + CALLOC_ARRAY(d, 1); + it->util = d; + } + return d; +} + static void wt_status_collect_changed_cb(struct diff_queue_struct *q, struct diff_options *options UNUSED, void *data) @@ -470,16 +484,10 @@ static void wt_status_collect_changed_cb(struct diff_queue_struct *q, s->workdir_dirty = 1; for (i = 0; i < q->nr; i++) { struct diff_filepair *p; - struct string_list_item *it; struct wt_status_change_data *d; p = q->queue[i]; - it = string_list_insert(&s->change, p->two->path); - d = it->util; - if (!d) { - CALLOC_ARRAY(d, 1); - it->util = d; - } + d = wt_status_get_change(s, p->two->path); if (!d->worktree_status) d->worktree_status = p->status; if (S_ISGITLINK(p->two->mode)) { @@ -525,6 +533,64 @@ static void wt_status_collect_changed_cb(struct diff_queue_struct *q, } } +static struct cache_entry **wt_status_collect_preload_changes( + struct wt_status *s, size_t *direct_nr) +{ + struct index_state *istate = s->repo->index; + struct cache_entry **direct = NULL; + size_t direct_alloc = 0; + uint64_t modified = 0, deleted = 0; + + *direct_nr = 0; + if (istate->preload_bulk_tracked_nr != istate->cache_nr) + goto clear; + for (size_t i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + struct wt_status_change_data *d; + unsigned char state = + istate->preload_bulk_tracked_state[i]; + int status; + + if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED) { + status = DIFF_STATUS_MODIFIED; + modified++; + } else if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) { + status = DIFF_STATUS_DELETED; + deleted++; + } else { + continue; + } + + d = wt_status_get_change(s, ce->name); + if (!d->worktree_status) + d->worktree_status = status; + d->mode_index = ce->ce_mode; + d->mode_worktree = status == DIFF_STATUS_MODIFIED ? + ce->ce_mode : 0; + oidcpy(&d->oid_index, &ce->oid); + ce_mark_uptodate(ce); + ALLOC_GROW(direct, *direct_nr + 1, direct_alloc); + direct[(*direct_nr)++] = ce; + s->workdir_dirty = 1; + } + trace2_data_intmax("status", s->repo, "preload/direct_modified", + modified); + trace2_data_intmax("status", s->repo, "preload/direct_deleted", + deleted); + +clear: + preload_index_bulk_result_clear(istate); + return direct; +} + +static void wt_status_release_preload_changes( + struct cache_entry **direct, size_t direct_nr) +{ + for (size_t i = 0; i < direct_nr; i++) + direct[i]->ce_flags &= ~CE_UPTODATE; + free(direct); +} + static int unmerged_mask(struct index_state *istate, const char *path) { int pos, mask; @@ -554,16 +620,10 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q, for (i = 0; i < q->nr; i++) { struct diff_filepair *p; - struct string_list_item *it; struct wt_status_change_data *d; p = q->queue[i]; - it = string_list_insert(&s->change, p->two->path); - d = it->util; - if (!d) { - CALLOC_ARRAY(d, 1); - it->util = d; - } + d = wt_status_get_change(s, p->two->path); if (!d->index_status) d->index_status = p->status; switch (p->status) { @@ -639,8 +699,11 @@ void wt_status_collect_changes_trees(struct wt_status *s, static void wt_status_collect_changes_worktree(struct wt_status *s) { + struct cache_entry **direct; + size_t direct_nr; struct rev_info rev; + direct = wt_status_collect_preload_changes(s, &direct_nr); repo_init_revisions(s->repo, &rev, NULL); setup_revisions(0, NULL, &rev, NULL); rev.diffopt.output_format |= DIFF_FORMAT_CALLBACK; @@ -661,6 +724,7 @@ static void wt_status_collect_changes_worktree(struct wt_status *s) rev.diffopt.rename_score = s->rename_score >= 0 ? s->rename_score : rev.diffopt.rename_score; copy_pathspec(&rev.prune_data, &s->pathspec); run_diff_files(&rev, 0); + wt_status_release_preload_changes(direct, direct_nr); release_revisions(&rev); } @@ -850,6 +914,7 @@ static void wt_status_finish_untracked_cache_preload(struct wt_status *s) if (!index_invalidated) return; + preload_index_bulk_result_clear(istate); trace2_data_intmax("status", s->repo, "fsmonitor/exclude-index-invalidated", index_invalidated); From 5f12f61c825a3092b6677271c4aedca8a3980cf3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:12 -0500 Subject: [PATCH 216/403] preload-index: enumerate Linux directories without trusting d_type A getdents64 record supplies a type hint, not proof that a path is a regular file or directory. Trusting that hint can hide a tracked replacement, misapply ignore rules, or report the wrong visible untracked shape. Parse record lengths and names within a bounded 1 MiB worker buffer. Require statx metadata and matching mount identity for tracked paths. When collecting untracked paths, obtain authoritative metadata before classifying a wholly untracked file or directory. Use directory hints only to schedule paths with tracked descendants. Preserve per-entry fallback for special and multiply linked tracked files. Stop an untracked subtree once its normal-status witness is visible, and invalidate uncertain untracked results. Register the module in all three Linux builds; the native DEVELOPER=1 boundary build compiles it without selecting the backend. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-entry.c | 263 ++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 7 + config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + 5 files changed, 273 insertions(+) create mode 100644 compat/preload-index/bulk-linux-entry.c diff --git a/compat/preload-index/bulk-linux-entry.c b/compat/preload-index/bulk-linux-entry.c new file mode 100644 index 00000000000000..d911c2be137964 --- /dev/null +++ b/compat/preload-index/bulk-linux-entry.c @@ -0,0 +1,263 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include +#include + +#include "compat/preload-index/bulk-linux.h" +#include "dir.h" +#include "preload-index-bulk.h" + +#define PRELOAD_INDEX_BULK_LINUX_BUFFER_SIZE (1024 * 1024) + +struct preload_linux_dirent64 { + uint64_t ino; + int64_t off; + uint16_t reclen; + uint8_t type; + char name[FLEX_ARRAY]; +}; + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static void record_foreign_entry(struct preload_bulk_worker *worker, + const char *path, size_t path_len, + int pos, mode_t mode) +{ + if (pos >= 0) + preload_bulk_record_tracked_fallback(worker, pos); + if (S_ISDIR(mode)) + preload_bulk_record_tracked_descendants_fallback( + worker, path, path_len); + if (worker->scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); +} + +static void handle_directory( + struct preload_bulk_worker *worker, + const struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity, + const char *name, int pos) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_untracked_root *untracked_root = + task->untracked_root; + int has_tracked_descendants; + + if (pos >= 0) { + if (scan->collect_untracked && + preload_bulk_index_entry_is_gitlink(scan, pos)) + return; + preload_bulk_record_tracked_fallback(worker, pos); + return; + } + has_tracked_descendants = + preload_bulk_index_pos_has_tracked_descendants( + scan, worker->path.buf, worker->path.len, pos); + if (!has_tracked_descendants && + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, worker->path.len)) { + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); + return; + } + if (!has_tracked_descendants) { + if (!scan->collect_untracked || + preload_bulk_untracked_is_invalid(worker) || + preload_bulk_untracked_root_is_visible( + worker, untracked_root) || + preload_bulk_path_is_excluded( + worker, worker->path.buf, DT_DIR)) + return; + if (!untracked_root) + untracked_root = preload_bulk_untracked_root_new( + worker, worker->path.buf, worker->path.len); + } + preload_bulk_schedule_directory( + worker, fd, parent_identity, NULL, untracked_root, + name, worker->path.buf, worker->path.len); +} + +static void record_untracked(struct preload_bulk_worker *worker, + const struct preload_bulk_task *task, + int dtype) +{ + struct preload_bulk_scan *scan = worker->scan; + + if (!scan->collect_untracked || + preload_bulk_untracked_is_invalid(worker)) + return; + if (preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, worker->path.len)) { + preload_bulk_invalidate_untracked(worker); + return; + } + if (!preload_bulk_path_is_excluded( + worker, worker->path.buf, dtype)) + preload_bulk_record_untracked( + worker, task->untracked_root, worker->path.buf); +} + +int preload_bulk_linux_enumerate( + struct preload_bulk_worker *worker, + struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity) +{ + struct preload_bulk_scan *scan = worker->scan; + char *buf = worker->buffer; + size_t path_prefix_len; + + if (!buf) { + buf = xmalloc(PRELOAD_INDEX_BULK_LINUX_BUFFER_SIZE); + worker->buffer = buf; + } + worker->dirs++; + strbuf_reset(&worker->path); + if (strcmp(task->path, ".")) { + strbuf_addstr(&worker->path, task->path); + strbuf_addch(&worker->path, '/'); + } + path_prefix_len = worker->path.len; + + for (;;) { + long bytes = syscall(SYS_getdents64, fd, buf, + PRELOAD_INDEX_BULK_LINUX_BUFFER_SIZE); + size_t offset = 0; + + worker->bulk_calls++; + if (bytes < 0) + return -1; + if (!bytes) + return 0; + while (offset < (size_t)bytes) { + struct preload_linux_dirent64 *de = + (void *)(buf + offset); + size_t minimum = + offsetof(struct preload_linux_dirent64, name) + 1; + size_t name_space; + struct preload_linux_statx stx; + struct stat st; + char *nul; + unsigned char dtype; + int has_tracked_descendants = 0, pos; + + if (scan->collect_untracked && + preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) + return 0; + if ((size_t)bytes - offset < minimum || + de->reclen < minimum || + de->reclen > (size_t)bytes - offset) + goto malformed; + name_space = de->reclen - + offsetof(struct preload_linux_dirent64, name); + nul = memchr(de->name, '\0', name_space); + if (!nul || nul == de->name || + memchr(de->name, '/', nul - de->name)) + goto malformed; + offset += de->reclen; + if (is_dot_or_dotdot(de->name)) + continue; + worker->entries++; + if (!fspathcmp(de->name, ".git")) { + if (strcmp(task->path, ".") && + scan->collect_untracked) + preload_bulk_invalidate_untracked( + worker); + continue; + } + + strbuf_setlen(&worker->path, path_prefix_len); + strbuf_addstr(&worker->path, de->name); + if (worker->path.len > INT_MAX) + goto malformed; + pos = preload_bulk_index_position( + scan, worker->path.buf, worker->path.len); + dtype = de->type; + + /* + * Exact tracked paths always reach statx. A directory + * which may contain tracked descendants can be + * scheduled directly: the O_DIRECTORY open and + * descriptor statx remain authoritative. + * + * A hint cannot classify a wholly untracked entry: + * file-versus-directory changes exclude matching and + * result shape. Force those entries through statx before + * taking either shortcut. + */ + if (pos < 0 && dtype != DT_UNKNOWN) + has_tracked_descendants = + preload_bulk_index_pos_has_tracked_descendants( + scan, worker->path.buf, + worker->path.len, pos); + if (scan->collect_untracked && pos < 0 && + !has_tracked_descendants) + dtype = DT_UNKNOWN; + if (dtype == DT_DIR && pos < 0) { + handle_directory(worker, task, fd, + parent_identity, + de->name, pos); + continue; + } + if (pos < 0 && !has_tracked_descendants && + (dtype == DT_REG || dtype == DT_LNK)) { + record_untracked( + worker, task, + dtype == DT_LNK ? DT_LNK : DT_REG); + continue; + } + if (pos < 0 && !has_tracked_descendants && + dtype != DT_UNKNOWN) { + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); + continue; + } + if (preload_bulk_linux_entry_stat( + worker, fd, de->name, &stx, &st)) { + if (errno == EXDEV) { + record_foreign_entry( + worker, worker->path.buf, + worker->path.len, pos, + stx.mode); + continue; + } + goto malformed; + } + if (S_ISDIR(st.st_mode)) { + handle_directory(worker, task, fd, + parent_identity, + de->name, pos); + continue; + } + if (pos < 0) { + if (S_ISREG(st.st_mode)) + record_untracked(worker, task, DT_REG); + else if (S_ISLNK(st.st_mode)) + record_untracked(worker, task, DT_LNK); + else + preload_bulk_record_tracked_alias_fallback( + worker, worker->path.buf, + worker->path.len); + continue; + } + if ((!S_ISREG(st.st_mode) && !S_ISLNK(st.st_mode)) || + st.st_nlink != 1) { + preload_bulk_record_tracked_fallback( + worker, pos); + continue; + } + preload_bulk_record_tracked(worker, pos, &st); + } + } + +malformed: + worker->malformed++; + return -1; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index e26ee469350b23..f8ec615656d601 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -60,7 +60,9 @@ struct preload_bulk_linux_data { }; struct preload_bulk_scan; +struct preload_bulk_task; struct preload_bulk_worker; +struct preload_bulk_dir_identity; #if defined(SYS_getdents64) && defined(SYS_statx) @@ -86,6 +88,11 @@ int preload_bulk_linux_open_dir_at(struct preload_bulk_worker *worker, int preload_bulk_linux_open_relative(struct preload_bulk_scan *scan, const char *path); +int preload_bulk_linux_enumerate( + struct preload_bulk_worker *worker, + struct preload_bulk_task *task, int fd, + const struct preload_bulk_dir_identity *parent_identity); + #endif /* SYS_getdents64 && SYS_statx */ #endif /* __linux__ */ diff --git a/config.mak.uname b/config.mak.uname index 293d084e01e318..d17fc31a0b541a 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,6 +63,7 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o + COMPAT_OBJS += compat/preload-index/bulk-linux-entry.o COMPAT_OBJS += compat/preload-index/bulk-linux-open.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 815b7512af0d64..4190755222a9d3 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -277,6 +277,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") unix-socket.c unix-stream-server.c compat/linux/procinfo.c + compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") diff --git a/meson.build b/meson.build index ba17de2a8d8e50..8aead4261dbd54 100644 --- a/meson.build +++ b/meson.build @@ -1362,6 +1362,7 @@ endif if host_machine.system() == 'linux' compat_sources += [ 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux-entry.c', 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-stat.c', ] From 297d1c580988df8479f5af52d90e8e7a09aa1c47 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:39 -0500 Subject: [PATCH 217/403] preload-index: detect replaced Linux scan directories Holding a directory descriptor establishes what workers read, but does not prove that the original directory stayed in the worktree. A child can also move under a different parent while queued. Publishing observations from either replacement could hide worktree changes. Capture the complete directory statx observation and converted stat identity before enumeration. Verify the descriptor mount and recheck both identities afterward. For queued children, resolve the parent through the held child descriptor and compare it with the recorded parent identity. Add the mount identifier to the shared directory identity and register the Linux scan module with Make, CMake, and Meson. The native DEVELOPER=1 boundary build compiles it, while recorded directory changes prevent the completed scan from being accepted. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-scan.c | 107 +++++++++++++++++++++++++ compat/preload-index/bulk-linux.h | 2 + config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 1 + meson.build | 1 + preload-index-bulk.h | 1 + 6 files changed, 113 insertions(+) create mode 100644 compat/preload-index/bulk-linux-scan.c diff --git a/compat/preload-index/bulk-linux-scan.c b/compat/preload-index/bulk-linux-scan.c new file mode 100644 index 00000000000000..c6da94aa41564b --- /dev/null +++ b/compat/preload-index/bulk-linux-scan.c @@ -0,0 +1,107 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include + +#include "compat/preload-index/bulk-linux.h" +#include "path-namespace.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static struct preload_bulk_dir_identity directory_identity( + const struct preload_linux_statx *stx, const struct stat *st) +{ + struct preload_bulk_dir_identity result = { + .stat = *st, + .platform_id = stx->mnt_id, + .complete = 1, + }; + + return result; +} + +static int directory_identity_matches( + const struct preload_bulk_dir_identity *before, + const struct preload_linux_statx *stx, const struct stat *after) +{ + return S_ISDIR(after->st_mode) && + path_namespace_stat_equal(&before->stat, after) && + before->platform_id == stx->mnt_id; +} + +int preload_bulk_linux_scan_directory(struct preload_bulk_worker *worker, + struct preload_bulk_task *task) +{ + struct preload_bulk_scan *scan = worker->scan; + struct preload_linux_statx before_stx, after_stx; + struct preload_bulk_dir_identity before_identity; + struct stat before, after; + size_t path_len; + int fd = task->fd; + int ret = -1; + + if (fd < 0) + fd = preload_bulk_linux_open_relative(scan, task->path); + if (fd < 0) + goto out; + if (preload_bulk_test_barrier(scan, task->path)) + goto out; + if (preload_bulk_linux_fd_stat( + worker, fd, &before_stx, &before) || + !S_ISDIR(before.st_mode)) { + if (errno != EXDEV) + goto out; + path_len = strlen(task->path); + preload_bulk_record_tracked_descendants_fallback( + worker, task->path, path_len); + if (scan->collect_untracked) + preload_bulk_invalidate_untracked(worker); + ret = 0; + goto out; + } + before_identity = directory_identity(&before_stx, &before); + if ((!scan->collect_untracked || + !preload_bulk_untracked_root_is_visible( + worker, task->untracked_root)) && + preload_bulk_linux_enumerate( + worker, task, fd, &before_identity)) + goto out; + if (preload_bulk_linux_fd_stat( + worker, fd, &after_stx, &after)) + goto out; + if (!preload_bulk_linux_statx_same(&before_stx, &after_stx) || + !directory_identity_matches( + &before_identity, &after_stx, &after)) + worker->changed_dirs++; + ret = 0; + +out: + if (task->has_parent_identity) { + struct preload_linux_statx parent_stx; + struct stat parent_after; + int parent_changed = fd < 0; + + /* + * Resolve ".." through the held child descriptor so a move + * cannot redirect the parent check to the old path. + */ + if (!parent_changed) + parent_changed = preload_bulk_linux_entry_stat( + worker, fd, "..", &parent_stx, + &parent_after); + if (parent_changed || + !directory_identity_matches( + &task->parent_identity, &parent_stx, + &parent_after)) + worker->changed_dirs++; + } + if (fd >= 0) + close(fd); + return ret; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index f8ec615656d601..672a71c13328ff 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -92,6 +92,8 @@ int preload_bulk_linux_enumerate( struct preload_bulk_worker *worker, struct preload_bulk_task *task, int fd, const struct preload_bulk_dir_identity *parent_identity); +int preload_bulk_linux_scan_directory(struct preload_bulk_worker *worker, + struct preload_bulk_task *task); #endif /* SYS_getdents64 && SYS_statx */ diff --git a/config.mak.uname b/config.mak.uname index d17fc31a0b541a..f4215b95b587c5 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -65,6 +65,7 @@ ifeq ($(uname_S),Linux) COMPAT_OBJS += compat/linux/procinfo.o COMPAT_OBJS += compat/preload-index/bulk-linux-entry.o COMPAT_OBJS += compat/preload-index/bulk-linux-open.o + COMPAT_OBJS += compat/preload-index/bulk-linux-scan.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 4190755222a9d3..315cedd4406a85 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -279,6 +279,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") compat/linux/procinfo.c compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c + compat/preload-index/bulk-linux-scan.c compat/preload-index/bulk-linux-stat.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE diff --git a/meson.build b/meson.build index 8aead4261dbd54..5f604e8928befd 100644 --- a/meson.build +++ b/meson.build @@ -1364,6 +1364,7 @@ if host_machine.system() == 'linux' 'compat/linux/procinfo.c', 'compat/preload-index/bulk-linux-entry.c', 'compat/preload-index/bulk-linux-open.c', + 'compat/preload-index/bulk-linux-scan.c', 'compat/preload-index/bulk-linux-stat.c', ] elif host_machine.system() == 'windows' diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 2934d31b8674c1..b08269dfb3ed59 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -12,6 +12,7 @@ struct preload_bulk_untracked_root; struct preload_bulk_dir_identity { struct stat stat; + uint64_t platform_id; unsigned complete : 1; }; From 426c3408c45cdca6a0e146408f80e7fb6f0fbdef Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:35:16 -0500 Subject: [PATCH 218/403] preload-index: validate Linux mount topology around bulk scans Individually anchored descriptors do not establish that the mount namespace or named worktree root stayed unchanged throughout a scan. A mount replacement can invalidate otherwise consistent directory observations. Accept only ext-family and XFS filesystems with complete root statx and mount-identity data. Capture /proc/self/mountinfo before the scan, compare it at completion, and freshly reopen the named worktree root with O_NOFOLLOW to verify its original complete identity. Probe openat2() without requiring it. Register the topology module in all three Linux builds. The native DEVELOPER=1 boundary build compiles it. Missing namespace proof, unsupported filesystems, changed mount tables, or replaced roots reject the result; the retained mount snapshot adds memory and can reject unrelated namespace changes. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-linux-topology.c | 142 +++++++++++++++++++++ compat/preload-index/bulk-linux.h | 8 ++ config.mak.uname | 1 + contrib/buildsystems/CMakeLists.txt | 3 +- meson.build | 1 + 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 compat/preload-index/bulk-linux-topology.c diff --git a/compat/preload-index/bulk-linux-topology.c b/compat/preload-index/bulk-linux-topology.c new file mode 100644 index 00000000000000..abfb80d214d10a --- /dev/null +++ b/compat/preload-index/bulk-linux-topology.c @@ -0,0 +1,142 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" +#include "repository.h" +#include "trace2.h" + +#ifndef EXT_FAMILY_SUPER_MAGIC +#define EXT_FAMILY_SUPER_MAGIC 0xef53 +#endif +#ifndef XFS_SUPER_MAGIC +#define XFS_SUPER_MAGIC 0x58465342 +#endif + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static int read_mountinfo(struct strbuf *out) +{ + int fd = open("/proc/self/mountinfo", O_RDONLY | O_CLOEXEC); + int ret = -1; + + if (fd < 0) + return -1; + strbuf_reset(out); + if (strbuf_read(out, fd, 0) >= 0) + ret = 0; + if (close(fd)) + ret = -1; + return ret; +} + +const char *preload_bulk_linux_start(struct preload_bulk_scan *scan) +{ + struct preload_bulk_linux_data *data; + struct statfs fs; + const char *fs_name; + + CALLOC_ARRAY(data, 1); + strbuf_init(&data->mountinfo, 0); + scan->platform_data = data; + scan->root_fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (scan->root_fd < 0 || fstatfs(scan->root_fd, &fs)) + return "unsupported-filesystem"; + if ((unsigned long)fs.f_type == EXT_FAMILY_SUPER_MAGIC) + fs_name = "ext-family"; + else if ((unsigned long)fs.f_type == XFS_SUPER_MAGIC) + fs_name = "xfs"; + else + return "unsupported-filesystem"; + trace2_data_string("index", scan->repo, "preload/bulk_filesystem", + fs_name); + if (preload_bulk_linux_statx_raw( + scan->root_fd, "", PRELOAD_AT_EMPTY_PATH, + &data->root_statx) || + !preload_bulk_linux_statx_complete(&data->root_statx) || + !S_ISDIR(data->root_statx.mode)) + return "statx-unavailable"; + data->root_mnt_id = data->root_statx.mnt_id; + if (read_mountinfo(&data->mountinfo)) + return "namespace-check-unavailable"; +#ifdef SYS_openat2 + { + int fd = preload_bulk_linux_openat2_raw(scan->root_fd, "."); + + if (fd >= 0) { + struct preload_linux_statx probe; + + if (!preload_bulk_linux_statx_raw( + fd, "", PRELOAD_AT_EMPTY_PATH, &probe) && + preload_bulk_linux_statx_complete(&probe) && + probe.mnt_id == data->root_mnt_id) + data->use_openat2 = 1; + close(fd); + } + } +#endif + trace2_data_intmax("index", scan->repo, "preload/bulk_openat2", + data->use_openat2); + return NULL; +} + +const char *preload_bulk_linux_finish(struct preload_bulk_scan *scan) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + struct strbuf after = STRBUF_INIT; + struct preload_linux_statx root_after; + const char *result = NULL; + int fd; + + if (read_mountinfo(&after)) { + result = "namespace-check-unavailable"; + goto out; + } + if (strbuf_cmp(&data->mountinfo, &after)) { + trace2_data_intmax( + "index", scan->repo, + "preload/bulk_namespace_churn", 1); + result = "namespace-churn"; + goto out; + } + fd = open(repo_get_work_tree(scan->repo), + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) { + result = "namespace-race"; + goto out; + } + if (preload_bulk_linux_statx_raw( + fd, "", PRELOAD_AT_EMPTY_PATH, &root_after) || + !preload_bulk_linux_statx_same( + &data->root_statx, &root_after)) + result = "namespace-race"; + close(fd); + +out: + strbuf_release(&after); + return result; +} + +void preload_bulk_linux_release(struct preload_bulk_scan *scan) +{ + struct preload_bulk_linux_data *data = scan->platform_data; + + if (scan->root_fd >= 0) { + close(scan->root_fd); + scan->root_fd = -1; + } + if (!data) + return; + strbuf_release(&data->mountinfo); + free(data); + scan->platform_data = NULL; +} + +#endif /* SYS_getdents64 && SYS_statx */ + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index 672a71c13328ff..ac1398216e1662 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -5,6 +5,8 @@ #include +#include "strbuf.h" + #define PRELOAD_AT_NO_AUTOMOUNT 0x800 #define PRELOAD_AT_EMPTY_PATH 0x1000 #define PRELOAD_AT_SYMLINK_NOFOLLOW 0x100 @@ -55,6 +57,8 @@ struct preload_linux_open_how { }; struct preload_bulk_linux_data { + struct preload_linux_statx root_statx; + struct strbuf mountinfo; uint64_t root_mnt_id; int use_openat2; }; @@ -95,6 +99,10 @@ int preload_bulk_linux_enumerate( int preload_bulk_linux_scan_directory(struct preload_bulk_worker *worker, struct preload_bulk_task *task); +const char *preload_bulk_linux_start(struct preload_bulk_scan *scan); +const char *preload_bulk_linux_finish(struct preload_bulk_scan *scan); +void preload_bulk_linux_release(struct preload_bulk_scan *scan); + #endif /* SYS_getdents64 && SYS_statx */ #endif /* __linux__ */ diff --git a/config.mak.uname b/config.mak.uname index f4215b95b587c5..bc768e714c9ee7 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -67,6 +67,7 @@ ifeq ($(uname_S),Linux) COMPAT_OBJS += compat/preload-index/bulk-linux-open.o COMPAT_OBJS += compat/preload-index/bulk-linux-scan.o COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o + COMPAT_OBJS += compat/preload-index/bulk-linux-topology.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. ifneq ($(findstring .el7.,$(uname_R)),) diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index 315cedd4406a85..e961999f61a0a4 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -280,7 +280,8 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-scan.c - compat/preload-index/bulk-linux-stat.c) + compat/preload-index/bulk-linux-stat.c + compat/preload-index/bulk-linux-topology.c) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") add_compile_definitions(HAVE_PRELOAD_INDEX_BULK PRECOMPOSE_UNICODE USE_ST_TIMESPEC) diff --git a/meson.build b/meson.build index 5f604e8928befd..7074bd322ed7a2 100644 --- a/meson.build +++ b/meson.build @@ -1366,6 +1366,7 @@ if host_machine.system() == 'linux' 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-scan.c', 'compat/preload-index/bulk-linux-stat.c', + 'compat/preload-index/bulk-linux-topology.c', ] elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' From a27510777706724ad9ec1be4ec00f61a9f40b250 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:39:36 -0500 Subject: [PATCH 219/403] preload-index: enable the verified Linux bulk scan backend The separately registered Linux metadata, anchored-open, enumeration, directory-validation, and topology modules cannot safely publish a physical scan by themselves. They must share the existing bulk backend lifecycle so every closing check runs before results are accepted. Assemble those modules into the Linux backend and register the shared and platform objects with Make, CMake, and Meson. Retain the existing requirements that core.preloadIndex and core.preloadIndexBulk are enabled and fsmonitor is disabled. Preserve ordinary preload when a required syscall, filesystem, mount proof, or closing validation is unavailable. Cap Linux scans at 16 workers. Each worker can allocate a 1 MiB directory buffer; mount snapshots and retained scan results add further memory. Document ext-family and XFS support and keep directory-type injection confined to the documented test environment. Add and register t7532-preload-index-linux.sh with 12 Linux-only cases. Native Linux validation passes 12/12 in the threaded build and 12/12 in a separate NO_PTHREADS build; CMake and Meson link Git. The suite compares ordinary status for tracked changes, visible and ignored paths, false type hints, fallback shapes, and a synchronized child replacement. Signed-off-by: Taylor Blau --- Documentation/config/core.adoc | 5 +- compat/preload-index/bulk-linux-entry.c | 4 + compat/preload-index/bulk-linux-topology.c | 24 ++ compat/preload-index/bulk-linux.c | 37 +++ compat/preload-index/bulk-linux.h | 2 + config.mak.uname | 11 +- contrib/buildsystems/CMakeLists.txt | 7 +- meson.build | 8 + preload-index-bulk.c | 3 + preload-index-bulk.h | 1 + t/README | 4 + t/meson.build | 1 + t/t7532-preload-index-linux.sh | 346 +++++++++++++++++++++ 13 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 compat/preload-index/bulk-linux.c create mode 100755 t/t7532-preload-index-linux.sh diff --git a/Documentation/config/core.adoc b/Documentation/config/core.adoc index 5f01b603e5761a..59bc4a818cceb8 100644 --- a/Documentation/config/core.adoc +++ b/Documentation/config/core.adoc @@ -736,8 +736,9 @@ core.preloadIndexBulk:: This replaces per-entry filesystem lookups with a physical directory scan, but may cost more than normal preload depending on filesystem and cache state. Inconclusive scans are discarded before continuing with the normal -preload. Currently this is supported on APFS and only has an effect when -`core.preloadIndex` is enabled. Defaults to false. +preload. Currently this is supported on APFS, ext-family filesystems, and +XFS, and only has an effect when `core.preloadIndex` is enabled. Defaults +to false. core.unsetenvvars:: Windows-only: comma-separated list of environment variables' diff --git a/compat/preload-index/bulk-linux-entry.c b/compat/preload-index/bulk-linux-entry.c index d911c2be137964..8b63db291e7562 100644 --- a/compat/preload-index/bulk-linux-entry.c +++ b/compat/preload-index/bulk-linux-entry.c @@ -105,6 +105,7 @@ int preload_bulk_linux_enumerate( const struct preload_bulk_dir_identity *parent_identity) { struct preload_bulk_scan *scan = worker->scan; + struct preload_bulk_linux_data *data = scan->platform_data; char *buf = worker->buffer; size_t path_prefix_len; @@ -175,6 +176,9 @@ int preload_bulk_linux_enumerate( pos = preload_bulk_index_position( scan, worker->path.buf, worker->path.len); dtype = de->type; + if (data->test_dirent_path && + !strcmp(data->test_dirent_path, worker->path.buf)) + dtype = data->test_dirent_type; /* * Exact tracked paths always reach statx. A directory diff --git a/compat/preload-index/bulk-linux-topology.c b/compat/preload-index/bulk-linux-topology.c index abfb80d214d10a..523e6c08b909b3 100644 --- a/compat/preload-index/bulk-linux-topology.c +++ b/compat/preload-index/bulk-linux-topology.c @@ -2,10 +2,12 @@ #ifdef __linux__ +#include #include #include #include "compat/preload-index/bulk-linux.h" +#include "parse.h" #include "preload-index-bulk.h" #include "repository.h" #include "trace2.h" @@ -19,6 +21,26 @@ #if defined(SYS_getdents64) && defined(SYS_statx) +static void load_test_dirent_type(struct preload_bulk_linux_data *data) +{ + const char *path, *value; + + if (!git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", 0)) + return; + value = getenv("GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE"); + if (!value) + return; + if (skip_prefix(value, "dir:", &path)) + data->test_dirent_type = DT_DIR; + else if (skip_prefix(value, "reg:", &path)) + data->test_dirent_type = DT_REG; + else + die("invalid GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE"); + if (!*path) + die("GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE needs a path"); + data->test_dirent_path = xstrdup(path); +} + static int read_mountinfo(struct strbuf *out) { int fd = open("/proc/self/mountinfo", O_RDONLY | O_CLOEXEC); @@ -42,6 +64,7 @@ const char *preload_bulk_linux_start(struct preload_bulk_scan *scan) CALLOC_ARRAY(data, 1); strbuf_init(&data->mountinfo, 0); + load_test_dirent_type(data); scan->platform_data = data; scan->root_fd = open(repo_get_work_tree(scan->repo), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); @@ -133,6 +156,7 @@ void preload_bulk_linux_release(struct preload_bulk_scan *scan) if (!data) return; strbuf_release(&data->mountinfo); + free(data->test_dirent_path); free(data); scan->platform_data = NULL; } diff --git a/compat/preload-index/bulk-linux.c b/compat/preload-index/bulk-linux.c new file mode 100644 index 00000000000000..5c4c120a61840c --- /dev/null +++ b/compat/preload-index/bulk-linux.c @@ -0,0 +1,37 @@ +#include "git-compat-util.h" + +#ifdef __linux__ + +#include + +#include "compat/preload-index/bulk-linux.h" +#include "preload-index-bulk.h" + +#if defined(SYS_getdents64) && defined(SYS_statx) + +static const struct preload_bulk_backend linux_backend = { + .collects_untracked = 1, + .max_threads = 16, + .start = preload_bulk_linux_start, + .finish = preload_bulk_linux_finish, + .release = preload_bulk_linux_release, + .open_proof_parent = preload_bulk_linux_open_relative, + .open_dir_at = preload_bulk_linux_open_dir_at, + .scan_directory = preload_bulk_linux_scan_directory, +}; + +const struct preload_bulk_backend *preload_bulk_platform_backend(void) +{ + return &linux_backend; +} + +#else /* !SYS_getdents64 || !SYS_statx */ + +const struct preload_bulk_backend *preload_bulk_platform_backend(void) +{ + return NULL; +} + +#endif + +#endif /* __linux__ */ diff --git a/compat/preload-index/bulk-linux.h b/compat/preload-index/bulk-linux.h index ac1398216e1662..e25db006115584 100644 --- a/compat/preload-index/bulk-linux.h +++ b/compat/preload-index/bulk-linux.h @@ -60,6 +60,8 @@ struct preload_bulk_linux_data { struct preload_linux_statx root_statx; struct strbuf mountinfo; uint64_t root_mnt_id; + char *test_dirent_path; + unsigned char test_dirent_type; int use_openat2; }; diff --git a/config.mak.uname b/config.mak.uname index bc768e714c9ee7..4d205c4c493939 100644 --- a/config.mak.uname +++ b/config.mak.uname @@ -63,11 +63,12 @@ ifeq ($(uname_S),Linux) PROCFS_EXECUTABLE_PATH = /proc/self/exe HAVE_PLATFORM_PROCINFO = YesPlease COMPAT_OBJS += compat/linux/procinfo.o - COMPAT_OBJS += compat/preload-index/bulk-linux-entry.o - COMPAT_OBJS += compat/preload-index/bulk-linux-open.o - COMPAT_OBJS += compat/preload-index/bulk-linux-scan.o - COMPAT_OBJS += compat/preload-index/bulk-linux-stat.o - COMPAT_OBJS += compat/preload-index/bulk-linux-topology.o + PRELOAD_INDEX_BULK_BACKEND = linux + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-entry.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-open.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-scan.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-stat.o + PRELOAD_INDEX_BULK_PLATFORM_OBJS += compat/preload-index/bulk-linux-topology.o EXTLIBS += -ldl # centos7/rhel7 provides gcc 4.8.5 and zlib 1.2.7. ifneq ($(findstring .el7.,$(uname_R)),) diff --git a/contrib/buildsystems/CMakeLists.txt b/contrib/buildsystems/CMakeLists.txt index e961999f61a0a4..6440b31a4483a1 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -272,11 +272,13 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") set(NO_UNIX_SOCKETS 1) elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") - add_compile_definitions(PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY ) + add_compile_definitions(HAVE_PRELOAD_INDEX_BULK + PROCFS_EXECUTABLE_PATH="/proc/self/exe" HAVE_DEV_TTY) list(APPEND compat_SOURCES unix-socket.c unix-stream-server.c compat/linux/procinfo.c + compat/preload-index/bulk-linux.c compat/preload-index/bulk-linux-entry.c compat/preload-index/bulk-linux-open.c compat/preload-index/bulk-linux-scan.c @@ -679,7 +681,8 @@ include_directories(${CMAKE_BINARY_DIR}) #libgit parse_makefile_for_sources(libgit_SOURCES ${CMAKE_SOURCE_DIR}/Makefile "LIB_OBJS") -if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin" OR + CMAKE_SYSTEM_NAME STREQUAL "Linux") list(APPEND libgit_SOURCES preload-index-bulk-index.c preload-index-bulk-thread.c diff --git a/meson.build b/meson.build index 7074bd322ed7a2..23888563a37db6 100644 --- a/meson.build +++ b/meson.build @@ -1319,6 +1319,8 @@ if host_machine.system() == 'darwin' libgit_c_args += '-DHAVE_PRELOAD_INDEX_BULK' libgit_c_args += '-DPRECOMPOSE_UNICODE' libgit_c_args += '-DPROTECT_HFS_DEFAULT' +elif host_machine.system() == 'linux' + libgit_c_args += '-DHAVE_PRELOAD_INDEX_BULK' endif # Configure general compatibility wrappers. @@ -1362,12 +1364,18 @@ endif if host_machine.system() == 'linux' compat_sources += [ 'compat/linux/procinfo.c', + 'compat/preload-index/bulk-linux.c', 'compat/preload-index/bulk-linux-entry.c', 'compat/preload-index/bulk-linux-open.c', 'compat/preload-index/bulk-linux-scan.c', 'compat/preload-index/bulk-linux-stat.c', 'compat/preload-index/bulk-linux-topology.c', ] + libgit_sources += [ + 'preload-index-bulk-index.c', + 'preload-index-bulk-thread.c', + 'preload-index-bulk.c', + ] elif host_machine.system() == 'windows' compat_sources += 'compat/win32/trace2_win32_process_info.c' elif host_machine.system() == 'darwin' diff --git a/preload-index-bulk.c b/preload-index-bulk.c index cd53761358af4f..eed468b0d156ca 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -213,6 +213,9 @@ int preload_bulk_collect(struct index_state *istate, int threads, backend->open_proof_parent; if (istate->preload_untracked && !scan.collect_untracked) untracked_reason = "backend-unsupported"; + if (backend->max_threads > 0 && + scan.threads > backend->max_threads) + scan.threads = backend->max_threads; if (scan.collect_untracked) { scan.exclude_dir = &exclude_dir; #if HAVE_THREADS diff --git a/preload-index-bulk.h b/preload-index-bulk.h index b08269dfb3ed59..c6c862dbb8136c 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -60,6 +60,7 @@ struct preload_bulk_worker { struct preload_bulk_backend { unsigned collects_untracked : 1; + int max_threads; const char *(*start)(struct preload_bulk_scan *scan); const char *(*finish)(struct preload_bulk_scan *scan); void (*release)(struct preload_bulk_scan *scan); diff --git a/t/README b/t/README index e063266240fda9..31ead13c82aeb5 100644 --- a/t/README +++ b/t/README @@ -425,6 +425,10 @@ by overriding the minimum number of cache entries required per thread. GIT_TEST_PRELOAD_INDEX_BULK= overrides the `core.preloadIndexBulk` setting. +GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=:, when +GIT_TEST_PRELOAD_INDEX_BULK is enabled, overrides the Linux directory +entry type for one worktree-relative path. is `dir` or `reg`. + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH=, GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY=, and GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME=, when diff --git a/t/meson.build b/t/meson.build index de510614fad57b..0951dce558bea3 100644 --- a/t/meson.build +++ b/t/meson.build @@ -960,6 +960,7 @@ integration_tests = [ 't7528-signed-commit-ssh.sh', 't7529-preload-index-apfs.sh', 't7531-semantic-verify.sh', + 't7532-preload-index-linux.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7532-preload-index-linux.sh b/t/t7532-preload-index-linux.sh new file mode 100755 index 00000000000000..2941448326397a --- /dev/null +++ b/t/t7532-preload-index-linux.sh @@ -0,0 +1,346 @@ +#!/bin/sh + +test_description='Linux bulk index preload' + +. ./test-lib.sh + +if test "$(uname -s)" != Linux +then + skip_all='Linux getdents64/statx backend required' + test_done +fi + +case "$(stat -f -c %t "$TRASH_DIRECTORY")" in +ef53) + filesystem=ext-family + ;; +58465342) + filesystem=xfs + ;; +*) + skip_all='tests require an ext-family filesystem or XFS' + test_done + ;; +esac + +setup_repo () { + repo=$1 && + git init "$repo" && + mkdir -p "$repo/nested/deep" && + test_write_lines root >"$repo/root" && + test_write_lines peer >"$repo/peer" && + test_write_lines nested >"$repo/nested/tracked" && + test_write_lines deep >"$repo/nested/deep/tracked" && + git -C "$repo" add . && + git -C "$repo" commit -m base && + git -C "$repo" config core.fsmonitor false && + test-tool chmtime -120 "$repo/root" "$repo/peer" \ + "$repo/nested/tracked" "$repo/nested/deep/tracked" && + git -C "$repo" update-index --refresh +} + +test_lazy_prereq LINUX_BULK_PRELOAD ' + setup_repo linux-bulk-prereq && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/linux-bulk-prereq.trace" \ + git -C linux-bulk-prereq \ + -c core.preloadIndexBulk=true \ + status --porcelain=v2 >actual && + test_must_be_empty actual && + test_trace2_data index preload/bulk_result complete \ + <"$TRASH_DIRECTORY/linux-bulk-prereq.trace" +' + +if ! test_have_prereq LINUX_BULK_PRELOAD +then + skip_all="Linux bulk preload backend unavailable at runtime" + test_done +fi + +ordinary_status () { + GIT_OPTIONAL_LOCKS=0 \ + git -C "$1" -c core.preloadIndex=false \ + status --porcelain=v2 >"$2" +} + +bulk_status () { + repo=$1 && + output=$2 && + bulk_trace=$TRASH_DIRECTORY/$3 && + rm -f "$bulk_trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$bulk_trace" \ + git -C "$repo" status --porcelain=v2 >"$output" +} + +check_data () { + test_trace2_data index "$2" "$3" <"$TRASH_DIRECTORY/$1" +} + +check_lstat_data () { + test_have_prereq !PTHREADS || + check_data "$1" preload/sum_lstat "$2" +} + +compare_status () { + ordinary_status "$1" expect && + bulk_status "$1" actual "$2" && + test_cmp expect actual +} + +cleanup_race () { + exec 9>&- + if test -n "$status_pid" + then + kill "$status_pid" 2>/dev/null || : + wait "$status_pid" 2>/dev/null || : + fi + status_pid= && + rm -f "$ready" "$resume" +} + +wait_for_ready () { + for i in $(test_seq 1 1000) + do + test "$(cat "$ready" 2>/dev/null)" = ready && return 0 + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +start_raced_status () { + repo=$1 && + barrier=$2 && + ready=$TRASH_DIRECTORY/$repo.ready && + resume=$TRASH_DIRECTORY/$repo.resume && + race_trace=$TRASH_DIRECTORY/$repo.trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_PATH="$barrier" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_READY="$ready" \ + GIT_TEST_PRELOAD_INDEX_BULK_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + git -C "$repo" status --porcelain=v2 >actual 9>&- & + status_pid=$! + } && + wait_for_ready +} + +finish_raced_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= && + ordinary_status "$1" expect && + test_cmp expect actual && + test_trace2_data index preload/bulk_applied 0 <"$race_trace" +} + +test_expect_success 'clean entries are published without lstat' ' + setup_repo clean && + bulk_status clean actual clean.trace && + test_must_be_empty actual && + check_data clean.trace preload/bulk_filesystem "$filesystem" && + check_data clean.trace preload/bulk_applied 4 && + check_data clean.trace preload/bulk_untracked_complete 1 && + check_lstat_data clean.trace 0 +' + +test_expect_success 'tracked files ignore a directory type hint' ' + setup_repo dirent-file && + ordinary_status dirent-file expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=dir:root \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-file.trace" \ + git -C dirent-file status --porcelain=v2 >actual && + test_cmp expect actual && + check_data dirent-file.trace preload/bulk_result complete && + check_data dirent-file.trace preload/bulk_applied 4 && + check_data dirent-file.trace preload/bulk_fallback 0 +' + +test_expect_success 'tracked subtrees ignore a regular-file type hint' ' + setup_repo dirent-prefix && + test_write_lines visible >dirent-prefix/nested/untracked && + ordinary_status dirent-prefix expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=reg:nested \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-prefix.trace" \ + git -C dirent-prefix status --porcelain=v2 >actual && + test_cmp expect actual && + check_data dirent-prefix.trace preload/bulk_result complete && + check_data dirent-prefix.trace preload/bulk_applied 4 && + check_data dirent-prefix.trace preload/bulk_definitive_deleted 0 && + check_data dirent-prefix.trace preload/bulk_fallback 0 && + check_data dirent-prefix.trace preload/bulk_untracked_complete 1 +' + +test_expect_success 'untracked directories ignore a regular-file type hint' ' + setup_repo dirent-untracked-directory && + mkdir -p dirent-untracked-directory/collapsed/deep && + test_write_lines visible \ + >dirent-untracked-directory/collapsed/deep/file && + ordinary_status dirent-untracked-directory expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=reg:collapsed \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-untracked-directory.trace" \ + git -C dirent-untracked-directory \ + status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep "^? collapsed/$" actual && + check_data dirent-untracked-directory.trace \ + preload/bulk_result complete && + check_data dirent-untracked-directory.trace \ + preload/bulk_untracked_complete 1 && + check_data dirent-untracked-directory.trace \ + preload/bulk_untracked_count 1 +' + +test_expect_success 'ignored directories ignore a regular-file type hint' ' + setup_repo dirent-ignored-directory && + test_write_lines "*.ignored" >dirent-ignored-directory/.gitignore && + git -C dirent-ignored-directory add .gitignore && + git -C dirent-ignored-directory commit -m ignore && + mkdir dirent-ignored-directory/ignored-only && + test_write_lines ignored \ + >dirent-ignored-directory/ignored-only/file.ignored && + ordinary_status dirent-ignored-directory expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=reg:ignored-only \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-ignored-directory.trace" \ + git -C dirent-ignored-directory \ + status --porcelain=v2 >actual && + test_cmp expect actual && + test_must_be_empty actual && + check_data dirent-ignored-directory.trace \ + preload/bulk_result complete && + check_data dirent-ignored-directory.trace \ + preload/bulk_untracked_complete 1 && + check_data dirent-ignored-directory.trace \ + preload/bulk_untracked_count 0 +' + +test_expect_success 'untracked files ignore a directory type hint' ' + setup_repo dirent-untracked-file && + test_write_lines "visible/" >dirent-untracked-file/.gitignore && + git -C dirent-untracked-file add .gitignore && + git -C dirent-untracked-file commit -m ignore && + test_write_lines visible >dirent-untracked-file/visible && + ordinary_status dirent-untracked-file expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_PRELOAD_INDEX_BULK_DIRENT_TYPE=dir:visible \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/dirent-untracked-file.trace" \ + git -C dirent-untracked-file status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep "^? visible$" actual && + check_data dirent-untracked-file.trace \ + preload/bulk_result complete && + check_data dirent-untracked-file.trace \ + preload/bulk_untracked_complete 1 && + check_data dirent-untracked-file.trace \ + preload/bulk_untracked_count 1 +' + +test_expect_success 'visible and ignored paths match ordinary status' ' + setup_repo visible && + test_write_lines "*.ignored" >visible/.gitignore && + git -C visible add .gitignore && + git -C visible commit -m ignore && + test_write_lines root >visible/untracked && + mkdir -p visible/collapsed/deep visible/ignored-only && + test_write_lines nested >visible/collapsed/deep/file && + test_write_lines ignored >visible/ignored-only/file.ignored && + compare_status visible visible.trace && + test_grep "^? untracked$" actual && + test_grep "^? collapsed/$" actual && + test_grep ! "ignored-only" actual && + check_data visible.trace preload/bulk_untracked_complete 1 && + check_data visible.trace preload/bulk_untracked_count 2 && + test_grep ! "\"category\":\"read_directory\"" visible.trace +' + +test_expect_success 'tracked changes match ordinary status' ' + for mode in modified deleted metadata + do + setup_repo "$mode" || return 1 && + case "$mode" in + modified) test_write_lines changed-content >"$mode/root" ;; + deleted) rm "$mode/nested/tracked" ;; + metadata) test-tool chmtime +60 "$mode/root" ;; + esac && + compare_status "$mode" "$mode.trace" || return 1 + done && + check_data modified.trace preload/bulk_definitive_modified 1 && + check_data modified.trace refresh/sum_lstat 0 && + check_data deleted.trace preload/bulk_definitive_deleted 1 && + check_data deleted.trace refresh/sum_lstat 0 && + check_data metadata.trace preload/bulk_content_check 1 && + check_data metadata.trace refresh/sum_lstat 0 +' + +test_expect_success 'tracked-file replacement directories are pruned' ' + setup_repo replacement-dir && + rm replacement-dir/root && + mkdir -p replacement-dir/root/deep/embedded && + test_write_lines hidden >replacement-dir/root/deep/untracked && + git -C replacement-dir/root/deep/embedded init && + compare_status replacement-dir replacement-dir.trace && + test_line_count = 1 actual && + check_data replacement-dir.trace preload/bulk_fallback 1 && + check_data replacement-dir.trace preload/bulk_untracked_complete 1 +' + +test_expect_success PIPE 'tracked FIFO replacements fall back' ' + setup_repo tracked-fifo && + rm tracked-fifo/root && + mkfifo tracked-fifo/root && + compare_status tracked-fifo tracked-fifo.trace && + test_grep "^1 \\.M .* root$" actual && + check_data tracked-fifo.trace preload/bulk_result complete && + check_data tracked-fifo.trace preload/bulk_applied 3 && + check_data tracked-fifo.trace preload/bulk_fallback 1 && + check_data tracked-fifo.trace preload/bulk_definitive_deleted 0 +' + +test_expect_success PIPE 'queued child replacement discards observations' ' + setup_repo child-race && + test_when_finished cleanup_race && + start_raced_status child-race nested/deep && + mv child-race/nested/deep child-race/deep-away && + mkdir child-race/nested/deep && + test_write_lines dirty >child-race/nested/deep/tracked && + finish_raced_status child-race && + test_file_not_empty actual +' + +test_expect_success PIPE 'fallback shapes retain exact output' ' + setup_repo shapes && + ln shapes/root shapes/linked && + mkfifo shapes/fifo && + git init shapes/embedded && + compare_status shapes shapes.trace && + check_data shapes.trace preload/bulk_fallback 1 && + check_data shapes.trace preload/bulk_untracked_complete 0 +' + +test_done From ceca085d39f4bfd3c0fe21cd52f970130a9cc914 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:53:33 -0500 Subject: [PATCH 220/403] status: close bulk content proofs with the provider token The bulk preloader rejected an active fsmonitor provider, so status verified ambiguous tracked entries through a separate semantic scan. Publishing bulk observations or refreshed stat data before the closing provider query would permit a concurrent change to invalidate a clean result. Pass held parent descriptors, basenames, and observed metadata from both platform walkers to semantic_verify_file_at(). Borrow the captured provider proof epoch, hash eligible raw-safe files during the bulk walk, and retain clean states and stat updates provisionally. After the closing provider query confirms the same epoch, validate all pending positions before publishing clean states, refreshed stat data, and fsmonitor-valid bits. Clear provisional state on provider failure, epoch mismatch, or invalid updates, and retain the existing complete-refresh fallback. Choose the provider-backed bulk path from its actual safety conditions, not from whether semantic history is awaiting adoption. This lets a trivial daemon response or daemon restart rebuild and close an ordinary bulk proof, including for a skipHash index, while retaining the complete proof epoch and closing query. Require both preload settings, an expanded index, a pending built-in IPC token, and an eligible whole-worktree request. Keep APFS and Linux within their platform and filesystem limits. Allocate a bounded hash buffer and attribute check per content-verification worker, and retain tracked states and stat updates only until closure. Extend the APFS and Linux tests with same-size, restored-mtime content changes. Cover accepted closure, provider failure, dirty status, daemon token reset with a null-checksum index, and Trace2 evidence of hashing, deferred publication, and token acceptance or rejection. Signed-off-by: Taylor Blau --- compat/preload-index/bulk-darwin.c | 3 +- compat/preload-index/bulk-linux-entry.c | 3 +- fsmonitor-ll.h | 3 +- fsmonitor.c | 13 +- preload-index-bulk-index.c | 61 ++++++- preload-index-bulk-thread.c | 74 ++++++++ preload-index-bulk.c | 14 ++ preload-index-bulk.h | 28 ++- preload-index.c | 221 ++++++++++++++++++++---- preload-index.h | 2 + read-cache-ll.h | 10 +- read-cache.c | 4 + semantic-verify-file.c | 32 +++- semantic-verify-internal.h | 2 +- t/t7519-status-fsmonitor.sh | 31 ++++ t/t7527-builtin-fsmonitor.sh | 65 +++++++ t/t7529-preload-index-apfs.sh | 114 ++++++++++++ t/t7532-preload-index-linux.sh | 75 ++++++++ wt-status.c | 104 +++++++++-- 19 files changed, 789 insertions(+), 70 deletions(-) diff --git a/compat/preload-index/bulk-darwin.c b/compat/preload-index/bulk-darwin.c index 7e65c5a24c78eb..8aef3a12a61413 100644 --- a/compat/preload-index/bulk-darwin.c +++ b/compat/preload-index/bulk-darwin.c @@ -495,7 +495,8 @@ static int enumerate_directory(struct preload_bulk_worker *worker, entry.uid, entry.gid, entry.access, entry.linkcount, entry.size)) goto malformed_record; - preload_bulk_record_tracked(worker, pos, &st); + preload_bulk_record_tracked( + worker, pos, fd, entry.name, &st, 0); next_record: record += entry.record_len; diff --git a/compat/preload-index/bulk-linux-entry.c b/compat/preload-index/bulk-linux-entry.c index 8b63db291e7562..02cb27882bece3 100644 --- a/compat/preload-index/bulk-linux-entry.c +++ b/compat/preload-index/bulk-linux-entry.c @@ -253,7 +253,8 @@ int preload_bulk_linux_enumerate( worker, pos); continue; } - preload_bulk_record_tracked(worker, pos, &st); + preload_bulk_record_tracked( + worker, pos, fd, de->name, &st, 0); } } diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 9e64d7d8571b87..339a21078c98ff 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -74,7 +74,8 @@ int fsmonitor_reopen_token(struct index_state *istate); enum fsmonitor_token_result fsmonitor_query_pending_token( struct index_state *istate, int untracked_ready); void fsmonitor_accept_pending_token(struct index_state *istate, - int untracked_ready); + int untracked_proof_complete, + int untracked_cache_valid); void fsmonitor_reject_pending_token(struct index_state *istate); void fsmonitor_mark_untracked_cache_valid(struct index_state *istate); diff --git a/fsmonitor.c b/fsmonitor.c index dd3e529db468f7..ee15d75bab4ca5 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1385,8 +1385,11 @@ enum fsmonitor_token_result fsmonitor_query_pending_token( } void fsmonitor_accept_pending_token(struct index_state *istate, - int untracked_ready) + int untracked_proof_complete, + int untracked_cache_valid) { + if (untracked_cache_valid && !untracked_proof_complete) + BUG("valid untracked cache without a complete proof"); if (!fsmonitor_pending_token_from_provider(istate)) return; FREE_AND_NULL(istate->fsmonitor_last_update); @@ -1394,15 +1397,15 @@ void fsmonitor_accept_pending_token(struct index_state *istate, istate->fsmonitor_last_update_pending = NULL; istate->fsmonitor_pending_token_from_provider = 0; istate->fsmonitor_token_valid = 1; - istate->fsmonitor_untracked_valid = !!untracked_ready; + istate->fsmonitor_untracked_valid = !!untracked_cache_valid; if (istate->untracked) - istate->untracked->use_fsmonitor = !!untracked_ready; + istate->untracked->use_fsmonitor = !!untracked_cache_valid; istate->cache_changed |= FSMONITOR_CHANGED; FREE_AND_NULL(istate->fsmonitor_untracked_token); - if (untracked_ready) + if (untracked_cache_valid) istate->fsmonitor_untracked_token = xstrdup(istate->fsmonitor_last_update); - else { + else if (!untracked_proof_complete) { /* * Keep a query anchored at the accepted tracked token. A * later in-process status may need to close work done after diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index b09b69582397a8..d4691d07678bd4 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -3,6 +3,9 @@ #include "object.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" +#include "repository.h" +#include "semantic-verify.h" +#include "semantic-verify-internal.h" #ifndef __has_builtin #define __has_builtin(x) 0 @@ -78,6 +81,18 @@ static int record_tracked_state(struct preload_bulk_worker *worker, int pos, return recorded; } +static void record_stat_update(struct preload_bulk_worker *worker, int pos, + const struct stat_data *stat_data) +{ + struct preload_bulk_stat_update *update; + + ALLOC_GROW(worker->stat_updates, worker->stat_updates_nr + 1, + worker->stat_updates_alloc); + update = &worker->stat_updates[worker->stat_updates_nr++]; + update->cache_pos = pos; + memcpy(&update->stat_data, stat_data, sizeof(update->stat_data)); +} + static int tracked_entry_is_eligible(const struct cache_entry *ce) { return !ce_stage(ce) && @@ -109,6 +124,38 @@ static int size_change_is_definitive(const struct cache_entry *ce, DATA_CHANGED); } +static unsigned char verify_content_at( + struct preload_bulk_worker *worker, int pos, int parent_fd, + const char *basename, const struct stat *st, + int observed_has_platform_identity, struct stat_data *stat_data, + int *has_stat_update) +{ + struct preload_bulk_scan *scan = worker->scan; + struct cache_entry *ce = scan->istate->cache[pos]; + struct semantic_verify_file_result file; + + *has_stat_update = 0; + if (!scan->verify_content || + !semantic_verify_classify_entry( + scan->istate, ce, worker->attr_check, 0, &file)) + return PRELOAD_BULK_TRACKED_CONTENT_CHECK; + semantic_verify_file_at( + parent_fd, basename, st, observed_has_platform_identity, + scan->root_dev, ce, scan->istate->repo, + worker->hash_buffer, &file); + worker->bytes_hashed += file.bytes_hashed; + if (file.kind == SEMANTIC_VERIFY_RAW_MODIFIED) + return PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED; + if (file.kind != SEMANTIC_VERIFY_RAW_CLEAN || !file.persistable) + return PRELOAD_BULK_TRACKED_CONTENT_CHECK; + if (memcmp(&file.stat_data, &ce->ce_stat_data, + sizeof(file.stat_data))) { + memcpy(stat_data, &file.stat_data, sizeof(*stat_data)); + *has_stat_update = 1; + } + return PRELOAD_BULK_TRACKED_CLEAN; +} + int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, int pos) { @@ -116,12 +163,16 @@ int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, } void preload_bulk_record_tracked( - struct preload_bulk_worker *worker, int pos, const struct stat *st) + struct preload_bulk_worker *worker, int pos, int parent_fd, + const char *basename, const struct stat *st, + int observed_has_platform_identity) { struct preload_bulk_scan *scan = worker->scan; struct cache_entry *ce = scan->istate->cache[pos]; + struct stat_data stat_data; unsigned int changed; unsigned char state; + int has_stat_update = 0; if (!tracked_entry_is_eligible(ce)) return; @@ -133,8 +184,12 @@ void preload_bulk_record_tracked( else if (size_change_is_definitive(ce, st, changed)) state = PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED; else - state = PRELOAD_BULK_TRACKED_CONTENT_CHECK; - record_tracked_state(worker, pos, state); + state = verify_content_at( + worker, pos, parent_fd, basename, st, + observed_has_platform_identity, &stat_data, + &has_stat_update); + if (record_tracked_state(worker, pos, state) && has_stat_update) + record_stat_update(worker, pos, &stat_data); } void preload_bulk_record_tracked_fallback( diff --git a/preload-index-bulk-thread.c b/preload-index-bulk-thread.c index 793bb79f6a670a..6cfc4f8409c45a 100644 --- a/preload-index-bulk-thread.c +++ b/preload-index-bulk-thread.c @@ -2,7 +2,13 @@ #include +#include "attr.h" +#include "clean-status.h" +#include "convert.h" #include "preload-index-bulk.h" +#include "read-cache-ll.h" +#include "semantic-verify-internal.h" +#include "trace2.h" #define PRELOAD_INDEX_BULK_OPEN_FD_CAP 128 #define PRELOAD_INDEX_BULK_OPEN_FD_RESERVE 16 @@ -185,12 +191,77 @@ static void *preload_bulk_worker_main(void *data) static void release_workers(struct preload_bulk_scan *scan) { for (int i = 0; i < scan->threads; i++) { + attr_check_free(scan->workers[i].attr_check); free(scan->workers[i].buffer); + free(scan->workers[i].hash_buffer); + free(scan->workers[i].stat_updates); strbuf_release(&scan->workers[i].path); } FREE_AND_NULL(scan->workers); } +static void prepare_content_verification(struct preload_bulk_scan *scan) +{ + if (!scan->proof_epoch) + return; + + convert_attrs_prepare(scan->istate); + for (int i = 0; i < scan->threads; i++) { + scan->workers[i].attr_check = convert_attrs_check_alloc(); + git_check_attr( + scan->istate, "", scan->workers[i].attr_check); + } + if (!clean_status_proof_epoch_prime_matches( + scan->istate, scan->proof_epoch)) { + for (int i = 0; i < scan->threads; i++) { + attr_check_free(scan->workers[i].attr_check); + scan->workers[i].attr_check = NULL; + } + git_attr_invalidate_all(); + return; + } + for (int i = 0; i < scan->threads; i++) + scan->workers[i].hash_buffer = + xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); + scan->verify_content = 1; + trace2_data_intmax("index", scan->repo, + "preload/bulk_content_verify", 1); +} + +static void collect_stat_updates(struct preload_bulk_scan *scan) +{ + size_t nr = 0; + + for (int i = 0; i < scan->threads; i++) { + struct preload_bulk_worker *worker = &scan->workers[i]; + + for (size_t j = 0; j < worker->stat_updates_nr; j++) { + struct preload_bulk_stat_update *update = + &worker->stat_updates[j]; + + if (update->cache_pos >= scan->istate->cache_nr) + BUG("bulk stat update position out of range"); + if (scan->tracked_state[update->cache_pos] == + PRELOAD_BULK_TRACKED_CLEAN) + nr++; + } + } + ALLOC_ARRAY(scan->stat_updates, nr); + for (int i = 0; i < scan->threads; i++) { + struct preload_bulk_worker *worker = &scan->workers[i]; + + for (size_t j = 0; j < worker->stat_updates_nr; j++) { + struct preload_bulk_stat_update *update = + &worker->stat_updates[j]; + + if (scan->tracked_state[update->cache_pos] != + PRELOAD_BULK_TRACKED_CLEAN) + continue; + scan->stat_updates[scan->stat_updates_nr++] = *update; + } + } +} + int preload_bulk_run_scan(struct preload_bulk_scan *scan, struct preload_bulk_run_result *result) { @@ -207,6 +278,7 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, scan->workers[i].scan = scan; strbuf_init(&scan->workers[i].path, 0); } + prepare_content_verification(scan); FLEX_ALLOC_STR(root_task, path, "."); if (!reserve_open_fd(&scan->queue)) @@ -244,9 +316,11 @@ int preload_bulk_run_scan(struct preload_bulk_scan *scan, result->dirs += worker->dirs; result->entries += worker->entries; result->bulk_calls += worker->bulk_calls; + result->bytes_hashed += worker->bytes_hashed; result->changed_dirs += worker->changed_dirs; result->malformed += worker->malformed; } + collect_stat_updates(scan); result->threads = started_threads; result->untracked_complete = scan->collect_untracked && !scan->queue.untracked_invalid; diff --git a/preload-index-bulk.c b/preload-index-bulk.c index eed468b0d156ca..5756df1faceb90 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -6,6 +6,7 @@ #include "parse.h" #include "preload-index-bulk.h" #include "read-cache-ll.h" +#include "repository.h" #include "trace2.h" struct preload_bulk_untracked_root { @@ -179,11 +180,13 @@ int preload_bulk_collect(struct index_state *istate, int threads, .repo = istate->repo, .istate = istate, .backend = backend, + .proof_epoch = istate->preload_bulk_proof_epoch, .root_fd = -1, .threads = threads, .untracked = STRING_LIST_INIT_DUP, }; struct preload_bulk_run_result run_result = { 0 }; + struct stat root_stat; const char *start_error, *finish_error = NULL; const char *untracked_reason = NULL; int scan_error = -1; @@ -236,6 +239,11 @@ int preload_bulk_collect(struct index_state *istate, int threads, CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); + if (!start_error && scan.proof_epoch && + (scan.root_fd < 0 || fstat(scan.root_fd, &root_stat))) + start_error = "root-stat"; + if (!start_error && scan.proof_epoch) + scan.root_dev = root_stat.st_dev; if (!start_error) { if (scan.collect_untracked) { exclude_proof = exclude_source_proof_create( @@ -296,11 +304,15 @@ int preload_bulk_collect(struct index_state *istate, int threads, } if (clean) { result->tracked_state = scan.tracked_state; + result->stat_updates = scan.stat_updates; + result->stat_updates_nr = scan.stat_updates_nr; result->nr = istate->cache_nr; result->can_skip_unseen_preload = scan.can_skip_unseen_preload; result->untracked_complete = run_result.untracked_complete; scan.tracked_state = NULL; + scan.stat_updates = NULL; + scan.stat_updates_nr = 0; } backend->release(&scan); @@ -320,12 +332,14 @@ int preload_bulk_collect(struct index_state *istate, int threads, exclude_source_proof_release(exclude_proof); } free(scan.tracked_state); + free(scan.stat_updates); return clean ? 0 : -1; } void preload_bulk_result_release(struct preload_bulk_result *result) { FREE_AND_NULL(result->tracked_state); + FREE_AND_NULL(result->stat_updates); string_list_clear(&result->untracked, 0); memset(result, 0, sizeof(*result)); } diff --git a/preload-index-bulk.h b/preload-index-bulk.h index c6c862dbb8136c..3a7d0ef84c1c05 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -3,11 +3,16 @@ #include "git-compat-util.h" #include "preload-index.h" +#include "statinfo.h" #include "strbuf.h" #include "string-list.h" #include "thread-utils.h" struct dir_struct; +struct attr_check; +struct clean_status_proof_epoch; +struct index_state; +struct repository; struct preload_bulk_untracked_root; struct preload_bulk_dir_identity { @@ -49,10 +54,16 @@ struct preload_bulk_worker { struct preload_bulk_scan *scan; pthread_t thread; void *buffer; + void *hash_buffer; + struct attr_check *attr_check; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; + size_t stat_updates_alloc; struct strbuf path; uint64_t dirs; uint64_t entries; uint64_t bulk_calls; + uint64_t bytes_hashed; uint64_t changed_dirs; uint64_t malformed; unsigned started : 1; @@ -87,13 +98,18 @@ struct preload_bulk_scan { struct preload_bulk_queue queue; struct preload_bulk_worker *workers; unsigned char *tracked_state; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; + struct clean_status_proof_epoch *proof_epoch; struct dir_struct *exclude_dir; pthread_mutex_t exclude_mutex; struct preload_bulk_untracked_root *untracked_roots; struct string_list untracked; int root_fd; int threads; + dev_t root_dev; unsigned collect_untracked : 1; + unsigned verify_content : 1; unsigned case_insensitive : 1; unsigned can_skip_unseen_preload : 1; }; @@ -104,12 +120,15 @@ struct preload_bulk_run_result { uint64_t bulk_calls; uint64_t changed_dirs; uint64_t malformed; + uint64_t bytes_hashed; int threads; unsigned untracked_complete : 1; }; struct preload_bulk_result { unsigned char *tracked_state; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; size_t nr; const char *outcome; const char *reason; @@ -120,6 +139,11 @@ struct preload_bulk_result { unsigned untracked_complete : 1; }; +struct preload_bulk_stat_update { + uint32_t cache_pos; + struct stat_data stat_data; +}; + void preload_bulk_schedule_directory( struct preload_bulk_worker *worker, int parent_fd, const struct preload_bulk_dir_identity *parent_identity, @@ -134,7 +158,9 @@ int preload_bulk_index_pos_has_tracked_descendants( int preload_bulk_index_entry_is_gitlink(struct preload_bulk_scan *scan, int pos); void preload_bulk_record_tracked( - struct preload_bulk_worker *worker, int pos, const struct stat *st); + struct preload_bulk_worker *worker, int pos, int parent_fd, + const char *basename, const struct stat *st, + int observed_has_platform_identity); void preload_bulk_record_tracked_fallback( struct preload_bulk_worker *worker, int pos); void preload_bulk_record_tracked_descendants_fallback( diff --git a/preload-index.c b/preload-index.c index d7c7f99896c28e..a82063e8fd4149 100644 --- a/preload-index.c +++ b/preload-index.c @@ -47,6 +47,7 @@ struct thread_data { struct progress_data *progress; #ifdef HAVE_PRELOAD_INDEX_BULK const unsigned char *bulk_state; + unsigned bulk_provider_pending : 1; #endif int offset, nr; int t2_nr_lstat; @@ -90,7 +91,9 @@ static void *preload_thread(void *_data) #ifdef HAVE_PRELOAD_INDEX_BULK if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK || state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED || - state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) + state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED || + (p->bulk_provider_pending && + state == PRELOAD_BULK_TRACKED_CLEAN)) continue; #endif if (p->progress && !(nr & 31)) { @@ -127,6 +130,13 @@ static void *preload_thread(void *_data) } #ifdef HAVE_PRELOAD_INDEX_BULK +struct preload_bulk_pending { + unsigned char *tracked_state; + struct preload_bulk_stat_update *stat_updates; + size_t stat_updates_nr; + unsigned provider : 1; +}; + static int stat_data_is_zero(const struct stat_data *sd) { return !sd->sd_ctime.sec && @@ -140,21 +150,24 @@ static int stat_data_is_zero(const struct stat_data *sd) !sd->sd_size; } -static int preload_bulk_entry_is_useful(const struct cache_entry *ce) +static int preload_bulk_entry_is_useful(const struct cache_entry *ce, + int allow_zero_stat) { return preload_entry_needs_stat(ce) && !ce_intent_to_add(ce) && !(ce->ce_flags & (CE_VALID | CE_REMOVE)) && (S_ISREG(ce->ce_mode) || S_ISLNK(ce->ce_mode)) && - !stat_data_is_zero(&ce->ce_stat_data); + (allow_zero_stat || !stat_data_is_zero(&ce->ce_stat_data)); } -static size_t preload_bulk_useful_candidates(struct index_state *index) +static size_t preload_bulk_useful_candidates(struct index_state *index, + int allow_zero_stat) { size_t useful = 0; for (size_t i = 0; i < index->cache_nr; i++) - if (preload_bulk_entry_is_useful(index->cache[i])) + if (preload_bulk_entry_is_useful( + index->cache[i], allow_zero_stat)) useful++; return useful; } @@ -162,6 +175,7 @@ static size_t preload_bulk_useful_candidates(struct index_state *index) static size_t preload_bulk_apply_result( struct index_state *index, struct preload_bulk_result *result, + int defer_all, int *has_deferred) { size_t applied = 0; @@ -181,7 +195,7 @@ static size_t preload_bulk_apply_result( */ if (result->can_skip_unseen_preload && state == PRELOAD_BULK_TRACKED_UNSEEN && - preload_bulk_entry_is_useful(ce)) { + preload_bulk_entry_is_useful(ce, defer_all)) { state = PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED; result->tracked_state[i] = state; } @@ -190,9 +204,14 @@ static size_t preload_bulk_apply_result( state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) && preload_entry_needs_stat(ce)) *has_deferred = 1; + if (defer_all && state == PRELOAD_BULK_TRACKED_CLEAN && + preload_entry_needs_stat(ce)) + *has_deferred = 1; if (state != PRELOAD_BULK_TRACKED_CLEAN) continue; - if (!preload_bulk_entry_is_useful(ce)) + if (!preload_bulk_entry_is_useful(ce, defer_all)) + continue; + if (defer_all) continue; ce_mark_uptodate(ce); mark_fsmonitor_valid(index, ce); @@ -263,6 +282,9 @@ static void preload_bulk_trace_result( result->run.entries); trace2_data_intmax("index", index->repo, "preload/bulk_calls", result->run.bulk_calls); + trace2_data_intmax("index", index->repo, + "preload/bulk_bytes_hashed", + result->run.bytes_hashed); trace2_data_intmax("index", index->repo, "preload/bulk_workers", result->run.threads); trace2_data_intmax("index", index->repo, @@ -283,47 +305,72 @@ static void preload_bulk_trace_result( result->untracked.nr); } -static unsigned char *preload_bulk_try(struct index_state *index) +static int preload_bulk_config_enabled(struct index_state *index) +{ + int enabled = 0; + int control; + + control = git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", -1); + if (control < 0) + repo_config_get_bool(index->repo, "core.preloadindexbulk", + &enabled); + else + enabled = control; + return enabled; +} + +static void preload_bulk_try(struct index_state *index, + unsigned int refresh_flags, + struct preload_bulk_pending *pending) { struct preload_bulk_result result = { 0 }; - unsigned char *tracked_state = NULL; size_t useful; size_t applied = 0; + int provider = !!index->preload_bulk_proof_epoch; int has_deferred = 0; - int enabled = 0; - int control, threads; + int threads; /* * Let the test variable override configuration without bypassing * any of the proof checks. */ - control = git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", -1); - if (control < 0) - repo_config_get_bool(index->repo, "core.preloadindexbulk", - &enabled); - else - enabled = control; - if (!enabled || - fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_DISABLED || + if (!preload_bulk_config_enabled(index) || !preload_bulk_available()) - return NULL; - useful = preload_bulk_useful_candidates(index); + return; + if (provider) { + if (!(refresh_flags & REFRESH_DEFER_BULK_DIRTY) || + fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_IPC || + !fsmonitor_pending_token_from_provider(index)) + return; + } else if (fsm_settings__get_mode(index->repo) != + FSMONITOR_MODE_DISABLED) { + return; + } + useful = preload_bulk_useful_candidates(index, provider); trace2_data_intmax("index", index->repo, "preload/bulk_useful", useful); trace2_data_intmax("index", index->repo, "preload/bulk_cache_nr", index->cache_nr); - if (!useful) - return NULL; + if (!useful && !index->preload_untracked) + return; threads = preload_bulk_threads(useful); trace2_region_enter("index", "preload/bulk", index->repo); if (!preload_bulk_collect(index, threads, &result)) { applied = preload_bulk_apply_result(index, &result, + provider, &has_deferred); } preload_bulk_trace_result(index, &result, applied); if (has_deferred) { - tracked_state = result.tracked_state; + pending->tracked_state = result.tracked_state; result.tracked_state = NULL; + pending->provider = provider; + if (provider) { + pending->stat_updates = result.stat_updates; + pending->stat_updates_nr = result.stat_updates_nr; + result.stat_updates = NULL; + result.stat_updates_nr = 0; + } } if (result.untracked_complete && index->preload_untracked) { *index->preload_untracked = result.untracked; @@ -333,26 +380,126 @@ static unsigned char *preload_bulk_try(struct index_state *index) } trace2_region_leave("index", "preload/bulk", index->repo); preload_bulk_result_release(&result); - return tracked_state; } static void preload_bulk_finish_state(struct index_state *index, - unsigned char **state, + struct preload_bulk_pending *pending, unsigned int refresh_flags) { - if ((refresh_flags & REFRESH_DEFER_BULK_DIRTY) && *state) { - index->preload_bulk_tracked_state = *state; + if ((refresh_flags & REFRESH_DEFER_BULK_DIRTY) && + pending->tracked_state) { + index->preload_bulk_tracked_state = pending->tracked_state; index->preload_bulk_tracked_nr = index->cache_nr; - *state = NULL; + index->preload_bulk_stat_updates = pending->stat_updates; + index->preload_bulk_stat_updates_nr = + pending->stat_updates_nr; + index->preload_bulk_provider_pending = pending->provider; + memset(pending, 0, sizeof(*pending)); } - FREE_AND_NULL(*state); + free(pending->tracked_state); + free(pending->stat_updates); +} + +static int compare_stat_update(const void *va, const void *vb) +{ + const struct preload_bulk_stat_update *a = va; + const struct preload_bulk_stat_update *b = vb; + + return a->cache_pos < b->cache_pos ? -1 : + a->cache_pos > b->cache_pos ? 1 : 0; } #endif void preload_index_bulk_result_clear(struct index_state *index) { FREE_AND_NULL(index->preload_bulk_tracked_state); + FREE_AND_NULL(index->preload_bulk_stat_updates); index->preload_bulk_tracked_nr = 0; + index->preload_bulk_stat_updates_nr = 0; + index->preload_bulk_provider_pending = 0; +} + +int preload_index_bulk_can_close_provider(struct index_state *index) +{ +#ifdef HAVE_PRELOAD_INDEX_BULK + int core_preload_index = 1; + + repo_config_get_bool(index->repo, "core.preloadindex", + &core_preload_index); + return core_preload_index && + preload_bulk_config_enabled(index) && + preload_bulk_available() && + index->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(index->repo) == FSMONITOR_MODE_IPC && + fsmonitor_pending_token_from_provider(index) && + (preload_bulk_useful_candidates(index, 1) || + index->preload_untracked); +#else + (void)index; + return 0; +#endif +} + +int preload_index_bulk_result_accept(struct index_state *index) +{ +#ifdef HAVE_PRELOAD_INDEX_BULK + size_t update_nr = 0; + int applied = 0; + + if (!index->preload_bulk_provider_pending) + return 0; + if (!index->preload_bulk_tracked_state || + index->preload_bulk_tracked_nr != index->cache_nr) + return -1; + + QSORT(index->preload_bulk_stat_updates, + index->preload_bulk_stat_updates_nr, compare_stat_update); + for (size_t i = 0; i < index->preload_bulk_stat_updates_nr; i++) { + struct preload_bulk_stat_update *update = + &index->preload_bulk_stat_updates[i]; + + if (update->cache_pos >= index->cache_nr || + index->preload_bulk_tracked_state[update->cache_pos] != + PRELOAD_BULK_TRACKED_CLEAN || + (i && update[-1].cache_pos == update->cache_pos)) + return -1; + } + + for (size_t i = 0; i < index->cache_nr; i++) { + struct cache_entry *ce = index->cache[i]; + struct preload_bulk_stat_update *update = NULL; + + if (index->preload_bulk_tracked_state[i] != + PRELOAD_BULK_TRACKED_CLEAN) + continue; + if (update_nr < index->preload_bulk_stat_updates_nr && + index->preload_bulk_stat_updates[update_nr].cache_pos == i) + update = + &index->preload_bulk_stat_updates[update_nr++]; + if (update && + memcmp(&ce->ce_stat_data, &update->stat_data, + sizeof(ce->ce_stat_data))) { + memcpy(&ce->ce_stat_data, &update->stat_data, + sizeof(ce->ce_stat_data)); + ce->ce_flags |= CE_UPDATE_IN_BASE; + index->cache_changed |= CE_ENTRY_CHANGED; + } + ce_mark_uptodate(ce); + mark_fsmonitor_valid(index, ce); + applied++; + } + if (update_nr != index->preload_bulk_stat_updates_nr) + BUG("validated bulk stat update was not applied"); + + FREE_AND_NULL(index->preload_bulk_stat_updates); + index->preload_bulk_stat_updates_nr = 0; + index->preload_bulk_provider_pending = 0; + trace2_data_intmax("index", index->repo, + "preload/bulk_provider_applied", applied); +#else + (void)index; +#endif + return 0; } void preload_index(struct index_state *index, @@ -363,7 +510,7 @@ void preload_index(struct index_state *index, struct thread_data data[MAX_PARALLEL]; struct progress_data pd; #ifdef HAVE_PRELOAD_INDEX_BULK - unsigned char *bulk_state = NULL; + struct preload_bulk_pending bulk = { 0 }; #endif int t2_sum_lstat = 0; int core_preload_index = 1; @@ -379,11 +526,11 @@ void preload_index(struct index_state *index, #ifdef HAVE_PRELOAD_INDEX_BULK if (!pathspec || !pathspec->nr) - bulk_state = preload_bulk_try(index); + preload_bulk_try(index, refresh_flags, &bulk); #endif if (!HAVE_THREADS) { #ifdef HAVE_PRELOAD_INDEX_BULK - preload_bulk_finish_state(index, &bulk_state, refresh_flags); + preload_bulk_finish_state(index, &bulk, refresh_flags); #endif return; } @@ -393,7 +540,7 @@ void preload_index(struct index_state *index, threads = 2; if (threads < 2) { #ifdef HAVE_PRELOAD_INDEX_BULK - preload_bulk_finish_state(index, &bulk_state, refresh_flags); + preload_bulk_finish_state(index, &bulk, refresh_flags); #endif return; } @@ -421,7 +568,9 @@ void preload_index(struct index_state *index, p->index = index; #ifdef HAVE_PRELOAD_INDEX_BULK - p->bulk_state = bulk_state ? bulk_state + offset : NULL; + p->bulk_state = bulk.tracked_state ? + bulk.tracked_state + offset : NULL; + p->bulk_provider_pending = bulk.provider; #endif if (pathspec) copy_pathspec(&p->pathspec, pathspec); @@ -443,7 +592,7 @@ void preload_index(struct index_state *index, } stop_progress(&pd.progress); #ifdef HAVE_PRELOAD_INDEX_BULK - preload_bulk_finish_state(index, &bulk_state, refresh_flags); + preload_bulk_finish_state(index, &bulk, refresh_flags); #endif if (pathspec) { diff --git a/preload-index.h b/preload-index.h index bb6deb6130cc2f..7f7fdcca28acb2 100644 --- a/preload-index.h +++ b/preload-index.h @@ -21,5 +21,7 @@ int repo_read_index_preload(struct repository *, const struct pathspec *pathspec, unsigned refresh_flags); void preload_index_bulk_result_clear(struct index_state *index); +int preload_index_bulk_can_close_provider(struct index_state *index); +int preload_index_bulk_result_accept(struct index_state *index); #endif /* PRELOAD_INDEX_H */ diff --git a/read-cache-ll.h b/read-cache-ll.h index 32496bade97a6f..5a50c0d6dd4bbf 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -31,6 +31,9 @@ struct cache_entry { char name[FLEX_ARRAY]; /* more */ }; +struct clean_status_proof_epoch; +struct preload_bulk_stat_update; + #define CE_STAGEMASK (0x3000) #define CE_EXTENDED (0x4000) #define CE_VALID (0x8000) @@ -191,7 +194,8 @@ struct index_state { fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, fsmonitor_pending_token_from_provider : 1, - preload_untracked_complete : 1; + preload_untracked_complete : 1, + preload_bulk_provider_pending : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; @@ -199,6 +203,10 @@ struct index_state { struct untracked_cache *untracked; unsigned char *preload_bulk_tracked_state; size_t preload_bulk_tracked_nr; + struct preload_bulk_stat_update *preload_bulk_stat_updates; + size_t preload_bulk_stat_updates_nr; + /* Borrowed only while refresh_index() performs a provider scan. */ + struct clean_status_proof_epoch *preload_bulk_proof_epoch; /* Borrowed for the duration of preload_index(). */ struct string_list *preload_untracked; char *fsmonitor_last_update; diff --git a/read-cache.c b/read-cache.c index 20f45870350cd5..db8b94a4c26bd3 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1626,6 +1626,9 @@ int refresh_index(struct index_state *istate, unsigned int flags, unsigned char state = istate->preload_bulk_tracked_state[i]; + if (istate->preload_bulk_provider_pending && + state == PRELOAD_BULK_TRACKED_CLEAN) + continue; if (state == PRELOAD_BULK_TRACKED_CONTENT_CHECK) { ce->ce_flags |= CE_CONTENT_CHECK_REQUIRED; continue; @@ -2542,6 +2545,7 @@ void release_index(struct index_state *istate) free(istate->fsmonitor_untracked_token); clean_status_release(istate); free(istate->preload_bulk_tracked_state); + free(istate->preload_bulk_stat_updates); free(istate->cache); discard_split_index(istate); free_untracked_cache(istate->untracked); diff --git a/semantic-verify-file.c b/semantic-verify-file.c index 811b9fc43355fc..40d95c2abb67cb 100644 --- a/semantic-verify-file.c +++ b/semantic-verify-file.c @@ -117,9 +117,32 @@ int semantic_verify_classify_entry(struct index_state *istate, } #if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static int observed_stat_equal(const struct stat *a, const struct stat *b, + int has_platform_identity) +{ + if (a->st_dev != b->st_dev || a->st_ino != b->st_ino || + a->st_mode != b->st_mode || a->st_nlink != b->st_nlink || + a->st_uid != b->st_uid || a->st_gid != b->st_gid || + a->st_size != b->st_size || a->st_mtime != b->st_mtime || + ST_MTIME_NSEC(*a) != ST_MTIME_NSEC(*b) || + a->st_ctime != b->st_ctime || + ST_CTIME_NSEC(*a) != ST_CTIME_NSEC(*b)) + return 0; +#ifdef __APPLE__ + if (has_platform_identity && + (a->st_birthtimespec.tv_sec != b->st_birthtimespec.tv_sec || + a->st_birthtimespec.tv_nsec != b->st_birthtimespec.tv_nsec || + a->st_gen != b->st_gen)) + return 0; +#else + (void)has_platform_identity; +#endif + return 1; +} + void semantic_verify_file_at(int parent_fd, const char *basename, const struct stat *observed, - dev_t root_dev, + int observed_has_platform_identity, dev_t root_dev, const struct cache_entry *ce, struct repository *repo, void *buffer, struct semantic_verify_file_result *result) @@ -152,7 +175,8 @@ void semantic_verify_file_at(int parent_fd, const char *basename, if (fstat(fd, &fd_before)) goto unstable; if (fd_before.st_dev != root_dev || - !path_namespace_stat_equal(&path_before, &fd_before)) { + !observed_stat_equal(&path_before, &fd_before, + observed_has_platform_identity)) { errno = EAGAIN; goto unstable; } @@ -213,7 +237,7 @@ void semantic_verify_file(struct semantic_verify_root *root, SEMANTIC_VERIFY_RAW_MODIFIED : SEMANTIC_VERIFY_ERROR; return; } - semantic_verify_file_at(parent_fd, basename, &path_before, + semantic_verify_file_at(parent_fd, basename, &path_before, 1, root->stat.st_dev, ce, repo, buffer, result); } #else @@ -228,7 +252,7 @@ static void semantic_verify_file_unavailable( void semantic_verify_file_at( int parent_fd UNUSED, const char *basename UNUSED, const struct stat *observed UNUSED, - dev_t root_dev UNUSED, + int observed_has_platform_identity UNUSED, dev_t root_dev UNUSED, const struct cache_entry *ce UNUSED, struct repository *repo UNUSED, void *buffer UNUSED, struct semantic_verify_file_result *result) diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index b35af3ff93b271..e784cf4d78f955 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -82,7 +82,7 @@ void semantic_verify_file(struct semantic_verify_root *root, struct semantic_verify_file_result *result); void semantic_verify_file_at(int parent_fd, const char *basename, const struct stat *observed, - dev_t root_dev, + int observed_has_platform_identity, dev_t root_dev, const struct cache_entry *ce, struct repository *repo, void *buffer, struct semantic_verify_file_result *result); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 002d5f1a83c1fc..66c5a3a28bfdf3 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -594,6 +594,37 @@ prepare_builtin_closure_repo () { ) } +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'builtin closure initializes a new untracked cache' ' + test_when_finished "rm -rf builtin-closure-new-uc" && + test_create_repo builtin-closure-new-uc && + ( + cd builtin-closure-new-uc && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 1 .git/read-directory && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSUC .git/index + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin clean closure publishes its proof' ' test_when_finished "rm -rf builtin-closure-clean" && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index a72cd29af06487..7587626dc0cd7d 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1623,4 +1623,69 @@ test_expect_success MACOS 'worktree binding rejects same-gitdir aliases' ' git -C binding-a fsmonitor--daemon stop ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'configured unused filters establish scoped history' ' + test_when_finished "rm -rf configured-filter" && + test_create_repo configured-filter && + ( + cd configured-filter && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config filter.demo.clean cat && + git config core.preloadIndexBulk true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/filter-scope.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/filter-scope.out && + test_must_be_empty .git/filter-scope.out && + test_trace2_data status semantic_verify/prepared 1 \ + <.git/filter-scope.trace && + ! test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/filter-scope.trace && + test_trace2_data semantic_verify active-filters 0 \ + <.git/filter-scope.trace && + test_trace2_data semantic_verify filter-scope-checked 1 \ + <.git/filter-scope.trace && + test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/filter-scope.trace && + test_grep FSCF .git/index && + + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/warm-filter-scope.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/warm-filter-scope.out && + test_must_be_empty .git/warm-filter-scope.out && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/warm-filter-scope.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/warm-filter-scope.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <.git/warm-filter-scope.trace && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <.git/warm-filter-scope.trace && + + test_write_lines "tracked filter=demo" >.git/info/attributes && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/active-filter.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/active-filter.out && + test_must_be_empty .git/active-filter.out && + test_trace2_data status semantic_verify/prepared 1 \ + <.git/active-filter.trace && + test_trace2_data semantic_verify active-filters 1 \ + <.git/active-filter.trace && + test_trace2_data semantic_verify filter-scope-rejected 1 \ + <.git/active-filter.trace && + ! test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/active-filter.trace + ) +' + test_done diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index b726b7559b9b3d..b10d61a3360a01 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -107,6 +107,47 @@ configured_bulk_status () { status --porcelain=v2 >"$output" } +setup_provider_proof_repo () { + setup_repo "$1" && + ( + cd "$1" && + git config core.trustctime false && + git config core.checkStat minimal && + mtime=$(test-tool chmtime --get root) && + printf "dirt\n" >root && + test-tool chmtime =$mtime root && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid root && + test_grep ! FSCF .git/index + ) +} + +setup_provider_proof_repo_with_untracked_cache () { + setup_repo "$1" && + ( + cd "$1" && + git config core.untrackedCache true && + git status --porcelain=2 >.git/prime && + test_must_be_empty .git/prime && + test_grep UNTR .git/index && + git config core.trustctime false && + git config core.checkStat minimal && + mtime=$(test-tool chmtime --get root) && + printf "dirt\n" >root && + test-tool chmtime =$mtime root && + test_write_lines visible >visible && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid root && + test_grep ! FSCF .git/index + ) +} + test_expect_success 'bulk preload follows its configuration' ' setup_repo opt-in && GIT_OPTIONAL_LOCKS=0 \ @@ -144,6 +185,79 @@ test_expect_success 'bulk preload waits for fsmonitor provider closure' ' test_grep ! "\"key\":\"preload/bulk_result\"" fsmonitor.trace ' +test_expect_success 'provider closure accepts bulk content proofs' ' + setup_provider_proof_repo provider-proof && + ( + cd provider-proof && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_bytes_hashed \ + "[1-9][0-9]*" \ + <.git/status.trace && + test_trace2_data index preload/bulk_provider_applied 7 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index + ) +' + +test_expect_success \ + 'provider bulk preserves an existing untracked-cache binding' ' + setup_provider_proof_repo_with_untracked_cache provider-proof-uc && + ( + cd provider-proof-uc && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_grep "^? visible$" .git/actual && + ! test_trace2_data index preload/bulk_untracked_complete 1 \ + <.git/status.trace && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + .git/status.trace >.git/read-directory && + test_line_count = 1 .git/read-directory && + test_grep FSUC .git/index + ) +' + +test_expect_success 'provider failure discards bulk content proofs' ' + setup_provider_proof_repo provider-failure && + ( + cd provider-failure && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + ! test_trace2_data index preload/bulk_provider_applied \ + "[0-9][0-9]*" <.git/status.trace && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSCF .git/index + ) +' + cleanup_race () { exec 9>&- if test -n "$status_pid" diff --git a/t/t7532-preload-index-linux.sh b/t/t7532-preload-index-linux.sh index 2941448326397a..4d4063809f884c 100755 --- a/t/t7532-preload-index-linux.sh +++ b/t/t7532-preload-index-linux.sh @@ -39,6 +39,27 @@ setup_repo () { git -C "$repo" update-index --refresh } +setup_provider_proof_repo () { + setup_repo "$1" && + ( + cd "$1" && + git config core.trustctime false && + git config core.checkStat minimal && + size=$(test_file_size root) && + mtime=$(test-tool chmtime --get root) && + printf "dirt\n" >root && + test "$(test_file_size root)" = "$size" && + test-tool chmtime =$mtime root && + test "$(test-tool chmtime --get root)" = "$mtime" && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid root && + test_grep ! FSCF .git/index + ) +} + test_lazy_prereq LINUX_BULK_PRELOAD ' setup_repo linux-bulk-prereq && GIT_OPTIONAL_LOCKS=0 \ @@ -156,6 +177,60 @@ test_expect_success 'clean entries are published without lstat' ' check_lstat_data clean.trace 0 ' +test_expect_success 'provider closure accepts bulk content proofs' ' + ( + sane_unset GIT_TEST_SPLIT_INDEX && + setup_provider_proof_repo provider-proof && + cd provider-proof && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_bytes_hashed \ + "[1-9][0-9]*" <.git/status.trace && + test_trace2_data index preload/bulk_provider_applied 3 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index + ) +' + +test_expect_success 'provider failure discards bulk content proofs' ' + ( + sane_unset GIT_TEST_SPLIT_INDEX && + setup_provider_proof_repo provider-failure && + cd provider-failure && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* root$" .git/actual && + test_trace2_data status semantic_verify/bulk_scan 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_content_verify 1 \ + <.git/status.trace && + test_trace2_data index preload/bulk_bytes_hashed \ + "[1-9][0-9]*" <.git/status.trace && + ! test_trace2_data index preload/bulk_provider_applied \ + "[0-9][0-9]*" <.git/status.trace && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep ! FSCF .git/index + ) +' + test_expect_success 'tracked files ignore a directory type hint' ' setup_repo dirent-file && ordinary_status dirent-file expect && diff --git a/wt-status.c b/wt-status.c index 38a2743b9db34d..3c5e0facbf473b 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1048,8 +1048,17 @@ static int wt_status_collect_untracked_1( return used_untracked_cache; } +static int wt_status_can_use_bulk_provider( + struct wt_status *s, unsigned int refresh_flags) +{ + return !s->show_ignored_mode && !s->pathspec.nr && + !clean_status_filter_scope_needs_validation(s->repo->index) && + (refresh_flags & REFRESH_DEFER_BULK_DIRTY) && + preload_index_bulk_can_close_provider(s->repo->index); +} + static struct semantic_verify_proof *wt_status_prepare_semantic_verify( - struct wt_status *s) + struct wt_status *s, unsigned int refresh_flags) { struct index_state *istate = s->repo->index; struct semantic_verify_options options = SEMANTIC_VERIFY_OPTIONS_INIT; @@ -1064,6 +1073,11 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( !fsmonitor_pending_token_from_provider(istate) || !clean_status_fsmonitor_semantic_adoption_needed(istate)) return NULL; + if (wt_status_can_use_bulk_provider(s, refresh_flags)) { + trace2_data_intmax("status", s->repo, + "semantic_verify/bulk_scan", 1); + return NULL; + } options.require_proof_epoch = 1; options.validate_filter_scope = @@ -1096,7 +1110,9 @@ struct wt_status_token_closure { unsigned int refresh_flags; int require_untracked; int can_prime; + int use_bulk_provider; int untracked_ready; + int untracked_proof_complete; struct string_list staged_untracked; struct string_list staged_ignored; int staged_untracked_ready; @@ -1191,18 +1207,38 @@ static void wt_status_discard_semantic_verify( static void wt_status_refresh_for_token( struct wt_status *s, unsigned int refresh_flags, - struct clean_status_proof_epoch **epoch, int *refresh_result) + struct clean_status_proof_epoch **epoch, int use_bulk_provider, + int *refresh_result) { struct index_state *istate = s->repo->index; clean_status_release_proof_epoch(*epoch); *epoch = clean_status_capture_proof_epoch( istate, s->attr_source_snapshot, 0); + if (*epoch && use_bulk_provider) + istate->preload_bulk_proof_epoch = *epoch; if (*epoch) { *refresh_result |= refresh_index( istate, refresh_flags | REFRESH_IN_PROOF_EPOCH, &s->pathspec, NULL, NULL); } + istate->preload_bulk_proof_epoch = NULL; +} + +static int wt_status_untracked_cache_valid( + const struct wt_status_token_closure *closure) +{ + const struct index_state *istate = closure->status->repo->index; + + return closure->untracked_ready && + istate->untracked && istate->untracked->root; +} + +static void wt_status_record_bulk_untracked( + struct wt_status_token_closure *closure) +{ + if (closure->status->repo->index->preload_untracked_complete) + closure->untracked_proof_complete = 1; } static int wt_status_close_ordinary_fsmonitor_token( @@ -1222,6 +1258,7 @@ static int wt_status_close_ordinary_fsmonitor_token( if (reliable_stat) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, + closure->use_bulk_provider, &closure->refresh_result); if (!scan_epoch) return 0; @@ -1230,8 +1267,12 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); } - if (!closure->untracked_ready && closure->can_prime) { - closure->untracked_ready = wt_status_stage_untracked(closure); + wt_status_record_bulk_untracked(closure); + if (!closure->untracked_proof_complete && closure->can_prime) { + closure->untracked_ready = + wt_status_stage_untracked(closure); + closure->untracked_proof_complete = + closure->untracked_ready; if (closure->queries) trace2_data_intmax( "status", s->repo, @@ -1248,7 +1289,8 @@ static int wt_status_close_ordinary_fsmonitor_token( break; closure->queries++; result = fsmonitor_query_pending_token( - istate, closure->untracked_ready); + istate, + wt_status_untracked_cache_valid(closure)); if (result == FSMONITOR_TOKEN_CLEAN) { if (reliable_stat && !clean_status_proof_epoch_matches( @@ -1256,19 +1298,27 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); break; } - if (closure->untracked_ready || + if (closure->untracked_proof_complete || !closure->require_untracked) { + if (preload_index_bulk_result_accept(istate) < 0) + break; if (reliable_stat) clean_status_mark_fsmonitor_config_valid( istate, istate->fsmonitor_last_update_pending); clean_status_release_proof_epoch(scan_epoch); fsmonitor_accept_pending_token( - istate, closure->untracked_ready); + istate, + closure->untracked_proof_complete, + wt_status_untracked_cache_valid( + closure)); return 1; } break; } + wt_status_discard_staged_untracked(closure); + closure->untracked_proof_complete = + !closure->require_untracked || !istate->untracked; clean_status_release_proof_epoch(scan_epoch); scan_epoch = NULL; if (!fsmonitor_token_requires_rescan(result)) @@ -1281,6 +1331,7 @@ static int wt_status_close_ordinary_fsmonitor_token( if (reliable_stat) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, + closure->use_bulk_provider, &closure->refresh_result); if (!scan_epoch) break; @@ -1289,9 +1340,14 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); } - if (closure->can_prime) + wt_status_record_bulk_untracked(closure); + if (!closure->untracked_proof_complete && + closure->can_prime) { closure->untracked_ready = wt_status_stage_untracked(closure); + closure->untracked_proof_complete = + closure->untracked_ready; + } } clean_status_release_proof_epoch(scan_epoch); return 0; @@ -1312,7 +1368,8 @@ wt_status_close_semantic_fsmonitor_token( struct index_state *istate = s->repo->index; enum fsmonitor_token_result result; int defer_untracked = - closure->can_prime && !closure->untracked_ready; + closure->can_prime && + !closure->untracked_proof_complete; int applied; if (!semantic_verify_start_token_is_current(istate, *proof)) { @@ -1324,7 +1381,8 @@ wt_status_close_semantic_fsmonitor_token( /* The first query closes the tracked scan and its semantic proof. */ closure->queries++; result = fsmonitor_query_pending_token( - istate, defer_untracked ? 0 : closure->untracked_ready); + istate, defer_untracked ? 0 : + wt_status_untracked_cache_valid(closure)); if (result != FSMONITOR_TOKEN_CLEAN) { wt_status_discard_semantic_verify( s, proof, "token-reset"); @@ -1352,7 +1410,10 @@ wt_status_close_semantic_fsmonitor_token( } if (defer_untracked) { - closure->untracked_ready = wt_status_stage_untracked(closure); + closure->untracked_ready = + wt_status_stage_untracked(closure); + closure->untracked_proof_complete = + closure->untracked_ready; trace2_data_intmax( "status", s->repo, "fsmonitor_token/untracked-after-semantic", @@ -1364,11 +1425,14 @@ wt_status_close_semantic_fsmonitor_token( /* A second query closes the subsequent untracked scan. */ closure->queries++; result = fsmonitor_query_pending_token( - istate, closure->untracked_ready); + istate, + wt_status_untracked_cache_valid(closure)); if (result != FSMONITOR_TOKEN_CLEAN) { + wt_status_discard_staged_untracked(closure); untracked_cache_invalidate_all(istate); fsmonitor_invalidate_semantics(istate); closure->untracked_ready = 0; + closure->untracked_proof_complete = 0; wt_status_discard_semantic_verify( s, proof, "token-reset"); if (fsmonitor_token_requires_rescan(result)) @@ -1392,7 +1456,9 @@ wt_status_close_semantic_fsmonitor_token( istate, istate->fsmonitor_last_update_pending); semantic_verify_proof_clear(*proof); *proof = NULL; - fsmonitor_accept_pending_token(istate, closure->untracked_ready); + fsmonitor_accept_pending_token( + istate, closure->untracked_proof_complete, + wt_status_untracked_cache_valid(closure)); return WT_STATUS_TOKEN_CLOSURE_ACCEPTED; } @@ -1438,11 +1504,15 @@ static int wt_status_close_fsmonitor_token( } closure.can_prime = require_untracked && - istate->untracked && istate->untracked->root && + istate->untracked && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode; + closure.use_bulk_provider = + wt_status_can_use_bulk_provider(s, refresh_flags); closure.untracked_ready = !istate->untracked || !istate->untracked->root; + closure.untracked_proof_complete = + !require_untracked || !istate->untracked; if (require_untracked && !closure.can_prime && !closure.untracked_ready) BUG("cannot close required untracked scan"); @@ -1471,6 +1541,7 @@ static int wt_status_close_fsmonitor_token( fallback: wt_status_discard_semantic_verify(s, &proof, "fallback"); wt_status_discard_staged_untracked(&closure); + preload_index_bulk_result_clear(istate); fsmonitor_reject_pending_token(istate); if (fstat_is_reliable()) { if (closure.can_prime) @@ -1496,7 +1567,7 @@ int wt_status_refresh_index(struct wt_status *s, wt_status_begin_attr_snapshot(s); refresh_fsmonitor(istate); - proof = wt_status_prepare_semantic_verify(s); + proof = wt_status_prepare_semantic_verify(s, refresh_flags); ret = wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); if (istate->preload_untracked == &s->untracked) { @@ -1581,7 +1652,8 @@ void wt_status_collect(struct wt_status *s) (used_untracked_cache || !s->repo->index->untracked || !s->repo->index->untracked->root)) { if (fsmonitor_pending_token_from_provider(s->repo->index)) - fsmonitor_accept_pending_token(s->repo->index, 1); + fsmonitor_accept_pending_token( + s->repo->index, 1, used_untracked_cache); else fsmonitor_reject_pending_token(s->repo->index); } From 5b506f9318c27b2c21202a54dc142fd88df8de89 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:13:48 -0500 Subject: [PATCH 221/403] exclude: compute stable source-proof digests A live exclude-source proof uses filesystem identity to keep one observation coherent. That identity cannot compare equivalent ignore sources captured by separate status processes: replacing a file with the same contents changes its identity without changing ignore semantics. Hash the existing, validated observations in first-observation order. Frame the digest with its version, source object format, unique source count, path, lookup policy, presence, and content identity. Exclude transient stat identity so an equivalent replacement retains the same semantic digest. Extend the existing exclude-proof unit tests to capture independent proofs across a same-content replacement and repeated observation. The digest is independently testable without issuing a sidecar or changing normal exclude-source validation. Signed-off-by: Taylor Blau --- exclude-source-proof.c | 46 +++++++++++++++++++++++++-- exclude-source-proof.h | 10 ++++++ t/unit-tests/u-exclude-source-proof.c | 32 +++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/exclude-source-proof.c b/exclude-source-proof.c index 022aa5a7ba1d5a..1a2ebd5f87190b 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "exclude-source-proof.h" +#include "hash-framing.h" #include "object-file.h" #include "path-namespace.h" #include "read-cache-ll.h" @@ -8,9 +9,9 @@ #include "trace2.h" /* - * Each entry describes one path/policy observation. Filesystem identities - * are used only to make capture and validation coherent; the durable - * observation is the source's existence and bytes. + * Each entry describes one path/policy observation for replay or a stable + * digest. Filesystem identities make capture and validation coherent; the + * durable observation is the source's existence and bytes. */ struct exclude_source_proof_entry { char *path; @@ -415,6 +416,45 @@ int exclude_source_proof_validate(struct exclude_source_proof *proof) return valid; } +int exclude_source_proof_digest( + struct exclude_source_proof *proof, + const struct git_hash_algo *algo, + struct object_id *oid) +{ + static const char domain[] = "git-exclude-source-proof-digest-v1"; + const struct git_hash_algo *source_algo; + struct git_hash_ctx ctx; + unsigned char count[sizeof(uint64_t)]; + unsigned char format[sizeof(uint32_t)]; + + if (!proof || !algo || !oid || + !exclude_source_proof_validate(proof)) + return -1; + source_algo = proof->istate->repo->hash_algo; + git_hash_init(&ctx, algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + put_be32(format, source_algo->format_id); + hash_length_delimited(&ctx, format, sizeof(format)); + put_be64(count, proof->nr); + hash_length_delimited(&ctx, count, sizeof(count)); + for (size_t i = 0; i < proof->nr; i++) { + const struct exclude_source_proof_entry *entry = + &proof->entries[i]; + unsigned char policy[] = { + entry->nofollow, + entry->exists, + }; + + hash_length_delimited(&ctx, entry->path, + strlen(entry->path)); + hash_length_delimited(&ctx, policy, sizeof(policy)); + hash_length_delimited(&ctx, entry->oid.hash, + entry->exists ? source_algo->rawsz : 0); + } + git_hash_final_oid(oid, &ctx); + return 0; +} + void exclude_source_proof_release(struct exclude_source_proof *proof) { if (!proof) diff --git a/exclude-source-proof.h b/exclude-source-proof.h index e2932f535fb7a4..f1c03b4a3cbf5f 100644 --- a/exclude-source-proof.h +++ b/exclude-source-proof.h @@ -12,7 +12,9 @@ struct exclude_source_capture; struct exclude_source_proof; +struct git_hash_algo; struct index_state; +struct object_id; struct stat; typedef int (*exclude_source_open_parent_fn)(void *data, const char *path); @@ -33,6 +35,14 @@ void exclude_source_capture_record( void exclude_source_capture_error(struct exclude_source_capture *capture); void exclude_source_capture_release(struct exclude_source_capture *capture); int exclude_source_proof_validate(struct exclude_source_proof *proof); +/* + * Hash unique observations in first-observation order. The caller must + * capture them in a deterministic order when comparing across processes. + */ +int exclude_source_proof_digest( + struct exclude_source_proof *proof, + const struct git_hash_algo *algo, + struct object_id *oid); void exclude_source_proof_release(struct exclude_source_proof *proof); #endif /* EXCLUDE_SOURCE_PROOF_H */ diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index 26d5f16e6f2e9c..e579dbcd51247a 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -198,6 +198,37 @@ void test_exclude_source_proof__rejects_conflicting_observations(void) free(parent); } +void test_exclude_source_proof__digest_deduplicates_and_ignores_identity(void) +{ + struct exclude_source_proof *first_proof = + exclude_source_proof_create(&istate, NULL, open_parent); + struct exclude_source_proof *second_proof; + struct object_id first, second; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(first_proof, source); + cl_must_pass(exclude_source_proof_digest( + first_proof, repo.hash_algo, &first)); + + cl_must_pass(unlink(source)); + write_file_buf(source, "content", 7); + second_proof = + exclude_source_proof_create(&istate, NULL, open_parent); + record_file(second_proof, source); + record_file(second_proof, source); + cl_must_pass(exclude_source_proof_digest( + second_proof, repo.hash_algo, &second)); + cl_assert(oideq(&first, &second)); + + exclude_source_proof_release(second_proof); + exclude_source_proof_release(first_proof); + free(source); + free(parent); +} + void test_exclude_source_proof__rejects_open_failure(void) { struct exclude_source_proof *proof = new_proof(); @@ -391,6 +422,7 @@ SKIP_TEST(test_exclude_source_proof__rejects_different_content_replacement) SKIP_TEST(test_exclude_source_proof__accepts_repeated_observation) SKIP_TEST(test_exclude_source_proof__rejects_missing_source_buffer) SKIP_TEST(test_exclude_source_proof__rejects_conflicting_observations) +SKIP_TEST(test_exclude_source_proof__digest_deduplicates_and_ignores_identity) SKIP_TEST(test_exclude_source_proof__rejects_open_failure) SKIP_TEST(test_exclude_source_proof__fails_closed_without_parent_opener) SKIP_TEST(test_exclude_source_proof__honors_nofollow) From 88686488d4708159a8479fedf606ccee56cb94f8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:04:25 -0500 Subject: [PATCH 222/403] status: add a bounded clean-status sidecar format A later status invocation cannot safely reuse an empty result unless its persistent record identifies the exact index and semantic inputs that the original scan proved. Accepting truncated, ambiguous, or forward-versioned records would turn a cache miss into a false clean result. Define the version-one CSTS encoding and serialize index identity in fixed-width network-byte-order fields. Bind the index format, entry count, checksum, HEAD tree, configuration and repository hashes, one exclude digest, and a bounded builtin-provider token. Protect the complete record with the repository's object-format checksum. Reject unknown flags, unsupported index formats, null required object IDs, invalid token bounds or prefixes, bad checksums, truncation, and trailing payload. Add fixed-width identity and sidecar unit coverage for both SHA-1 and SHA-256. Register the new source and unit suite in both Make and Meson. This patch defines and tests the format; it neither writes a sidecar nor changes status dispatch. Signed-off-by: Taylor Blau --- Makefile | 2 + clean-status-identity.c | 29 +++ clean-status-identity.h | 9 + clean-status-sidecar.c | 128 +++++++++++ clean-status-sidecar.h | 35 +++ meson.build | 1 + t/meson.build | 1 + t/unit-tests/u-clean-status-identity.c | 38 ++++ t/unit-tests/u-clean-status-sidecar.c | 287 +++++++++++++++++++++++++ 9 files changed, 530 insertions(+) create mode 100644 clean-status-sidecar.c create mode 100644 clean-status-sidecar.h create mode 100644 t/unit-tests/u-clean-status-sidecar.c diff --git a/Makefile b/Makefile index 9b6f8b5e518fe9..974beec198be26 100644 --- a/Makefile +++ b/Makefile @@ -1136,6 +1136,7 @@ LIB_OBJS += clean-status-history.o LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o +LIB_OBJS += clean-status-sidecar.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o @@ -1576,6 +1577,7 @@ CLAR_TEST_SUITES += u-clean-status-history-store CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest +CLAR_TEST_SUITES += u-clean-status-sidecar CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-identity.c b/clean-status-identity.c index 415ecab19a64f2..98d5a7e51b9a73 100644 --- a/clean-status-identity.c +++ b/clean-status-identity.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "clean-status-identity.h" +#include "strbuf.h" int clean_status_identity_from_stat(struct clean_status_identity *identity, const struct stat *st) @@ -25,3 +26,31 @@ int clean_status_identity_equal(const struct clean_status_identity *a, { return path_stat_identity_equal(&a->stat, &b->stat); } + +void clean_status_identity_write(struct strbuf *out, + const struct clean_status_identity *identity) +{ + uint64_t value; + size_t i; + + for (i = 0; i < ARRAY_SIZE(identity->stat.fields); i++) { + put_be64(&value, identity->stat.fields[i]); + strbuf_add(out, &value, sizeof(value)); + } +} + +int clean_status_identity_read(const unsigned char **p, + const unsigned char *end, + struct clean_status_identity *identity) +{ + size_t i; + + memset(identity, 0, sizeof(*identity)); + for (i = 0; i < ARRAY_SIZE(identity->stat.fields); i++) { + if ((size_t)(end - *p) < sizeof(uint64_t)) + return -1; + identity->stat.fields[i] = get_be64(*p); + *p += sizeof(uint64_t); + } + return 0; +} diff --git a/clean-status-identity.h b/clean-status-identity.h index 68e459a0349a1f..a22f8438f50874 100644 --- a/clean-status-identity.h +++ b/clean-status-identity.h @@ -3,16 +3,25 @@ #include "path-namespace.h" +struct strbuf; struct stat; struct clean_status_identity { struct path_stat_identity stat; }; +#define CLEAN_STATUS_IDENTITY_SIZE \ + (PATH_STAT_IDENTITY_FIELDS * sizeof(uint64_t)) + int clean_status_identity_from_stat(struct clean_status_identity *identity, const struct stat *st); int clean_status_identity_is_durable(void); int clean_status_identity_equal(const struct clean_status_identity *a, const struct clean_status_identity *b); +void clean_status_identity_write(struct strbuf *out, + const struct clean_status_identity *identity); +int clean_status_identity_read(const unsigned char **p, + const unsigned char *end, + struct clean_status_identity *identity); #endif /* CLEAN_STATUS_IDENTITY_H */ diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c new file mode 100644 index 00000000000000..b850f27e6a6d57 --- /dev/null +++ b/clean-status-sidecar.c @@ -0,0 +1,128 @@ +#include "git-compat-util.h" +#include "clean-status-sidecar.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" +#include "strbuf.h" + +#define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" + +static int checksum_valid(const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *bytes = data; + unsigned char actual[GIT_MAX_RAWSZ]; + + if (len < algo->rawsz) + return 0; + hash_buffer_digest(algo, data, len - algo->rawsz, actual); + return !memcmp(actual, bytes + len - algo->rawsz, algo->rawsz); +} + +static int token_valid(const unsigned char *token, size_t token_len) +{ + static const char prefix[] = "builtin:"; + + return token && token_len && + token_len <= FSMONITOR_CLEAN_PROOF_TOKEN_MAX && + !memchr(token, '\0', token_len) && + token_len >= sizeof(prefix) - 1 && + !memcmp(token, prefix, sizeof(prefix) - 1); +} + +static int proof_valid(const struct clean_status_proof *proof, + const struct git_hash_algo *algo) +{ + return proof->index_version >= 2 && proof->index_version <= 4 && + !is_null_oid(&proof->index_checksum) && + !is_null_oid(&proof->head_tree) && + !is_null_oid(&proof->exclude_source_digest) && + proof->index_checksum.algo == hash_algo_by_ptr(algo) && + proof->head_tree.algo == hash_algo_by_ptr(algo) && + proof->exclude_source_digest.algo == hash_algo_by_ptr(algo); +} + +int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, + const void *data, size_t len, + const struct git_hash_algo *algo) +{ + const unsigned char *p = data; + const unsigned char *end; + size_t minimum = 4 + 2 * sizeof(uint32_t) + + CLEAN_STATUS_IDENTITY_SIZE + 3 * sizeof(uint32_t) + + 6 * algo->rawsz + 1; + uint32_t flags, token_len; + + memset(sidecar, 0, sizeof(*sidecar)); + if (len < minimum || memcmp(p, CLEAN_STATUS_SIDECAR_MAGIC, 4) || + !checksum_valid(data, len, algo)) + return -1; + end = p + len - algo->rawsz; + p += 4; + if (get_be32(p) != CLEAN_STATUS_SIDECAR_VERSION) + return -1; + p += sizeof(uint32_t); + flags = get_be32(p); + p += sizeof(uint32_t); + if (flags) + return -1; + if (clean_status_identity_read(&p, end, &sidecar->identity)) + return -1; + sidecar->proof.index_version = get_be32(p); + p += sizeof(uint32_t); + sidecar->proof.cache_nr = get_be32(p); + p += sizeof(uint32_t); + oidread(&sidecar->proof.index_checksum, p, algo); + p += algo->rawsz; + oidread(&sidecar->proof.head_tree, p, algo); + p += algo->rawsz; + memcpy(sidecar->proof.config_hash, p, algo->rawsz); + p += algo->rawsz; + memcpy(sidecar->proof.repo_hash, p, algo->rawsz); + p += algo->rawsz; + oidread(&sidecar->proof.exclude_source_digest, p, algo); + p += algo->rawsz; + token_len = get_be32(p); + p += sizeof(uint32_t); + if (!proof_valid(&sidecar->proof, algo) || + (size_t)(end - p) != token_len || + !token_valid(p, token_len)) + return -1; + sidecar->token = p; + sidecar->token_len = token_len; + return 0; +} + +int clean_status_sidecar_write(struct strbuf *out, + const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo) +{ + uint32_t value; + + strbuf_reset(out); + if (!proof_valid(&sidecar->proof, algo) || + sidecar->token_len > UINT32_MAX || + !token_valid(sidecar->token, sidecar->token_len)) + return -1; + + strbuf_add(out, CLEAN_STATUS_SIDECAR_MAGIC, 4); + put_be32(&value, CLEAN_STATUS_SIDECAR_VERSION); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, 0); + strbuf_add(out, &value, sizeof(value)); + clean_status_identity_write(out, &sidecar->identity); + put_be32(&value, sidecar->proof.index_version); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, sidecar->proof.cache_nr); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, sidecar->proof.index_checksum.hash, algo->rawsz); + strbuf_add(out, sidecar->proof.head_tree.hash, algo->rawsz); + strbuf_add(out, sidecar->proof.config_hash, algo->rawsz); + strbuf_add(out, sidecar->proof.repo_hash, algo->rawsz); + strbuf_add(out, sidecar->proof.exclude_source_digest.hash, + algo->rawsz); + put_be32(&value, sidecar->token_len); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, sidecar->token, sidecar->token_len); + hash_append_checksum(out, algo); + return 0; +} diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h new file mode 100644 index 00000000000000..1b99434f1f7045 --- /dev/null +++ b/clean-status-sidecar.h @@ -0,0 +1,35 @@ +#ifndef CLEAN_STATUS_SIDECAR_H +#define CLEAN_STATUS_SIDECAR_H + +#include "clean-status-identity.h" +#include "hash.h" + +struct strbuf; + +#define CLEAN_STATUS_SIDECAR_VERSION 1 + +struct clean_status_proof { + uint32_t index_version; + uint32_t cache_nr; + struct object_id index_checksum; + struct object_id head_tree; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char repo_hash[GIT_MAX_RAWSZ]; + struct object_id exclude_source_digest; +}; + +struct clean_status_sidecar { + struct clean_status_identity identity; + struct clean_status_proof proof; + const unsigned char *token; + size_t token_len; +}; + +int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, + const void *data, size_t len, + const struct git_hash_algo *algo); +int clean_status_sidecar_write(struct strbuf *out, + const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo); + +#endif /* CLEAN_STATUS_SIDECAR_H */ diff --git a/meson.build b/meson.build index 23888563a37db6..f9170fb500a250 100644 --- a/meson.build +++ b/meson.build @@ -341,6 +341,7 @@ libgit_sources = [ 'clean-status-identity.c', 'clean-status-index.c', 'clean-status-manifest.c', + 'clean-status-sidecar.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/t/meson.build b/t/meson.build index 0951dce558bea3..a92a264668bbaa 100644 --- a/t/meson.build +++ b/t/meson.build @@ -7,6 +7,7 @@ clar_test_suites = [ 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', + 'unit-tests/u-clean-status-sidecar.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-identity.c b/t/unit-tests/u-clean-status-identity.c index 33e7b80fcfc7e7..6875d594c58c5c 100644 --- a/t/unit-tests/u-clean-status-identity.c +++ b/t/unit-tests/u-clean-status-identity.c @@ -1,5 +1,43 @@ #include "unit-test.h" #include "clean-status-identity.h" +#include "strbuf.h" + +void test_clean_status_identity__round_trips_fixed_width_encoding(void) +{ + struct clean_status_identity expected = { + .stat.fields = { + 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, + }, + }; + struct clean_status_identity actual; + struct strbuf encoded = STRBUF_INIT; + const unsigned char *p; + + clean_status_identity_write(&encoded, &expected); + cl_assert_equal_i(encoded.len, CLEAN_STATUS_IDENTITY_SIZE); + p = (const unsigned char *)encoded.buf; + cl_assert_equal_i(clean_status_identity_read( + &p, (const unsigned char *)encoded.buf + encoded.len, &actual), 0); + cl_assert_equal_i(p - (const unsigned char *)encoded.buf, encoded.len); + cl_assert(clean_status_identity_equal(&expected, &actual)); + strbuf_release(&encoded); +} + +void test_clean_status_identity__rejects_every_truncation(void) +{ + struct clean_status_identity identity = { 0 }, parsed; + struct strbuf encoded = STRBUF_INIT; + + clean_status_identity_write(&encoded, &identity); + for (size_t len = 0; len < encoded.len; len++) { + const unsigned char *p = (const unsigned char *)encoded.buf; + + cl_assert_equal_i(clean_status_identity_read( + &p, (const unsigned char *)encoded.buf + len, &parsed), -1); + } + strbuf_release(&encoded); +} void test_clean_status_identity__requires_a_single_link_regular_file(void) { diff --git a/t/unit-tests/u-clean-status-sidecar.c b/t/unit-tests/u-clean-status-sidecar.c new file mode 100644 index 00000000000000..56ea5581b2a059 --- /dev/null +++ b/t/unit-tests/u-clean-status-sidecar.c @@ -0,0 +1,287 @@ +#include "unit-test.h" +#include "clean-status-sidecar.h" +#include "fsmonitor-clean-proof.h" +#include "hash-framing.h" +#include "strbuf.h" + +struct sidecar_fixture { + struct clean_status_sidecar sidecar; + struct strbuf encoded; +}; + +static void fill_oid(struct object_id *oid, unsigned char value, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(hash, value, algo->rawsz); + oidread(oid, hash, algo); +} + +static void fixture_init(struct sidecar_fixture *fixture, + const struct git_hash_algo *algo) +{ + static const unsigned char token[] = "builtin:1:2"; + struct clean_status_proof *proof; + + memset(fixture, 0, sizeof(*fixture)); + fixture->encoded = (struct strbuf)STRBUF_INIT; + fixture->sidecar.identity.stat.fields[0] = 1; + fixture->sidecar.identity.stat.fields[1] = 2; + proof = &fixture->sidecar.proof; + proof->index_version = 4; + proof->cache_nr = 5; + fill_oid(&proof->index_checksum, 2, algo); + fill_oid(&proof->head_tree, 3, algo); + memset(proof->config_hash, 4, algo->rawsz); + memset(proof->repo_hash, 5, algo->rawsz); + fill_oid(&proof->exclude_source_digest, 6, algo); + fixture->sidecar.token = token; + fixture->sidecar.token_len = sizeof(token) - 1; +} + +static void fixture_encode(struct sidecar_fixture *fixture, + const struct git_hash_algo *algo) +{ + cl_assert_equal_i(clean_status_sidecar_write( + &fixture->encoded, &fixture->sidecar, algo), 0); +} + +static void fixture_release(struct sidecar_fixture *fixture) +{ + strbuf_release(&fixture->encoded); +} + +static void replace_checksum(struct strbuf *encoded, + const struct git_hash_algo *algo) +{ + strbuf_setlen(encoded, encoded->len - algo->rawsz); + hash_append_checksum(encoded, algo); +} + +static size_t flags_offset(void) +{ + return 4 + sizeof(uint32_t); +} + +static size_t proof_offset(void) +{ + return 4 + 2 * sizeof(uint32_t) + CLEAN_STATUS_IDENTITY_SIZE; +} + +static size_t index_checksum_offset(void) +{ + return proof_offset() + 2 * sizeof(uint32_t); +} + +static size_t head_tree_offset(const struct git_hash_algo *algo) +{ + return index_checksum_offset() + algo->rawsz; +} + +static size_t exclude_digest_offset(const struct git_hash_algo *algo) +{ + return index_checksum_offset() + 4 * algo->rawsz; +} + +static size_t token_length_offset(const struct git_hash_algo *algo) +{ + return index_checksum_offset() + 5 * algo->rawsz; +} + +static size_t token_offset(const struct git_hash_algo *algo) +{ + return token_length_offset(algo) + sizeof(uint32_t); +} + +static void assert_parse_fails(struct sidecar_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct clean_status_sidecar parsed; + + replace_checksum(&fixture->encoded, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture->encoded.buf, fixture->encoded.len, algo), -1); +} + +static void assert_round_trip(const struct git_hash_algo *algo) +{ + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert(clean_status_identity_equal(&parsed.identity, + &fixture.sidecar.identity)); + cl_assert_equal_i(parsed.proof.index_version, + fixture.sidecar.proof.index_version); + cl_assert_equal_i(parsed.proof.cache_nr, + fixture.sidecar.proof.cache_nr); + cl_assert(oideq(&parsed.proof.index_checksum, + &fixture.sidecar.proof.index_checksum)); + cl_assert(oideq(&parsed.proof.head_tree, + &fixture.sidecar.proof.head_tree)); + cl_assert(!memcmp(parsed.proof.config_hash, + fixture.sidecar.proof.config_hash, algo->rawsz)); + cl_assert(!memcmp(parsed.proof.repo_hash, + fixture.sidecar.proof.repo_hash, algo->rawsz)); + cl_assert(oideq(&parsed.proof.exclude_source_digest, + &fixture.sidecar.proof.exclude_source_digest)); + cl_assert_equal_i(parsed.token_len, fixture.sidecar.token_len); + cl_assert(!memcmp(parsed.token, fixture.sidecar.token, + parsed.token_len)); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__round_trips_both_object_formats(void) +{ + assert_round_trip(&hash_algos[GIT_HASH_SHA1]); + assert_round_trip(&hash_algos[GIT_HASH_SHA256]); +} + +void test_clean_status_sidecar__rejects_bad_envelopes(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len - 1, algo), -1); + + fixture.encoded.buf[0] ^= 1; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[0] ^= 1; + + put_be32(fixture.encoded.buf + 4, CLEAN_STATUS_SIDECAR_VERSION + 1); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + 4, CLEAN_STATUS_SIDECAR_VERSION); + + put_be32(fixture.encoded.buf + flags_offset(), 1); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + flags_offset(), 0); + + fixture.encoded.buf[fixture.encoded.len - 1] ^= 1; + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), -1); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_invalid_proofs(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + + put_be32(fixture.encoded.buf + proof_offset(), 1); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + proof_offset(), 4); + + memset(fixture.encoded.buf + index_checksum_offset(), 0, algo->rawsz); + assert_parse_fails(&fixture, algo); + memset(fixture.encoded.buf + index_checksum_offset(), 2, algo->rawsz); + + memset(fixture.encoded.buf + head_tree_offset(algo), 0, algo->rawsz); + assert_parse_fails(&fixture, algo); + memset(fixture.encoded.buf + head_tree_offset(algo), 3, algo->rawsz); + + memset(fixture.encoded.buf + exclude_digest_offset(algo), 0, + algo->rawsz); + assert_parse_fails(&fixture, algo); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_invalid_tokens(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + size_t token_len_offset = token_length_offset(algo); + size_t token_start = token_offset(algo); + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + + put_be32(fixture.encoded.buf + token_len_offset, 0); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + token_len_offset, + fixture.sidecar.token_len); + + fixture.encoded.buf[token_start] = 'x'; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[token_start] = 'b'; + + fixture.encoded.buf[token_start + fixture.sidecar.token_len - 1] = '\0'; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[token_start + fixture.sidecar.token_len - 1] = '2'; + + put_be32(fixture.encoded.buf + token_len_offset, + FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); + assert_parse_fails(&fixture, algo); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__accepts_the_maximum_token(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + unsigned char *token; + + fixture_init(&fixture, algo); + token = xmalloc(FSMONITOR_CLEAN_PROOF_TOKEN_MAX); + memset(token, 'x', FSMONITOR_CLEAN_PROOF_TOKEN_MAX); + memcpy(token, "builtin:", strlen("builtin:")); + fixture.sidecar.token = token; + fixture.sidecar.token_len = FSMONITOR_CLEAN_PROOF_TOKEN_MAX; + fixture_encode(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.token_len, FSMONITOR_CLEAN_PROOF_TOKEN_MAX); + free(token); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_trailing_payload(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + + fixture_init(&fixture, algo); + fixture_encode(&fixture, algo); + strbuf_setlen(&fixture.encoded, fixture.encoded.len - algo->rawsz); + strbuf_addch(&fixture.encoded, 'x'); + hash_append_checksum(&fixture.encoded, algo); + cl_assert_equal_i(clean_status_sidecar_parse( + &fixture.sidecar, fixture.encoded.buf, fixture.encoded.len, algo), + -1); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_invalid_writes(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + unsigned char *token; + + fixture_init(&fixture, algo); + oidclr(&fixture.sidecar.proof.index_checksum, algo); + cl_assert_equal_i(clean_status_sidecar_write( + &fixture.encoded, &fixture.sidecar, algo), -1); + fill_oid(&fixture.sidecar.proof.index_checksum, 2, algo); + + token = xmalloc(FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); + memset(token, 'x', FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); + memcpy(token, "builtin:", strlen("builtin:")); + fixture.sidecar.token = token; + fixture.sidecar.token_len = FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1; + cl_assert_equal_i(clean_status_sidecar_write( + &fixture.encoded, &fixture.sidecar, algo), -1); + free(token); + fixture_release(&fixture); +} From ec6e3553d7738fb3ee5659e8a64d44a9011ee766 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:06 -0500 Subject: [PATCH 223/403] status: install sidecars against a pinned index A valid sidecar encoding is not sufficient if its named index can be replaced between proof capture and publication. Publishing that record would let a later reader associate one clean result with another index. Expose the existing index-snapshot open and named-path revalidation helpers at their first store consumer. Require a durable index identity on local APFS, a matching index format, entry count, and checksum, and agreement between the held descriptor and the named index. Encode the sidecar under its own lockfile and repeat the index checks before committing that lock. Register the store unit suite with Make and Meson. Its local-APFS tests cover successful installation for SHA-1 and SHA-256 and rejection when the source index is replaced after pinning. Other filesystems fail closed. Signed-off-by: Taylor Blau --- Makefile | 1 + clean-status-sidecar.c | 104 ++++++++++++++++++ clean-status-sidecar.h | 9 ++ t/meson.build | 1 + t/unit-tests/u-clean-status-store.c | 161 ++++++++++++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 t/unit-tests/u-clean-status-store.c diff --git a/Makefile b/Makefile index 974beec198be26..230acf9a86741d 100644 --- a/Makefile +++ b/Makefile @@ -1578,6 +1578,7 @@ CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest CLAR_TEST_SUITES += u-clean-status-sidecar +CLAR_TEST_SUITES += u-clean-status-store CLAR_TEST_SUITES += u-ctype CLAR_TEST_SUITES += u-dir CLAR_TEST_SUITES += u-example-decorate diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index b850f27e6a6d57..e97873f82fa929 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -1,10 +1,23 @@ #include "git-compat-util.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-index.h" #include "clean-status-sidecar.h" #include "fsmonitor-clean-proof.h" #include "hash-framing.h" +#include "lockfile.h" #include "strbuf.h" +#include "wrapper.h" #define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" +#define CLEAN_STATUS_FILESYSTEM_ID_SIZE 16 + +struct clean_status_filesystem_id { + unsigned char value[CLEAN_STATUS_FILESYSTEM_ID_SIZE]; +}; static int checksum_valid(const void *data, size_t len, const struct git_hash_algo *algo) @@ -126,3 +139,94 @@ int clean_status_sidecar_write(struct strbuf *out, hash_append_checksum(out, algo); return 0; } + +static char *sidecar_path(const char *index_path) +{ + return xstrfmt("%s.csts", index_path); +} + +static int local_apfs_id(int fd MAYBE_UNUSED, + struct clean_status_filesystem_id *id) +{ +#ifdef __APPLE__ + struct statfs fs; +#endif + + memset(id, 0, sizeof(*id)); +#ifdef __APPLE__ + if (fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs") || + sizeof(fs.f_fsid) > sizeof(id->value)) + return -1; + memcpy(id->value, &fs.f_fsid, sizeof(fs.f_fsid)); + return 0; +#else + return -1; +#endif +} + +static int sidecar_matches_snapshot( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct clean_status_filesystem_id fsid; + + return clean_status_identity_is_durable() && + snapshot && snapshot->fd >= 0 && + !local_apfs_id(snapshot->fd, &fsid) && + clean_status_identity_equal(&snapshot->identity, + &sidecar->identity) && + snapshot->version == sidecar->proof.index_version && + snapshot->cache_nr == sidecar->proof.cache_nr && + oideq(&snapshot->checksum, + &sidecar->proof.index_checksum) && + clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo); +} + +int clean_status_sidecar_pin_source( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo, + struct clean_status_index_snapshot *snapshot) +{ + if (clean_status_index_snapshot_open(snapshot, index_path, algo)) + return -1; + if (sidecar_matches_snapshot( + index_path, sidecar, snapshot, algo)) + return 0; + clean_status_index_snapshot_release(snapshot); + return -1; +} + +int clean_status_sidecar_install( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct strbuf encoded = STRBUF_INIT; + struct lock_file lock = LOCK_INIT; + char *path = sidecar_path(index_path); + int sidecar_fd = -1, ret = -1; + + if (!sidecar_matches_snapshot( + index_path, sidecar, snapshot, algo) || + clean_status_sidecar_write(&encoded, sidecar, algo)) + goto done; + sidecar_fd = hold_lock_file_for_update(&lock, path, 0); + if (sidecar_fd < 0 || + (size_t)write_in_full(sidecar_fd, encoded.buf, encoded.len) != + encoded.len || + !sidecar_matches_snapshot( + index_path, sidecar, snapshot, algo) || + commit_lock_file(&lock)) + goto done; + ret = 0; + +done: + if (ret) + rollback_lock_file(&lock); + free(path); + strbuf_release(&encoded); + return ret; +} diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index 1b99434f1f7045..05e03c952022e5 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -4,6 +4,7 @@ #include "clean-status-identity.h" #include "hash.h" +struct clean_status_index_snapshot; struct strbuf; #define CLEAN_STATUS_SIDECAR_VERSION 1 @@ -31,5 +32,13 @@ int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, int clean_status_sidecar_write(struct strbuf *out, const struct clean_status_sidecar *sidecar, const struct git_hash_algo *algo); +int clean_status_sidecar_pin_source( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct git_hash_algo *algo, + struct clean_status_index_snapshot *snapshot); +int clean_status_sidecar_install( + const char *index_path, const struct clean_status_sidecar *sidecar, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo); #endif /* CLEAN_STATUS_SIDECAR_H */ diff --git a/t/meson.build b/t/meson.build index a92a264668bbaa..242c425550a7b5 100644 --- a/t/meson.build +++ b/t/meson.build @@ -8,6 +8,7 @@ clar_test_suites = [ 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', 'unit-tests/u-clean-status-sidecar.c', + 'unit-tests/u-clean-status-store.c', 'unit-tests/u-ctype.c', 'unit-tests/u-dir.c', 'unit-tests/u-example-decorate.c', diff --git a/t/unit-tests/u-clean-status-store.c b/t/unit-tests/u-clean-status-store.c new file mode 100644 index 00000000000000..ac25bb225e3c2e --- /dev/null +++ b/t/unit-tests/u-clean-status-store.c @@ -0,0 +1,161 @@ +#include "unit-test.h" + +#ifdef __APPLE__ +#include +#endif + +#include "clean-status-index.h" +#include "clean-status-sidecar.h" +#include "dir.h" +#include "strbuf.h" + +struct store_fixture { + char *directory; + struct strbuf index_path; + struct clean_status_sidecar sidecar; +}; + +static void fill_oid(struct object_id *oid, unsigned char value, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + + memset(hash, value, algo->rawsz); + oidread(oid, hash, algo); +} + +static void fixture_init(struct store_fixture *fixture, + const struct git_hash_algo *algo) +{ + static const unsigned char token[] = "builtin:1:2"; + struct strbuf index = STRBUF_INIT; + struct clean_status_proof *proof; + struct stat st; + const char *tmp = getenv("TMPDIR"); + uint32_t value; + + memset(fixture, 0, sizeof(*fixture)); + fixture->index_path = (struct strbuf)STRBUF_INIT; + fixture->directory = xstrfmt("%s/status-store.XXXXXX", + tmp ? tmp : "/tmp"); + cl_assert(mkdtemp(fixture->directory) != NULL); + strbuf_addf(&fixture->index_path, "%s/index", fixture->directory); + strbuf_addstr(&index, "DIRC"); + put_be32(&value, 4); + strbuf_add(&index, &value, sizeof(value)); + put_be32(&value, 5); + strbuf_add(&index, &value, sizeof(value)); + strbuf_addchars(&index, 2, algo->rawsz); + write_file_buf(fixture->index_path.buf, index.buf, index.len); + cl_assert_equal_i(stat(fixture->index_path.buf, &st), 0); + cl_assert_equal_i(clean_status_identity_from_stat( + &fixture->sidecar.identity, &st), 0); + proof = &fixture->sidecar.proof; + proof->index_version = 4; + proof->cache_nr = 5; + fill_oid(&proof->index_checksum, 2, algo); + fill_oid(&proof->head_tree, 3, algo); + memset(proof->config_hash, 4, algo->rawsz); + memset(proof->repo_hash, 5, algo->rawsz); + fill_oid(&proof->exclude_source_digest, 6, algo); + fixture->sidecar.token = token; + fixture->sidecar.token_len = sizeof(token) - 1; + strbuf_release(&index); +} + +static void fixture_release(struct store_fixture *fixture) +{ + struct strbuf cleanup = STRBUF_INIT; + + strbuf_addstr(&cleanup, fixture->directory); + cl_assert_equal_i(remove_dir_recursively(&cleanup, 0), 0); + strbuf_release(&cleanup); + strbuf_release(&fixture->index_path); + free(fixture->directory); +} + +static struct strbuf sidecar_path(struct store_fixture *fixture) +{ + struct strbuf path = STRBUF_INIT; + + strbuf_addf(&path, "%s.csts", fixture->index_path.buf); + return path; +} + +static void require_local_apfs(const char *path MAYBE_UNUSED) +{ +#ifdef __APPLE__ + struct statfs fs; + int fd = git_open_cloexec(path, O_RDONLY); + + if (fd < 0 || fstatfs(fd, &fs) || !(fs.f_flags & MNT_LOCAL) || + strcmp(fs.f_fstypename, "apfs")) { + if (fd >= 0) + close(fd); + cl_skip(); + } + close(fd); +#else + cl_skip(); +#endif +} + +static void assert_installs_against_source(const struct git_hash_algo *algo) +{ + struct clean_status_sidecar parsed; + struct clean_status_index_snapshot snapshot; + struct store_fixture fixture; + struct strbuf encoded = STRBUF_INIT; + struct strbuf path; + + fixture_init(&fixture, algo); + path = sidecar_path(&fixture); + cl_assert_equal_i(clean_status_sidecar_pin_source( + fixture.index_path.buf, &fixture.sidecar, algo, &snapshot), 0); + cl_assert_equal_i(clean_status_sidecar_install( + fixture.index_path.buf, &fixture.sidecar, &snapshot, algo), 0); + cl_assert(strbuf_read_file(&encoded, path.buf, 0) > 0); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, encoded.buf, encoded.len, algo), 0); + cl_assert(clean_status_identity_equal( + &parsed.identity, &fixture.sidecar.identity)); + + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&path); + strbuf_release(&encoded); + fixture_release(&fixture); +} + +void test_clean_status_store__installs_both_object_formats(void) +{ + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + assert_installs_against_source(&hash_algos[GIT_HASH_SHA1]); + assert_installs_against_source(&hash_algos[GIT_HASH_SHA256]); +} + +static void assert_rejects_replaced_source(const struct git_hash_algo *algo) +{ + struct clean_status_index_snapshot snapshot; + struct store_fixture fixture; + struct strbuf replacement = STRBUF_INIT; + + fixture_init(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_pin_source( + fixture.index_path.buf, &fixture.sidecar, algo, &snapshot), 0); + strbuf_addf(&replacement, "%s/replacement", fixture.directory); + write_file(replacement.buf, "replacement"); + cl_assert_equal_i(rename(replacement.buf, fixture.index_path.buf), 0); + cl_assert_equal_i(clean_status_sidecar_install( + fixture.index_path.buf, &fixture.sidecar, &snapshot, algo), -1); + + clean_status_index_snapshot_release(&snapshot); + strbuf_release(&replacement); + fixture_release(&fixture); +} + +void test_clean_status_store__rejects_a_replaced_source_index(void) +{ + require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + assert_rejects_replaced_source(&hash_algos[GIT_HASH_SHA1]); + assert_rejects_replaced_source(&hash_algos[GIT_HASH_SHA256]); +} From 37709565ff1dc50c03e29a228be8f76b7351894e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:16 -0500 Subject: [PATCH 224/403] status: classify indexes that clean proofs may certify A clean provider response does not establish that every index entry can be represented by an empty status result. Conflicted entries, submodules, sparse entries, intent-to-add entries, and independently trusted stat state can all require ordinary index processing. Introduce a single conservative certifiability check. Require a non-null index checksum and provider-valid ordinary entries. Reject gitlinks, nonzero stages, intent-to-add, skip-worktree, CE_VALID, and unrecognized entry flags while allowing the explicitly supported in-memory flags. Extend the existing index unit suite to exercise accepted ordinary entries and each unsupported entry shape. The classifier does not issue a proof or change status behavior by itself. Signed-off-by: Taylor Blau --- clean-status-index.c | 25 +++++++++++++++ clean-status-index.h | 3 ++ t/unit-tests/u-clean-status-index.c | 49 +++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/clean-status-index.c b/clean-status-index.c index 0042fb7086ff19..d2303784c9dcd0 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -3,6 +3,7 @@ #include "clean-status-index.h" #include "clean-status-internal.h" #include "hash-framing.h" +#include "object.h" #include "read-cache-ll.h" #include "repository.h" #include "trace2.h" @@ -210,6 +211,30 @@ void clean_status_index_snapshot_release( snapshot->fd = -1; } +int clean_status_index_entries_are_certifiable( + const struct index_state *istate) +{ + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + if (S_ISGITLINK(ce->ce_mode) || ce_stage(ce) || + ce_intent_to_add(ce) || ce_skip_worktree(ce) || + (ce->ce_flags & CE_VALID) || + !(ce->ce_flags & CE_FSMONITOR_VALID) || + (ce->ce_flags & ~(CE_UPTODATE | CE_HASHED | + CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE))) + return 0; + } + return 1; +} + +int clean_status_index_is_certifiable(const struct index_state *istate) +{ + return !is_null_oid(&istate->oid) && + clean_status_index_entries_are_certifiable(istate); +} + static int index_logical_digest(const struct index_state *istate, unsigned int extra_benign_flags, unsigned char *out) diff --git a/clean-status-index.h b/clean-status-index.h index 61b288d3de2e4f..2579b20ee437e1 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -34,6 +34,9 @@ int clean_status_index_snapshot_still_matches_proof_epoch( const struct index_state *istate); void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot); +int clean_status_index_entries_are_certifiable( + const struct index_state *istate); +int clean_status_index_is_certifiable(const struct index_state *istate); int clean_status_index_logical_digest(const struct index_state *istate, unsigned char *out); int clean_status_index_logical_digest_after_status( diff --git a/t/unit-tests/u-clean-status-index.c b/t/unit-tests/u-clean-status-index.c index c13fbc03123299..9f97f683fed803 100644 --- a/t/unit-tests/u-clean-status-index.c +++ b/t/unit-tests/u-clean-status-index.c @@ -3,6 +3,7 @@ #include "clean-status-index.h" #include "clean-status-internal.h" #include "dir.h" +#include "object.h" #include "read-cache-ll.h" #include "repository.h" #include "strbuf.h" @@ -455,6 +456,54 @@ void test_clean_status_index__binds_the_parsed_source(void) free(worktree); } +void test_clean_status_index__recognizes_certifiable_entries(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct cache_entry *ce; + static const char path[] = "tracked"; + + CALLOC_ARRAY(istate.cache, 1); + istate.cache_alloc = istate.cache_nr = 1; + memset(istate.oid.hash, 1, repo.hash_algo->rawsz); + oid_set_algo(&istate.oid, repo.hash_algo); + ce = make_empty_cache_entry(&istate, sizeof(path) - 1); + ce->ce_mode = S_IFREG | 0644; + ce->ce_namelen = sizeof(path) - 1; + memcpy(ce->name, path, sizeof(path)); + istate.cache[0] = ce; + + ce->ce_flags = CE_FSMONITOR_VALID; + cl_assert(clean_status_index_is_certifiable(&istate)); + ce->ce_flags |= CE_UPTODATE | CE_HASHED; + cl_assert(clean_status_index_is_certifiable(&istate)); + + oidclr(&istate.oid, repo.hash_algo); + cl_assert(!clean_status_index_is_certifiable(&istate)); + cl_assert(clean_status_index_entries_are_certifiable(&istate)); + memset(istate.oid.hash, 1, repo.hash_algo->rawsz); + + ce->ce_flags = 0; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_VALID; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_UPDATE_IN_BASE; + cl_assert(clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | create_ce_flags(1); + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_INTENT_TO_ADD; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_SKIP_WORKTREE; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID | CE_WT_REMOVE; + cl_assert(!clean_status_index_is_certifiable(&istate)); + ce->ce_flags = CE_FSMONITOR_VALID; + ce->ce_mode = S_IFGITLINK; + cl_assert(!clean_status_index_is_certifiable(&istate)); + + release_index(&istate); +} + void test_clean_status_index__digests_only_persistent_logical_entries(void) { struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; From 8e1abdc75eeb312021f224d15a1f89ae4a39fcff Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 02:28:35 -0500 Subject: [PATCH 225/403] status: issue sidecars after a verified full scan An empty status result cannot certify the next invocation unless its tracked and untracked observations, ignore sources, provider token, configuration, repository, HEAD, and named index belong to one completed scan. Publishing a digest before provider-token closure, or reusing cached replacement-ref state, could issue a false clean proof. Retain the standard-exclude digest produced by the complete bulk scan. Keep provider-originated digest state pending until token closure accepts it, and preserve the accepted digest when consuming single-use tracked results. Inspect a fresh, uncached ref store and reject effective replacement refs. Then fingerprint the held local-APFS index and worktree, repository paths, locale, and external attribute state. Issue a sidecar only for the literal, top-level, empty porcelain-v2 command after persistent semantic history, an eligible expanded index, a complete untracked scan, and the HEAD cache tree all agree. Install against the pinned index before rolling back its held index lock; otherwise retain ordinary index-update behavior. Also enable the preceding external-history checkpoint path only for a literal normal status with no pathspec. After the full scan, publish a complete checkpoint for the closed token. A successful save or restore rolls back the acceleration-only index update, preserving another Git implementation's physical index namespace. Optional-lock-free and index-changing commands keep the ordinary path. Add the focused sidecar integration suite and register its source and production code with the relevant Make and Meson builds. Cover prior semantic history, unchanged index contents, exact command shape, rejection of unsupported exact-sidecar inputs, namespace-specific external-history restoration across index re-encoding, and failed checkpoint republication. No early status answer is introduced here. Signed-off-by: Taylor Blau --- Makefile | 1 + builtin/commit.c | 23 ++++ clean-status-sidecar-issue.c | 169 ++++++++++++++++++++++++ clean-status-sidecar.c | 120 +++++++++++++++++ clean-status-sidecar.h | 9 ++ clean-status.h | 7 + dir.c | 31 ++++- dir.h | 1 + meson.build | 1 + preload-index-bulk.c | 18 ++- preload-index-bulk.h | 4 + preload-index.c | 56 +++++++- preload-index.h | 6 + read-cache-ll.h | 6 +- refs.c | 17 +++ refs.h | 6 + replace-object.c | 13 ++ replace-object.h | 7 + t/meson.build | 1 + t/t7508-status.sh | 40 ++++++ t/t7527-builtin-fsmonitor.sh | 6 +- t/t7530-status-clean-sidecar.sh | 227 ++++++++++++++++++++++++++++++++ wt-status.c | 206 ++++++++++++++++++++++++++--- wt-status.h | 13 ++ 24 files changed, 952 insertions(+), 36 deletions(-) create mode 100644 clean-status-sidecar-issue.c create mode 100755 t/t7530-status-clean-sidecar.sh diff --git a/Makefile b/Makefile index 230acf9a86741d..0ee895f3dd106c 100644 --- a/Makefile +++ b/Makefile @@ -1137,6 +1137,7 @@ LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += clean-status-sidecar.o +LIB_OBJS += clean-status-sidecar-issue.o LIB_OBJS += color.o LIB_OBJS += column.o LIB_OBJS += combine-diff.o diff --git a/builtin/commit.c b/builtin/commit.c index 452d56c20a4abd..feff3c8df156d5 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1614,6 +1614,9 @@ struct repository *repo UNUSED) struct clean_status_config_digest clean_digest; unsigned int progress_flag = 0; int fd; + int default_status_command = argc == 1 && (!prefix || !*prefix); + int exact_clean_command = argc == 2 && + !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1696,6 +1699,10 @@ struct repository *repo UNUSED) parse_pathspec(&s.pathspec, 0, PATHSPEC_PREFER_FULL, prefix, argv); + s.allow_clean_status_shortcuts = + default_status_command && !s.pathspec.nr; + if (s.allow_clean_status_shortcuts) + clean_status_enable_external_history(the_repository); if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) @@ -1731,6 +1738,22 @@ struct repository *repo UNUSED) wt_status_collect(&s); + if (exact_clean_command && 0 <= fd && + clean_status_issue_sidecar(&s, &clean_digest, &index_lock)) + fd = -1; + if (0 <= fd) { + int external_restored = + clean_status_external_history_was_restored( + the_repository->index); + int external_saved = + clean_status_save_external_history( + the_repository->index); + + if (external_restored || external_saved) { + rollback_lock_file(&index_lock); + fd = -1; + } + } if (0 <= fd) repo_update_index_if_able(the_repository, &index_lock); diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c new file mode 100644 index 00000000000000..bdf3c058595a27 --- /dev/null +++ b/clean-status-sidecar-issue.c @@ -0,0 +1,169 @@ +#include "git-compat-util.h" +#include "cache-tree.h" +#include "clean-status.h" +#include "clean-status-index.h" +#include "clean-status-internal.h" +#include "clean-status-sidecar.h" +#include "environment.h" +#include "fsmonitor-clean-proof.h" +#include "fsmonitor-ll.h" +#include "fsmonitor-settings.h" +#include "lockfile.h" +#include "object-name.h" +#include "preload-index.h" +#include "read-cache-ll.h" +#include "repository.h" +#include "trace2.h" +#include "wt-status.h" + +static void trace_miss(struct repository *repo, const char *reason) +{ + trace2_data_string("status", repo, "clean-proof/miss", reason); +} + +static int issue_test_barrier(void) +{ + const char *ready = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_READY"); + const char *resume = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_RESUME"); + struct strbuf buf = STRBUF_INIT; + int ret; + + if (!ready && !resume) + return 0; + if (!ready || !resume) + return -1; + write_file(ready, "ready"); + ret = strbuf_read_file(&buf, resume, 1) > 0 ? 0 : -1; + strbuf_release(&buf); + return ret; +} + +static int output_is_certifiable(const struct wt_status *status) +{ + return status->status_format == STATUS_FORMAT_PORCELAIN_V2 && + !status->pathspec.nr && !status->show_branch && + !status->show_stash && !status->show_ignored_mode && + !status->null_termination && !status->verbose && + status->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !status->change.nr && !status->untracked.nr && + !status->ignored.nr; +} + +static int history_is_certifiable(const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && + clean_status_has_persistent_fsmonitor_semantic_history(istate) && + clean_status_revalidated_token_matches(istate) && + state->manifest.current_valid && + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL; +} + +static int fsmonitor_state_is_certifiable( + struct repository *repo, const struct index_state *istate) +{ + return !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(repo) == FSMONITOR_MODE_IPC && + !fsmonitor_has_pending_token(istate) && + istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + strlen(istate->fsmonitor_last_update) <= + FSMONITOR_CLEAN_PROOF_TOKEN_MAX && + clean_status_index_is_certifiable(istate); +} + +static int untracked_scan_is_certifiable( + struct wt_status *status, struct object_id *exclude_digest, + struct stat *scanned_worktree) +{ + if (status->untracked_from_preload) + return !preload_index_bulk_standard_excludes_digest( + status->repo->index, exclude_digest, + scanned_worktree); + if (status->untracked_from_token_closure) + return !wt_status_certified_excludes_digest( + status, exclude_digest, scanned_worktree); + return 0; +} + +int clean_status_issue_sidecar( + struct wt_status *status, + const struct clean_status_config_digest *config, + struct lock_file *index_lock) +{ + struct repository *repo = status->repo; + struct index_state *istate = repo->index; + struct clean_status_index_snapshot index = { .fd = -1 }; + struct clean_status_sidecar sidecar = { 0 }; + struct object_id exclude_digest, head_tree; + struct stat scanned_worktree; + unsigned char repo_hash[GIT_MAX_RAWSZ]; + int installed = 0; + + if (!is_lock_file_locked(index_lock) || + !config->finalized || config->filter_configured || + !output_is_certifiable(status)) { + trace_miss(repo, "issue-command-or-output"); + goto done; + } + if (!history_is_certifiable(istate)) { + trace_miss(repo, "issue-coherent-history"); + goto done; + } + if (getenv(INDEX_ENVIRONMENT) || + !fsmonitor_state_is_certifiable(repo, istate) || + !untracked_scan_is_certifiable( + status, &exclude_digest, &scanned_worktree)) { + trace_miss(repo, "issue-scan-or-index-shape"); + goto done; + } + if (issue_test_barrier()) { + trace_miss(repo, "issue-test-barrier"); + goto done; + } + if (!status->attr_source_snapshot || + clean_status_index_snapshot_pin(&index, istate) || + clean_status_repository_fingerprint( + repo, status->attr_source_snapshot, &index, + &scanned_worktree, repo_hash)) { + trace_miss(repo, "issue-pinned-inputs"); + goto done; + } + if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || + !istate->cache_tree || istate->cache_tree->entry_count < 0 || + !oideq(&head_tree, &istate->cache_tree->oid)) { + trace_miss(repo, "issue-head-cache-tree"); + goto done; + } + + sidecar.identity = index.identity; + sidecar.proof.index_version = index.version; + sidecar.proof.cache_nr = index.cache_nr; + oidcpy(&sidecar.proof.index_checksum, &index.checksum); + oidcpy(&sidecar.proof.head_tree, &head_tree); + memcpy(sidecar.proof.config_hash, config->hash, + repo->hash_algo->rawsz); + memcpy(sidecar.proof.repo_hash, repo_hash, + repo->hash_algo->rawsz); + oidcpy(&sidecar.proof.exclude_source_digest, &exclude_digest); + sidecar.token = (const unsigned char *)istate->fsmonitor_last_update; + sidecar.token_len = strlen(istate->fsmonitor_last_update); + + if (clean_status_sidecar_install( + repo->index_file, &sidecar, &index, repo->hash_algo)) { + trace_miss(repo, "issue-sidecar-write"); + goto done; + } + rollback_lock_file(index_lock); + trace2_data_intmax("status", repo, "clean-proof/sidecar", 1); + installed = 1; + +done: + clean_status_index_snapshot_release(&index); + return installed; +} diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index e97873f82fa929..a68d3dc8059367 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -4,12 +4,18 @@ #include #endif +#include "abspath.h" +#include "attr-fingerprint.h" #include "clean-status-index.h" #include "clean-status-sidecar.h" #include "fsmonitor-clean-proof.h" #include "hash-framing.h" #include "lockfile.h" +#include "path.h" +#include "repository.h" +#include "replace-object.h" #include "strbuf.h" +#include "worktree.h" #include "wrapper.h" #define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" @@ -145,6 +151,18 @@ static char *sidecar_path(const char *index_path) return xstrfmt("%s.csts", index_path); } +static int open_nofollow_nonblocking(const char *path, int flags) +{ +#ifdef O_NONBLOCK + return open_nofollow(path, flags | O_NONBLOCK); +#else + (void)path; + (void)flags; + errno = ENOSYS; + return -1; +#endif +} + static int local_apfs_id(int fd MAYBE_UNUSED, struct clean_status_filesystem_id *id) { @@ -230,3 +248,105 @@ int clean_status_sidecar_install( strbuf_release(&encoded); return ret; } + +static int current_worktree_is_main(struct repository *repo) +{ + struct worktree *worktree = get_current_worktree(repo); + int ret = worktree && is_main_worktree(worktree); + + free_worktree(worktree); + return ret; +} + +static int worktree_root_identity( + const struct stat *st, uint64_t *identity MAYBE_UNUSED) +{ + if (!S_ISDIR(st->st_mode)) + return -1; +#ifdef __APPLE__ + identity[0] = st->st_dev; + identity[1] = st->st_ino; + identity[2] = st->st_birthtimespec.tv_sec; + identity[3] = st->st_birthtimespec.tv_nsec; + identity[4] = st->st_gen; + return 0; +#else + return -1; +#endif +} + +int clean_status_repository_fingerprint( + struct repository *repo, + const struct attr_source_snapshot *attrs, + const struct clean_status_index_snapshot *index, + const struct stat *scanned_worktree, + unsigned char *out) +{ + static const char domain[] = "git-clean-status-repository-v1"; + const struct attr_fingerprint *attr_fingerprint = + attr_source_snapshot_fingerprint(attrs); + struct clean_status_filesystem_id index_fsid, worktree_fsid; + struct git_hash_ctx ctx; + struct stat st; + char *worktree = NULL, *gitdir = NULL, *commondir = NULL; + uint64_t root_identity[5]; + uint64_t scanned_root_identity[5]; + uint64_t value; + int worktree_fd = -1, ret = -1; + + if (!attr_fingerprint || attr_fingerprint->sources_present || + !index || index->fd < 0 || !scanned_worktree || + is_bare_repository(repo) || + !repo_get_work_tree(repo) || + !current_worktree_is_main(repo) || + repo_has_replace_refs_uncached(repo)) + goto done; + + worktree = real_pathdup(repo_get_work_tree(repo), 0); + gitdir = real_pathdup(repo_get_git_dir(repo), 0); + commondir = real_pathdup(repo_get_common_dir(repo), 0); + if (!worktree || !gitdir || !commondir) + goto done; + worktree_fd = open_nofollow_nonblocking( + worktree, O_RDONLY | O_CLOEXEC); + if (worktree_fd < 0 || + local_apfs_id(worktree_fd, &worktree_fsid) || + local_apfs_id(index->fd, &index_fsid) || + fstat(worktree_fd, &st) || + worktree_root_identity(&st, root_identity) || + worktree_root_identity( + scanned_worktree, scanned_root_identity) || + memcmp(root_identity, scanned_root_identity, + sizeof(root_identity))) + goto done; + + git_hash_init(&ctx, repo->hash_algo); + hash_length_delimited(&ctx, domain, sizeof(domain) - 1); + hash_length_delimited(&ctx, worktree, strlen(worktree)); + hash_length_delimited(&ctx, gitdir, strlen(gitdir)); + hash_length_delimited(&ctx, commondir, strlen(commondir)); + for (size_t i = 0; i < ARRAY_SIZE(root_identity); i++) { + put_be64(&value, root_identity[i]); + hash_length_delimited(&ctx, &value, sizeof(value)); + } + hash_length_delimited(&ctx, worktree_fsid.value, + sizeof(worktree_fsid.value)); + hash_length_delimited(&ctx, index_fsid.value, + sizeof(index_fsid.value)); + hash_length_delimited(&ctx, attr_fingerprint->content_hash, + repo->hash_algo->rawsz); + hash_optional_cstring(&ctx, setlocale(LC_CTYPE, NULL)); + hash_optional_cstring(&ctx, getenv("LC_ALL")); + hash_optional_cstring(&ctx, getenv("LC_CTYPE")); + hash_optional_cstring(&ctx, getenv("LANG")); + git_hash_final(out, &ctx); + ret = 0; + +done: + if (worktree_fd >= 0) + close(worktree_fd); + free(worktree); + free(gitdir); + free(commondir); + return ret; +} diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index 05e03c952022e5..963d6bccc6b65a 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -5,7 +5,10 @@ #include "hash.h" struct clean_status_index_snapshot; +struct attr_source_snapshot; +struct repository; struct strbuf; +struct stat; #define CLEAN_STATUS_SIDECAR_VERSION 1 @@ -40,5 +43,11 @@ int clean_status_sidecar_install( const char *index_path, const struct clean_status_sidecar *sidecar, const struct clean_status_index_snapshot *snapshot, const struct git_hash_algo *algo); +int clean_status_repository_fingerprint( + struct repository *repo, + const struct attr_source_snapshot *attrs, + const struct clean_status_index_snapshot *index, + const struct stat *scanned_worktree, + unsigned char *out); #endif /* CLEAN_STATUS_SIDECAR_H */ diff --git a/clean-status.h b/clean-status.h index 1cbfd1e0329456..f3db36e3ea21ff 100644 --- a/clean-status.h +++ b/clean-status.h @@ -6,9 +6,11 @@ struct index_state; struct attr_source_snapshot; struct clean_status_proof_epoch; +struct lock_file; struct repository; struct stat; struct strbuf; +struct wt_status; enum clean_status_attr_change { CLEAN_STATUS_ATTR_CONTENT_CHANGED = 1 << 0, @@ -79,6 +81,11 @@ int clean_status_retain_source_index_fd(struct index_state *istate, int fd, int clean_status_verify_null_index(const struct index_state *istate, const struct stat *st); +int clean_status_issue_sidecar( + struct wt_status *status, + const struct clean_status_config_digest *config, + struct lock_file *index_lock); + int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); void clean_status_prepare_fsmonitor_config(struct index_state *istate); diff --git a/dir.c b/dir.c index 4e2e474e083871..c1057362ea573f 100644 --- a/dir.c +++ b/dir.c @@ -933,6 +933,8 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, struct index_state *istate, const char *path, int len, struct untracked_cache_dir *untracked, int check_only, int stop_at_first_file, const struct pathspec *pathspec); +static int resolve_dtype_with_error(int dtype, struct index_state *istate, + const char *path, int len, int *failed); static int resolve_dtype(int dtype, struct index_state *istate, const char *path, int len); struct dirent *readdir_skip_dot_and_dotdot(DIR *dirp) @@ -3296,6 +3298,12 @@ unsigned char get_dtype(struct dirent *e, struct strbuf *path, static int resolve_dtype(int dtype, struct index_state *istate, const char *path, int len) +{ + return resolve_dtype_with_error(dtype, istate, path, len, NULL); +} + +static int resolve_dtype_with_error(int dtype, struct index_state *istate, + const char *path, int len, int *failed) { struct stat st; @@ -3304,8 +3312,11 @@ static int resolve_dtype(int dtype, struct index_state *istate, dtype = get_index_dtype(istate, path, len); if (dtype != DT_UNKNOWN) return dtype; - if (lstat(path, &st)) + if (lstat(path, &st)) { + if (failed && !is_missing_file_error(errno)) + *failed = 1; return dtype; + } if (S_ISREG(st.st_mode)) return DT_REG; if (S_ISDIR(st.st_mode)) @@ -3361,6 +3372,7 @@ static enum path_treatment treat_path(struct dir_struct *dir, const struct pathspec *pathspec) { int has_path_in_index, dtype, excluded; + int dtype_failed = 0; if (!cdir->d_name) return treat_path_fast(dir, cdir, istate, path, @@ -3372,7 +3384,11 @@ static enum path_treatment treat_path(struct dir_struct *dir, if (simplify_away(path->buf, path->len, pathspec)) return path_none; - dtype = resolve_dtype(cdir->d_type, istate, path->buf, path->len); + dtype = resolve_dtype_with_error( + cdir->d_type, istate, path->buf, path->len, + &dtype_failed); + if (dtype_failed) + dir->internal.traversal_failed = 1; /* Always exclude indexed files */ has_path_in_index = !!index_file_exists(istate, path->buf, path->len, @@ -3523,8 +3539,10 @@ static int open_cached_dir(struct cached_dir *cdir, return 0; c_path = path->len ? path->buf : "."; cdir->fdir = opendir(c_path); - if (!cdir->fdir) + if (!cdir->fdir) { + dir->internal.traversal_failed = 1; warning_errno(_("could not open directory '%s'"), c_path); + } if (dir->untracked) { invalidate_directory(dir->untracked, untracked); dir->untracked->dir_opened++; @@ -3534,13 +3552,16 @@ static int open_cached_dir(struct cached_dir *cdir, return 0; } -static int read_cached_dir(struct cached_dir *cdir) +static int read_cached_dir(struct cached_dir *cdir, struct dir_struct *dir) { struct dirent *de; if (cdir->fdir) { + errno = 0; de = readdir_skip_dot_and_dotdot(cdir->fdir); if (!de) { + if (errno) + dir->internal.traversal_failed = 1; cdir->d_name = NULL; cdir->d_type = DT_UNKNOWN; return -1; @@ -3698,7 +3719,7 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir, if (untracked) untracked->check_only = !!check_only; - while (!read_cached_dir(&cdir)) { + while (!read_cached_dir(&cdir, dir)) { /* check how the file or directory should be treated */ state = treat_path(dir, untracked, &cdir, istate, &path, baselen, pathspec); diff --git a/dir.h b/dir.h index 23eed870a0e235..088e06c1ada4d8 100644 --- a/dir.h +++ b/dir.h @@ -365,6 +365,7 @@ struct dir_struct { unsigned visited_paths; unsigned visited_directories; unsigned untracked_cache_preloaded : 1; + unsigned traversal_failed : 1; /* * Optional borrowed proof that covers every exclusion source diff --git a/meson.build b/meson.build index f9170fb500a250..0d2ab0d178df09 100644 --- a/meson.build +++ b/meson.build @@ -342,6 +342,7 @@ libgit_sources = [ 'clean-status-index.c', 'clean-status-manifest.c', 'clean-status-sidecar.c', + 'clean-status-sidecar-issue.c', 'color.c', 'column.c', 'combine-diff.c', diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 5756df1faceb90..9aa15920b530b0 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -186,9 +186,11 @@ int preload_bulk_collect(struct index_state *istate, int threads, .untracked = STRING_LIST_INIT_DUP, }; struct preload_bulk_run_result run_result = { 0 }; + struct object_id standard_excludes_digest; struct stat root_stat; const char *start_error, *finish_error = NULL; const char *untracked_reason = NULL; + int standard_excludes_digest_valid = 0; int scan_error = -1; int clean; @@ -239,7 +241,7 @@ int preload_bulk_collect(struct index_state *istate, int threads, CALLOC_ARRAY(scan.tracked_state, istate->cache_nr); start_error = backend->start(&scan); - if (!start_error && scan.proof_epoch && + if (!start_error && (scan.proof_epoch || scan.collect_untracked) && (scan.root_fd < 0 || fstat(scan.root_fd, &root_stat))) start_error = "root-stat"; if (!start_error && scan.proof_epoch) @@ -251,6 +253,11 @@ int preload_bulk_collect(struct index_state *istate, int threads, exclude_dir.internal.exclude_source_proof = exclude_proof; setup_standard_excludes(&exclude_dir); + standard_excludes_digest_valid = + !exclude_source_proof_digest( + exclude_proof, + istate->repo->hash_algo, + &standard_excludes_digest); } scan_error = preload_bulk_run_scan(&scan, &run_result); if (!scan_error) @@ -264,7 +271,8 @@ int preload_bulk_collect(struct index_state *istate, int threads, "index", "preload/bulk_excludes", istate->repo); exclude_proof_valid = exclude_source_proof_validate(exclude_proof); - if (!exclude_proof_valid) { + if (!standard_excludes_digest_valid || + !exclude_proof_valid) { run_result.untracked_complete = 0; untracked_reason = "exclude-race"; } @@ -310,6 +318,12 @@ int preload_bulk_collect(struct index_state *istate, int threads, result->can_skip_unseen_preload = scan.can_skip_unseen_preload; result->untracked_complete = run_result.untracked_complete; + if (result->untracked_complete) { + result->standard_excludes_digest_valid = 1; + oidcpy(&result->standard_excludes_digest, + &standard_excludes_digest); + result->scanned_worktree = root_stat; + } scan.tracked_state = NULL; scan.stat_updates = NULL; scan.stat_updates_nr = 0; diff --git a/preload-index-bulk.h b/preload-index-bulk.h index 3a7d0ef84c1c05..ff5583438fd07a 100644 --- a/preload-index-bulk.h +++ b/preload-index-bulk.h @@ -2,6 +2,7 @@ #define PRELOAD_INDEX_BULK_H #include "git-compat-util.h" +#include "hash.h" #include "preload-index.h" #include "statinfo.h" #include "strbuf.h" @@ -137,6 +138,9 @@ struct preload_bulk_result { unsigned can_skip_unseen_preload : 1; struct string_list untracked; unsigned untracked_complete : 1; + unsigned standard_excludes_digest_valid : 1; + struct object_id standard_excludes_digest; + struct stat scanned_worktree; }; struct preload_bulk_stat_update { diff --git a/preload-index.c b/preload-index.c index a82063e8fd4149..892f1204676829 100644 --- a/preload-index.c +++ b/preload-index.c @@ -134,7 +134,10 @@ struct preload_bulk_pending { unsigned char *tracked_state; struct preload_bulk_stat_update *stat_updates; size_t stat_updates_nr; + struct object_id standard_excludes_digest; + struct stat scanned_worktree; unsigned provider : 1; + unsigned standard_excludes_digest_valid : 1; }; static int stat_data_is_zero(const struct stat_data *sd) @@ -372,6 +375,13 @@ static void preload_bulk_try(struct index_state *index, result.stat_updates_nr = 0; } } + if (result.standard_excludes_digest_valid) { + pending->provider = provider; + pending->standard_excludes_digest_valid = 1; + oidcpy(&pending->standard_excludes_digest, + &result.standard_excludes_digest); + pending->scanned_worktree = result.scanned_worktree; + } if (result.untracked_complete && index->preload_untracked) { *index->preload_untracked = result.untracked; index->preload_untracked_complete = 1; @@ -394,7 +404,19 @@ static void preload_bulk_finish_state(struct index_state *index, index->preload_bulk_stat_updates_nr = pending->stat_updates_nr; index->preload_bulk_provider_pending = pending->provider; - memset(pending, 0, sizeof(*pending)); + pending->tracked_state = NULL; + pending->stat_updates = NULL; + pending->stat_updates_nr = 0; + } + if (pending->standard_excludes_digest_valid) { + oidcpy(&index->preload_bulk_standard_excludes_digest, + &pending->standard_excludes_digest); + index->preload_bulk_scanned_worktree = + pending->scanned_worktree; + if (pending->provider) + index->preload_bulk_excludes_digest_pending = 1; + else + index->preload_bulk_excludes_digest_valid = 1; } free(pending->tracked_state); free(pending->stat_updates); @@ -410,13 +432,22 @@ static int compare_stat_update(const void *va, const void *vb) } #endif -void preload_index_bulk_result_clear(struct index_state *index) +void preload_index_bulk_result_consume(struct index_state *index) { FREE_AND_NULL(index->preload_bulk_tracked_state); FREE_AND_NULL(index->preload_bulk_stat_updates); index->preload_bulk_tracked_nr = 0; index->preload_bulk_stat_updates_nr = 0; index->preload_bulk_provider_pending = 0; + index->preload_bulk_excludes_digest_pending = 0; +} + +void preload_index_bulk_result_clear(struct index_state *index) +{ + preload_index_bulk_result_consume(index); + index->preload_bulk_excludes_digest_valid = 0; + oidclr(&index->preload_bulk_standard_excludes_digest, + index->repo->hash_algo); } int preload_index_bulk_can_close_provider(struct index_state *index) @@ -446,8 +477,11 @@ int preload_index_bulk_result_accept(struct index_state *index) size_t update_nr = 0; int applied = 0; - if (!index->preload_bulk_provider_pending) + if (!index->preload_bulk_provider_pending && + !index->preload_bulk_excludes_digest_pending) return 0; + if (!index->preload_bulk_provider_pending) + goto accept_digest; if (!index->preload_bulk_tracked_state || index->preload_bulk_tracked_nr != index->cache_nr) return -1; @@ -494,6 +528,11 @@ int preload_index_bulk_result_accept(struct index_state *index) FREE_AND_NULL(index->preload_bulk_stat_updates); index->preload_bulk_stat_updates_nr = 0; index->preload_bulk_provider_pending = 0; +accept_digest: + if (index->preload_bulk_excludes_digest_pending) { + index->preload_bulk_excludes_digest_pending = 0; + index->preload_bulk_excludes_digest_valid = 1; + } trace2_data_intmax("index", index->repo, "preload/bulk_provider_applied", applied); #else @@ -502,6 +541,17 @@ int preload_index_bulk_result_accept(struct index_state *index) return 0; } +int preload_index_bulk_standard_excludes_digest( + const struct index_state *index, struct object_id *digest, + struct stat *scanned_worktree) +{ + if (!index->preload_bulk_excludes_digest_valid) + return -1; + oidcpy(digest, &index->preload_bulk_standard_excludes_digest); + *scanned_worktree = index->preload_bulk_scanned_worktree; + return 0; +} + void preload_index(struct index_state *index, const struct pathspec *pathspec, unsigned int refresh_flags) diff --git a/preload-index.h b/preload-index.h index 7f7fdcca28acb2..87e6d0b89276a7 100644 --- a/preload-index.h +++ b/preload-index.h @@ -2,8 +2,10 @@ #define PRELOAD_INDEX_H struct index_state; +struct object_id; struct pathspec; struct repository; +struct stat; enum preload_bulk_tracked_state { PRELOAD_BULK_TRACKED_UNSEEN = 0, @@ -21,7 +23,11 @@ int repo_read_index_preload(struct repository *, const struct pathspec *pathspec, unsigned refresh_flags); void preload_index_bulk_result_clear(struct index_state *index); +void preload_index_bulk_result_consume(struct index_state *index); int preload_index_bulk_can_close_provider(struct index_state *index); int preload_index_bulk_result_accept(struct index_state *index); +int preload_index_bulk_standard_excludes_digest( + const struct index_state *index, struct object_id *digest, + struct stat *scanned_worktree); #endif /* PRELOAD_INDEX_H */ diff --git a/read-cache-ll.h b/read-cache-ll.h index 5a50c0d6dd4bbf..40be30c9bda066 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -195,7 +195,9 @@ struct index_state { fsmonitor_untracked_extension_invalid : 1, fsmonitor_pending_token_from_provider : 1, preload_untracked_complete : 1, - preload_bulk_provider_pending : 1; + preload_bulk_provider_pending : 1, + preload_bulk_excludes_digest_pending : 1, + preload_bulk_excludes_digest_valid : 1; enum sparse_index_mode sparse_index; struct hashmap name_hash; struct hashmap dir_hash; @@ -205,6 +207,8 @@ struct index_state { size_t preload_bulk_tracked_nr; struct preload_bulk_stat_update *preload_bulk_stat_updates; size_t preload_bulk_stat_updates_nr; + struct object_id preload_bulk_standard_excludes_digest; + struct stat preload_bulk_scanned_worktree; /* Borrowed only while refresh_index() performs a provider scan. */ struct clean_status_proof_epoch *preload_bulk_proof_epoch; /* Borrowed for the duration of preload_index(). */ diff --git a/refs.c b/refs.c index 92d5df5b71fa4b..845a5fd3f7587a 100644 --- a/refs.c +++ b/refs.c @@ -2350,6 +2350,23 @@ static struct ref_store *ref_store_init(struct repository *repo, return refs; } +int refs_for_each_replace_ref_uncached(struct repository *repo, + refs_for_each_cb cb, void *cb_data) +{ + struct ref_store *refs; + int ret; + + if (!repo->gitdir) + BUG("attempting to get uncached refs outside of repository"); + + refs = ref_store_init(repo, repo->ref_storage_format, repo->gitdir, + REF_STORE_READ); + ret = refs_for_each_replace_ref(refs, cb, cb_data); + ref_store_release(refs); + free(refs); + return ret; +} + void ref_store_release(struct ref_store *ref_store) { ref_store->be->release(ref_store); diff --git a/refs.h b/refs.h index 9979446d15fd3b..1f80233c5fc472 100644 --- a/refs.h +++ b/refs.h @@ -520,6 +520,12 @@ int refs_for_each_remote_ref(struct ref_store *refs, refs_for_each_cb fn, void *cb_data); int refs_for_each_replace_ref(struct ref_store *refs, refs_for_each_cb fn, void *cb_data); +/* + * Iterate replacement refs through a fresh read-only ref store, without + * consulting caches held by the repository's main ref store. + */ +int refs_for_each_replace_ref_uncached(struct repository *repo, + refs_for_each_cb fn, void *cb_data); /** * Iterate all refs in "prefixes" by partitioning prefixes into disjoint sets diff --git a/replace-object.c b/replace-object.c index 03d0f1f083bed9..29f7c4903e20de 100644 --- a/replace-object.c +++ b/replace-object.c @@ -107,3 +107,16 @@ int replace_refs_enabled(struct repository *r) /* repository has no objects or refs. */ return 0; } + +static int has_replace_ref(const struct reference *ref UNUSED, + void *data UNUSED) +{ + return 1; +} + +int repo_has_replace_refs_uncached(struct repository *r) +{ + if (!replace_refs_enabled(r)) + return 0; + return refs_for_each_replace_ref_uncached(r, has_replace_ref, NULL) != 0; +} diff --git a/replace-object.h b/replace-object.h index 4c9f2a2383d577..595b4598e7e629 100644 --- a/replace-object.h +++ b/replace-object.h @@ -31,6 +31,13 @@ const struct object_id *do_lookup_replace_object(struct repository *r, */ int replace_refs_enabled(struct repository *r); +/* + * Return whether the repository currently has any replacement objects that + * would be honored by lookup_replace_object(). Do not consult the cached + * replacement map. + */ +int repo_has_replace_refs_uncached(struct repository *r); + /* * If object sha1 should be replaced, return the replacement object's * name (replaced recursively, if necessary). The return value is diff --git a/t/meson.build b/t/meson.build index 242c425550a7b5..0f52f5637b3066 100644 --- a/t/meson.build +++ b/t/meson.build @@ -961,6 +961,7 @@ integration_tests = [ 't7527-builtin-fsmonitor.sh', 't7528-signed-commit-ssh.sh', 't7529-preload-index-apfs.sh', + 't7530-status-clean-sidecar.sh', 't7531-semantic-verify.sh', 't7532-preload-index-linux.sh', 't7600-merge.sh', diff --git a/t/t7508-status.sh b/t/t7508-status.sh index 0fd7c79911572e..8059c64940f165 100755 --- a/t/t7508-status.sh +++ b/t/t7508-status.sh @@ -1789,4 +1789,44 @@ test_expect_success EXPENSIVE,SIZE_T_IS_64BIT 'status does not re-read unchanged ) ' +test_expect_success 'status uses only a matching effective cache-tree' ' + test_when_finished "rm -rf cache-tree-status" && + test_create_repo cache-tree-status && + ( + cd cache-tree-status && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + git add tracked && + git commit -m base && + + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status >.git/clean && + test_grep "nothing to commit, working tree clean" \ + .git/clean && + test_trace2_data status index/cache-tree-match 1 \ + <.git/clean.trace && + + test_write_lines staged >tracked && + git add tracked && + git write-tree >.git/staged-tree && + GIT_TRACE2_EVENT="$PWD/.git/staged.trace" \ + git status >.git/staged && + test_grep "Changes to be committed:" .git/staged && + test_grep "modified:.*tracked" .git/staged && + ! test_trace2_data status index/cache-tree-match 1 \ + <.git/staged.trace && + + replacement_tree=$(cat .git/staged-tree) && + git reset --hard HEAD && + head_tree=$(git rev-parse HEAD^{tree}) && + git replace "$head_tree" "$replacement_tree" && + GIT_TRACE2_EVENT="$PWD/.git/replaced.trace" \ + git status >.git/replaced && + test_grep "Changes to be committed:" .git/replaced && + test_grep "modified:.*tracked" .git/replaced && + ! test_trace2_data status index/cache-tree-match 1 \ + <.git/replaced.trace + ) +' + test_done diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 7587626dc0cd7d..81f8bf59c6a896 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1582,10 +1582,8 @@ test_expect_success 'bound query accepts a capability superset' ' GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status >.git/status.out && - test-tool dump-fsmonitor >.git/fsmonitor && - test_grep \ - "^fsmonitor last update builtin:test-capable:0" \ - .git/fsmonitor && + test_trace2_data fsm_client query/command \ + "builtin:test-capable:0" <.git/status.trace && test_grep ! \ "\"key\":\"query/incompatible-daemon\"" \ .git/status.trace && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh new file mode 100755 index 00000000000000..e3197d42ca3925 --- /dev/null +++ b/t/t7530-status-clean-sidecar.sh @@ -0,0 +1,227 @@ +#!/bin/sh + +test_description='exact clean status sidecars' + +. ./test-lib.sh + +test_lazy_prereq LOCAL_APFS ' + test_have_prereq MACOS && + /bin/df -t apfs "$TRASH_DIRECTORY" >/dev/null +' + +if ! test_have_prereq FSMONITOR_DAEMON,LOCAL_APFS,MACOS +then + skip_all='clean status sidecars require local APFS and the macOS fsmonitor daemon' + test_done +fi + +test_lazy_prereq DURABLE_FSMONITOR ' + test_create_repo durable-fsmonitor-probe || return 1 + ( + cd durable-fsmonitor-probe && + test_commit base tracked && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git status --porcelain=v2 >/dev/null && + test-tool dump-fsmonitor >token && + grep "^fsmonitor last update builtin:" token + result=$? + git fsmonitor--daemon stop >/dev/null 2>&1 || : + exit $result + ) +' + +stop_daemon () { + git -C "$1" fsmonitor--daemon stop 2>/dev/null || : +} + +setup_repo () { + repo=$1 && + test_create_repo "$repo" && + test_commit -C "$repo" base tracked && + test-tool chmtime -120 "$repo/tracked" && + git -C "$repo" update-index --refresh && + git -C "$repo" config core.fsmonitor true && + git -C "$repo" fsmonitor--daemon start --start-timeout=10 +} + +bulk_status () { + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + git "$@" +} + +prime_semantic_history () { + repo=$1 && + bulk_status -C "$repo" status --porcelain=2 >actual.1 && + test_must_be_empty actual.1 && + bulk_status -C "$repo" status --porcelain=2 >actual.2 && + test_must_be_empty actual.2 && + test_grep FSCF "$repo/.git/index" +} + +test_expect_success DURABLE_FSMONITOR \ + 'exact clean status installs a sidecar without rewriting the index' ' + test_when_finished "stop_daemon sidecar-issue" && + setup_repo sidecar-issue && + test_env GIT_TRACE2_EVENT="$PWD/first-scan.trace" \ + bulk_status -C sidecar-issue status --porcelain=v2 \ + >actual.first && + test_must_be_empty actual.first && + test_path_is_missing sidecar-issue/.git/index.csts && + test_grep "\"value\":\"issue-coherent-history\"" first-scan.trace && + + prime_semantic_history sidecar-issue && + git -C sidecar-issue config core.autocrlf false && + cp sidecar-issue/.git/index index.before && + + test_env GIT_TRACE2_EVENT="$PWD/issue.trace" \ + bulk_status -C sidecar-issue status --porcelain=v2 >actual && + test_must_be_empty actual && + test_cmp index.before sidecar-issue/.git/index && + test_path_is_file sidecar-issue/.git/index.csts && + test_grep \ + "\"key\":\"preload/bulk_untracked_complete\",\"value\":\"1\"" \ + issue.trace && + test_grep "\"key\":\"preload/bulk_provider_applied\"" issue.trace && + test_grep "\"key\":\"clean-proof/sidecar\"" issue.trace && + test_grep ! "\"label\":\"do_write_index\"" issue.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'only an exact empty output installs a sidecar' ' + test_when_finished "stop_daemon sidecar-shape" && + setup_repo sidecar-shape && + prime_semantic_history sidecar-shape && + + bulk_status -C sidecar-shape status --porcelain=2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-shape/.git/index.csts && + + bulk_status -C sidecar-shape status --porcelain=v2 --branch >actual && + test_grep "^# branch.oid " actual && + test_path_is_missing sidecar-shape/.git/index.csts && + + echo changed >sidecar-shape/tracked && + bulk_status -C sidecar-shape status --porcelain=v2 >actual && + test_grep "^1 .M " actual && + test_path_is_missing sidecar-shape/.git/index.csts +' + +test_expect_success DURABLE_FSMONITOR \ + 'external attributes, untracked cache, and alternate indexes are rejected' ' + test_when_finished "stop_daemon sidecar-inputs" && + setup_repo sidecar-inputs && + prime_semantic_history sidecar-inputs && + + test_write_lines "tracked -text" \ + >sidecar-inputs/.git/info/attributes && + bulk_status -C sidecar-inputs status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-inputs/.git/index.csts && + + rm sidecar-inputs/.git/info/attributes && + git -C sidecar-inputs config core.untrackedCache true && + bulk_status -C sidecar-inputs status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-inputs/.git/index.csts && + + cp sidecar-inputs/.git/index sidecar-inputs/.git/alternate-index && + test_env GIT_INDEX_FILE="$PWD/sidecar-inputs/.git/alternate-index" \ + bulk_status -C sidecar-inputs status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-inputs/.git/alternate-index.csts +' + +test_expect_success DURABLE_FSMONITOR \ + 'normal status restores namespace-specific history outside the index' ' + test_when_finished "stop_daemon external-history" && + setup_repo external-history && + git -C external-history config core.untrackedCache true && + git -C external-history config status.renameLimit 100 && + git -C external-history update-index \ + --index-version=4 --force-write-index && + prime_semantic_history external-history && + test "$(git -C external-history \ + update-index --show-index-version)" = 4 && + test_grep FSMN external-history/.git/index && + test_grep UNTR external-history/.git/index && + test_grep FSCF external-history/.git/index && + test_grep FSUC external-history/.git/index && + git -C external-history ls-files --stage >baseline.stage && + cp external-history/.git/index namespace-a-v4.index && + + # Namespace B recovers once, but leaves namespace A in the main index. + git -C external-history config status.renameLimit 200 && + cp external-history/.git/index seed.before && + test_env GIT_TRACE2_EVENT="$PWD/external-seed.trace" \ + git -C external-history status >actual.seed && + test_grep "nothing to commit, working tree clean" actual.seed && + test_cmp seed.before external-history/.git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + external-sidecars && + test_line_count = 1 external-sidecars && + sidecar=$(cat external-sidecars) && + + # Namespace A rewrites the same entries in a different physical format. + cp "$sidecar" sidecar.before-rewrite && + git -C external-history config status.renameLimit 100 && + git -C external-history update-index \ + --index-version=2 --force-write-index && + test "$(git -C external-history \ + update-index --show-index-version)" = 2 && + ! cmp namespace-a-v4.index external-history/.git/index && + git -C external-history ls-files --stage >namespace-a-v2.stage && + test_cmp baseline.stage namespace-a-v2.stage && + test_grep FSMN external-history/.git/index && + test_grep UNTR external-history/.git/index && + test_grep FSCF external-history/.git/index && + test_grep FSUC external-history/.git/index && + test_cmp sidecar.before-rewrite "$sidecar" && + + git -C external-history config status.renameLimit 200 && + test_cmp sidecar.before-rewrite "$sidecar" && + cp external-history/.git/index namespace-a-v2.index && + test_env GIT_TRACE2_EVENT="$PWD/external-restore.trace" \ + git -C external-history status >actual.restore && + test_grep "nothing to commit, working tree clean" actual.restore && + test_cmp namespace-a-v2.index external-history/.git/index && + test_trace2_data fsmonitor history/external-restored 1 \ + flush.out && + : >"$sidecar.lock" && + test_when_finished "rm -f \"$sidecar.lock\"" && + cp external-history/.git/index locked.before && + test_env GIT_TRACE2_EVENT="$PWD/external-locked.trace" \ + git -C external-history status >actual.locked && + test_grep "nothing to commit, working tree clean" actual.locked && + test_cmp locked.before external-history/.git/index && + test_trace2_data fsmonitor history/external-restored 1 \ + repo->index; + struct object_id reference_tree; + struct strbuf reference = STRBUF_INIT; + int matches = 0; + + if (!s->allow_clean_status_shortcuts || s->is_initial || + getenv(INDEX_ENVIRONMENT) || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + !istate->cache_tree || + istate->cache_tree->entry_count < 0 || + (unsigned int)istate->cache_tree->entry_count != + istate->cache_nr) + return 0; + + /* + * A replacement below the root can change the effective tree without + * changing the root object name stored in the commit. + */ + if (replace_refs_enabled(s->repo)) { + prepare_replace_object(s->repo); + if (oidmap_get_size(&s->repo->objects->replace_map)) + return 0; + } + + strbuf_addf(&reference, "%s^{tree}", s->reference); + if (!repo_get_oid_tree(s->repo, reference.buf, &reference_tree) && + oideq(&istate->cache_tree->oid, &reference_tree)) + matches = 1; + strbuf_release(&reference); + return matches; +} + static void wt_status_collect_changes_index(struct wt_status *s) { struct rev_info rev; struct setup_revision_opt opt; + if (wt_status_cache_tree_matches_reference(s)) { + trace2_data_intmax("status", s->repo, + "index/cache-tree-match", 1); + return; + } + repo_init_revisions(s->repo, &rev, NULL); memset(&opt, 0, sizeof(opt)); opt.def = s->is_initial ? empty_tree_oid_hex(s->repo->hash_algo) : s->reference; @@ -879,6 +928,109 @@ static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; } +struct wt_status_exclude_context { + int root_fd; +}; + +static void wt_status_release_exclude_proof(struct wt_status *s) +{ + exclude_source_proof_release(s->certify_exclude_proof); + s->certify_exclude_proof = NULL; + if (s->certify_exclude_context) { + if (s->certify_exclude_context->root_fd >= 0) + close(s->certify_exclude_context->root_fd); + FREE_AND_NULL(s->certify_exclude_context); + } + oidclr(&s->certify_exclude_digest, s->repo->hash_algo); + s->certify_exclude_digest_valid = 0; + s->certify_untracked_scan_failed = 0; +} + +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + +static int wt_status_open_exclude_parent(void *data, const char *path) +{ + struct wt_status_exclude_context *context = data; + int flags = O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC | + O_NOFOLLOW; + + if (is_absolute_path(path)) + return open(path, flags); + return openat(context->root_fd, path, flags); +} + +static void wt_status_prepare_exclude_proof( + struct wt_status *s, struct dir_struct *dir) +{ + struct wt_status_exclude_context *context; + const char *worktree; + + if (!s->certify_clean_status) + return; + if (!s->certify_exclude_proof) { + worktree = repo_get_work_tree(s->repo); + if (!worktree) + return; + CALLOC_ARRAY(context, 1); + context->root_fd = open_nofollow( + worktree, + O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC); + if (context->root_fd < 0) { + free(context); + return; + } + s->certify_exclude_context = context; + s->certify_exclude_proof = exclude_source_proof_create( + s->repo->index, context, + wt_status_open_exclude_parent); + } + dir->internal.exclude_source_proof = + s->certify_exclude_proof; +} + +static void wt_status_record_exclude_digest(struct wt_status *s) +{ + if (s->certify_exclude_digest_valid || + !s->certify_exclude_proof) + return; + s->certify_exclude_digest_valid = + !exclude_source_proof_digest( + s->certify_exclude_proof, + s->repo->hash_algo, + &s->certify_exclude_digest); +} + +#else + +static void wt_status_prepare_exclude_proof( + struct wt_status *s UNUSED, struct dir_struct *dir UNUSED) +{ +} + +static void wt_status_record_exclude_digest(struct wt_status *s UNUSED) +{ +} + +#endif + +int wt_status_certified_excludes_digest( + struct wt_status *s, struct object_id *digest, + struct stat *scanned_worktree) +{ + if (s->certify_untracked_scan_failed || + !s->certify_exclude_digest_valid || + !s->certify_exclude_proof || + !s->certify_exclude_context || + s->certify_exclude_context->root_fd < 0 || + !exclude_source_proof_validate( + s->certify_exclude_proof) || + fstat(s->certify_exclude_context->root_fd, + scanned_worktree)) + return -1; + oidcpy(digest, &s->certify_exclude_digest); + return 0; +} + static int wt_status_begin_attr_snapshot(struct wt_status *s) { int ret; @@ -931,6 +1083,9 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) wt_status_begin_attr_snapshot(s); /* Record the provider token before either filesystem traversal. */ refresh_fsmonitor(istate); + if (s->certify_clean_status && + !fsmonitor_has_pending_token(istate)) + fsmonitor_reopen_token(istate); if (s->pathspec.nr || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) @@ -1000,10 +1155,6 @@ static int wt_status_collect_untracked_1( if (!s->show_untracked_files) return 0; - if (s->untracked_from_preload && - !istate->untracked && - !s->show_ignored_mode) - return 0; if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -1016,12 +1167,16 @@ static int wt_status_collect_untracked_1( dir.untracked = istate->untracked; } + wt_status_prepare_exclude_proof(s, &dir); setup_standard_excludes(&dir); + wt_status_record_exclude_digest(s); wt_status_finish_untracked_cache_preload(s); dir.internal.untracked_cache_preloaded = s->untracked_cache_preloaded; fill_directory(&dir, istate, &s->pathspec); + if (s->certify_clean_status && dir.internal.traversal_failed) + s->certify_untracked_scan_failed = 1; used_untracked_cache = dir.untracked && dir.untracked == istate->untracked; @@ -1099,6 +1254,8 @@ static int wt_status_collect_untracked(struct wt_status *s) { if (s->untracked_from_token_closure && !s->show_ignored_mode) return 1; + if (s->untracked_from_preload && !s->show_ignored_mode) + return 0; return wt_status_collect_untracked_1( s, &s->untracked, &s->ignored); } @@ -1157,6 +1314,22 @@ static void wt_status_publish_staged_untracked( closure->staged_untracked_ready = 0; } +static int wt_status_untracked_cache_valid( + const struct wt_status_token_closure *closure) +{ + const struct index_state *istate = closure->status->repo->index; + + return closure->untracked_ready && + istate->untracked && istate->untracked->root; +} + +static void wt_status_record_bulk_untracked( + struct wt_status_token_closure *closure) +{ + if (closure->status->repo->index->preload_untracked_complete) + closure->untracked_proof_complete = 1; +} + static int fsmonitor_token_requires_rescan(enum fsmonitor_token_result result) { return result == FSMONITOR_TOKEN_CHANGED || @@ -1225,22 +1398,6 @@ static void wt_status_refresh_for_token( istate->preload_bulk_proof_epoch = NULL; } -static int wt_status_untracked_cache_valid( - const struct wt_status_token_closure *closure) -{ - const struct index_state *istate = closure->status->repo->index; - - return closure->untracked_ready && - istate->untracked && istate->untracked->root; -} - -static void wt_status_record_bulk_untracked( - struct wt_status_token_closure *closure) -{ - if (closure->status->repo->index->preload_untracked_complete) - closure->untracked_proof_complete = 1; -} - static int wt_status_close_ordinary_fsmonitor_token( struct wt_status_token_closure *closure, int refreshed_before_closure) @@ -1592,6 +1749,12 @@ void wt_status_invalidate_refresh(struct wt_status *s) { struct index_state *istate = s->repo->index; + if (s->untracked_from_token_closure) { + string_list_clear(&s->untracked, 0); + string_list_clear(&s->ignored, 0); + s->untracked_from_token_closure = 0; + } + wt_status_release_exclude_proof(s); wt_status_release_attr_snapshot(s); if (!s->pathspec.nr && !istate->split_index && fsmonitor_reopen_token(istate)) @@ -1667,6 +1830,7 @@ void wt_status_collect_free_buffers(struct wt_status *s) { untracked_cache_preload_release(s->untracked_cache_preload); s->untracked_cache_preload = NULL; + wt_status_release_exclude_proof(s); wt_status_release_attr_snapshot(s); wt_status_state_free_buffers(&s->state); } diff --git a/wt-status.h b/wt-status.h index 99b005cb8b5cea..afee3b4ad9840c 100644 --- a/wt-status.h +++ b/wt-status.h @@ -7,7 +7,10 @@ #include "remote.h" struct repository; +struct stat; struct attr_source_snapshot; +struct exclude_source_proof; +struct wt_status_exclude_context; struct worktree; struct untracked_cache_preload; @@ -140,6 +143,8 @@ struct wt_status { /* These are computed during processing of the individual sections */ int committable; int workdir_dirty; + unsigned allow_clean_status_shortcuts : 1; + unsigned certify_clean_status : 1; unsigned untracked_from_token_closure : 1; unsigned untracked_from_preload : 1; unsigned bulk_update_index_stat : 1; @@ -152,8 +157,13 @@ struct wt_status { uint32_t untracked_in_ms; struct untracked_cache_preload *untracked_cache_preload; struct attr_source_snapshot *attr_source_snapshot; + struct exclude_source_proof *certify_exclude_proof; + struct wt_status_exclude_context *certify_exclude_context; + struct object_id certify_exclude_digest; unsigned untracked_cache_preloaded : 1; unsigned attr_snapshot_failed : 1; + unsigned certify_exclude_digest_valid : 1; + unsigned certify_untracked_scan_failed : 1; }; size_t wt_status_locate_end(const char *s, size_t len); @@ -171,6 +181,9 @@ int wt_status_refresh_index(struct wt_status *s, unsigned int refresh_flags, int require_untracked); void wt_status_invalidate_refresh(struct wt_status *s); +int wt_status_certified_excludes_digest( + struct wt_status *s, struct object_id *digest, + struct stat *scanned_worktree); /* * Collect all changes between the two trees. Changes will be displayed as if From 03e6c19751992c277c87811144a3221c47695367 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:16:38 -0500 Subject: [PATCH 226/403] status: load bounded clean-status sidecars An installed sidecar cannot be inspected safely by opening an untrusted adjacent path without bounds. A symbolic link, named pipe, oversized record, or growing file could redirect the read, block status, or consume unbounded memory. Open the named sidecar without following symbolic links and request a nonblocking descriptor. Accept only a regular file of at most 8192 bytes, read exactly its recorded size, reject an additional byte, and parse its checksummed contents into caller-owned storage. Clear failed records and release storage explicitly. Platforms without nonblocking support fail closed. Extend the registered store unit suite to cover owned token storage under both object formats, symbolic links, FIFOs, and an oversized 8193-byte record. The loader is testable at this boundary; it does not yet bypass index deserialization. Signed-off-by: Taylor Blau --- clean-status-sidecar.c | 44 +++++++++++ clean-status-sidecar.h | 16 +++- t/unit-tests/u-clean-status-store.c | 116 ++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index a68d3dc8059367..95db486a47b289 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -19,6 +19,7 @@ #include "wrapper.h" #define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" +#define CLEAN_STATUS_SIDECAR_MAX_SIZE 8192 #define CLEAN_STATUS_FILESYSTEM_ID_SIZE 16 struct clean_status_filesystem_id { @@ -163,6 +164,49 @@ static int open_nofollow_nonblocking(const char *path, int flags) #endif } +int clean_status_sidecar_load( + const char *index_path, const struct git_hash_algo *algo, + struct clean_status_sidecar_record *record) +{ + struct stat st; + char extra; + char *path = sidecar_path(index_path); + int fd = -1, ret = -1; + size_t size; + + memset(&record->sidecar, 0, sizeof(record->sidecar)); + strbuf_reset(&record->storage); + fd = open_nofollow_nonblocking(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || fstat(fd, &st) || !S_ISREG(st.st_mode) || + st.st_size < 0 || st.st_size > CLEAN_STATUS_SIDECAR_MAX_SIZE) + goto done; + size = xsize_t(st.st_size); + strbuf_grow(&record->storage, size); + strbuf_setlen(&record->storage, size); + if ((size_t)read_in_full(fd, record->storage.buf, size) != size || + read(fd, &extra, 1) != 0 || + clean_status_sidecar_parse(&record->sidecar, + record->storage.buf, + record->storage.len, algo)) + goto done; + ret = 0; + +done: + if (ret) + strbuf_reset(&record->storage); + if (fd >= 0) + close(fd); + free(path); + return ret; +} + +void clean_status_sidecar_record_release( + struct clean_status_sidecar_record *record) +{ + strbuf_release(&record->storage); + memset(&record->sidecar, 0, sizeof(record->sidecar)); +} + static int local_apfs_id(int fd MAYBE_UNUSED, struct clean_status_filesystem_id *id) { diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index 963d6bccc6b65a..8149acbe5b86bb 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -3,11 +3,11 @@ #include "clean-status-identity.h" #include "hash.h" +#include "strbuf.h" struct clean_status_index_snapshot; struct attr_source_snapshot; struct repository; -struct strbuf; struct stat; #define CLEAN_STATUS_SIDECAR_VERSION 1 @@ -29,12 +29,26 @@ struct clean_status_sidecar { size_t token_len; }; +struct clean_status_sidecar_record { + struct clean_status_sidecar sidecar; + struct strbuf storage; +}; + +#define CLEAN_STATUS_SIDECAR_RECORD_INIT { \ + .storage = STRBUF_INIT, \ +} + int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, const void *data, size_t len, const struct git_hash_algo *algo); int clean_status_sidecar_write(struct strbuf *out, const struct clean_status_sidecar *sidecar, const struct git_hash_algo *algo); +int clean_status_sidecar_load( + const char *index_path, const struct git_hash_algo *algo, + struct clean_status_sidecar_record *record); +void clean_status_sidecar_record_release( + struct clean_status_sidecar_record *record); int clean_status_sidecar_pin_source( const char *index_path, const struct clean_status_sidecar *sidecar, const struct git_hash_algo *algo, diff --git a/t/unit-tests/u-clean-status-store.c b/t/unit-tests/u-clean-status-store.c index ac25bb225e3c2e..4c2e5739aaceea 100644 --- a/t/unit-tests/u-clean-status-store.c +++ b/t/unit-tests/u-clean-status-store.c @@ -82,6 +82,122 @@ static struct strbuf sidecar_path(struct store_fixture *fixture) return path; } +#ifdef O_NONBLOCK +static void write_fixture_sidecar(struct store_fixture *fixture, + const struct git_hash_algo *algo) +{ + struct strbuf encoded = STRBUF_INIT; + struct strbuf path = sidecar_path(fixture); + + cl_assert_equal_i(clean_status_sidecar_write( + &encoded, &fixture->sidecar, algo), 0); + write_file_buf(path.buf, encoded.buf, encoded.len); + strbuf_release(&path); + strbuf_release(&encoded); +} + +static void assert_loads_sidecar(const struct git_hash_algo *algo) +{ + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct store_fixture fixture; + + fixture_init(&fixture, algo); + write_fixture_sidecar(&fixture, algo); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), 0); + cl_assert(clean_status_identity_equal( + &record.sidecar.identity, &fixture.sidecar.identity)); + cl_assert_equal_i(record.sidecar.proof.index_version, + fixture.sidecar.proof.index_version); + cl_assert_equal_i(record.sidecar.proof.cache_nr, + fixture.sidecar.proof.cache_nr); + cl_assert(oideq(&record.sidecar.proof.index_checksum, + &fixture.sidecar.proof.index_checksum)); + cl_assert(oideq(&record.sidecar.proof.head_tree, + &fixture.sidecar.proof.head_tree)); + cl_assert(!memcmp(record.sidecar.proof.config_hash, + fixture.sidecar.proof.config_hash, algo->rawsz)); + cl_assert(!memcmp(record.sidecar.proof.repo_hash, + fixture.sidecar.proof.repo_hash, algo->rawsz)); + cl_assert(oideq(&record.sidecar.proof.exclude_source_digest, + &fixture.sidecar.proof.exclude_source_digest)); + cl_assert_equal_i(record.sidecar.token_len, fixture.sidecar.token_len); + cl_assert(!memcmp(record.sidecar.token, fixture.sidecar.token, + record.sidecar.token_len)); + cl_assert(record.sidecar.token >= + (const unsigned char *)record.storage.buf); + cl_assert(record.sidecar.token + record.sidecar.token_len <= + (const unsigned char *)record.storage.buf + + record.storage.len); + + clean_status_sidecar_record_release(&record); + fixture_release(&fixture); +} +#endif + +void test_clean_status_store__loads_owned_sidecars_in_both_object_formats(void) +{ +#ifdef O_NONBLOCK + assert_loads_sidecar(&hash_algos[GIT_HASH_SHA1]); + assert_loads_sidecar(&hash_algos[GIT_HASH_SHA256]); +#else + cl_skip(); +#endif +} + +void test_clean_status_store__rejects_nonregular_sidecars(void) +{ +#if defined(O_NONBLOCK) && !defined(GIT_WINDOWS_NATIVE) + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct store_fixture fixture; + struct strbuf path, target = STRBUF_INIT; + + fixture_init(&fixture, algo); + path = sidecar_path(&fixture); + strbuf_addf(&target, "%s/target", fixture.directory); + write_file(target.buf, "target"); + cl_assert_equal_i(symlink(target.buf, path.buf), 0); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), -1); + cl_assert_equal_i(unlink(path.buf), 0); + cl_assert_equal_i(mkfifo(path.buf, 0600), 0); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), -1); + + clean_status_sidecar_record_release(&record); + strbuf_release(&target); + strbuf_release(&path); + fixture_release(&fixture); +#else + cl_skip(); +#endif +} + +void test_clean_status_store__rejects_oversized_sidecars(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct store_fixture fixture; + struct strbuf oversized = STRBUF_INIT; + struct strbuf path; + + fixture_init(&fixture, algo); + path = sidecar_path(&fixture); + strbuf_addchars(&oversized, 'x', 8193); + write_file_buf(path.buf, oversized.buf, oversized.len); + cl_assert_equal_i(clean_status_sidecar_load( + fixture.index_path.buf, algo, &record), -1); + + clean_status_sidecar_record_release(&record); + strbuf_release(&oversized); + strbuf_release(&path); + fixture_release(&fixture); +} + static void require_local_apfs(const char *path MAYBE_UNUSED) { #ifdef __APPLE__ From c29ca752105aa2a7de84fdc22dbbb8bf0a97bb3e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:12 -0500 Subject: [PATCH 227/403] exclude: support nonblocking proof captures Validating a sidecar must recapture standard excludes before status can trust an empty result. Opening an exclude source that has become a named pipe may otherwise block the supposedly cheap validation. Add an explicit nonblocking flag to exclude-source proof creation and carry it into the existing anchored source-open operation. Reject unknown flags, request nonblocking captures for sidecar issuance, and update the existing bulk-scan and unit-test callers to pass zero, preserving their current blocking and symbolic-link policies. Add a focused FIFO unit test showing that an opted-in proof captures and validates an empty pipe without waiting. The later early-status consumer can reuse nonblocking capture without changing ordinary exclude handling. Signed-off-by: Taylor Blau --- exclude-source-proof.c | 11 ++++++++--- exclude-source-proof.h | 6 +++++- preload-index-bulk.c | 2 +- t/unit-tests/u-exclude-source-proof.c | 28 +++++++++++++++++++++++---- wt-status.c | 3 ++- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/exclude-source-proof.c b/exclude-source-proof.c index 1a2ebd5f87190b..83ee1626d93746 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -29,6 +29,7 @@ struct exclude_source_proof { struct strintmap entries_by_path[2]; size_t nr; size_t alloc; + unsigned nonblocking : 1; unsigned invalid : 1; }; @@ -187,7 +188,7 @@ static struct exclude_source_capture *capture_begin( struct exclude_source_proof *exclude_source_proof_create( struct index_state *istate, void *open_data, - exclude_source_open_parent_fn open_parent) + exclude_source_open_parent_fn open_parent, unsigned flags) { struct exclude_source_proof *proof; @@ -195,13 +196,16 @@ struct exclude_source_proof *exclude_source_proof_create( proof->istate = istate; proof->open_data = open_data; proof->open_parent = open_parent; + proof->nonblocking = + !!(flags & EXCLUDE_SOURCE_PROOF_NONBLOCKING); strintmap_init_with_options(&proof->entries_by_path[0], -1, NULL, 0); strintmap_init_with_options(&proof->entries_by_path[1], -1, NULL, 0); if (!EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN || !istate || !istate->repo || !istate->repo->hash_algo || - !open_parent) + !open_parent || + (flags & ~EXCLUDE_SOURCE_PROOF_NONBLOCKING)) proof->invalid = 1; return proof; } @@ -220,7 +224,8 @@ int exclude_source_capture_open(struct exclude_source_capture *capture) return -1; } return open_source_at(capture->parent_fd, capture->relative, - capture->nofollow, 0); + capture->nofollow, + capture->proof->nonblocking); } int exclude_source_capture_absent(struct exclude_source_capture *capture) diff --git a/exclude-source-proof.h b/exclude-source-proof.h index f1c03b4a3cbf5f..ab9b626c664380 100644 --- a/exclude-source-proof.h +++ b/exclude-source-proof.h @@ -19,9 +19,13 @@ struct stat; typedef int (*exclude_source_open_parent_fn)(void *data, const char *path); +enum exclude_source_proof_flags { + EXCLUDE_SOURCE_PROOF_NONBLOCKING = (1 << 0), +}; + struct exclude_source_proof *exclude_source_proof_create( struct index_state *istate, void *open_data, - exclude_source_open_parent_fn open_parent); + exclude_source_open_parent_fn open_parent, unsigned flags); struct exclude_source_capture *exclude_source_capture_begin( struct exclude_source_proof *proof, const char *path, int nofollow); diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 9aa15920b530b0..92ce36e8fe4850 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -249,7 +249,7 @@ int preload_bulk_collect(struct index_state *istate, int threads, if (!start_error) { if (scan.collect_untracked) { exclude_proof = exclude_source_proof_create( - istate, &scan, open_exclude_parent); + istate, &scan, open_exclude_parent, 0); exclude_dir.internal.exclude_source_proof = exclude_proof; setup_standard_excludes(&exclude_dir); diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index e579dbcd51247a..be1f2d046599b1 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -29,7 +29,7 @@ static int open_parent(void *data UNUSED, const char *path) static struct exclude_source_proof *new_proof(void) { return exclude_source_proof_create( - &istate, NULL, open_parent); + &istate, NULL, open_parent, 0); } static char *make_path(const char *name) @@ -201,7 +201,7 @@ void test_exclude_source_proof__rejects_conflicting_observations(void) void test_exclude_source_proof__digest_deduplicates_and_ignores_identity(void) { struct exclude_source_proof *first_proof = - exclude_source_proof_create(&istate, NULL, open_parent); + exclude_source_proof_create(&istate, NULL, open_parent, 0); struct exclude_source_proof *second_proof; struct object_id first, second; char *parent = make_path("parent"); @@ -216,7 +216,7 @@ void test_exclude_source_proof__digest_deduplicates_and_ignores_identity(void) cl_must_pass(unlink(source)); write_file_buf(source, "content", 7); second_proof = - exclude_source_proof_create(&istate, NULL, open_parent); + exclude_source_proof_create(&istate, NULL, open_parent, 0); record_file(second_proof, source); record_file(second_proof, source); cl_must_pass(exclude_source_proof_digest( @@ -249,7 +249,7 @@ void test_exclude_source_proof__rejects_open_failure(void) void test_exclude_source_proof__fails_closed_without_parent_opener(void) { struct exclude_source_proof *proof = - exclude_source_proof_create(&istate, NULL, NULL); + exclude_source_proof_create(&istate, NULL, NULL, 0); cl_assert(!exclude_source_capture_begin(proof, "/dev/null", 0)); cl_assert(!exclude_source_proof_validate(proof)); @@ -410,6 +410,25 @@ void test_exclude_source_proof__rejects_nonempty_fifo_replacement(void) free(parent); } +void test_exclude_source_proof__captures_fifo_without_blocking(void) +{ + struct exclude_source_proof *proof = + exclude_source_proof_create( + &istate, NULL, open_parent, + EXCLUDE_SOURCE_PROOF_NONBLOCKING); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + cl_must_pass(mkfifo(source, 0600)); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + #else #define EMPTY_TEST(name) void name(void) {} @@ -432,5 +451,6 @@ SKIP_TEST(test_exclude_source_proof__reresolves_absent_source_parent) SKIP_TEST(test_exclude_source_proof__accepts_dev_null) SKIP_TEST(test_exclude_source_proof__accepts_empty_fifo_replacement) SKIP_TEST(test_exclude_source_proof__rejects_nonempty_fifo_replacement) +SKIP_TEST(test_exclude_source_proof__captures_fifo_without_blocking) #endif diff --git a/wt-status.c b/wt-status.c index 5eb618ec0f4e04..4de9940765ceeb 100644 --- a/wt-status.c +++ b/wt-status.c @@ -982,7 +982,8 @@ static void wt_status_prepare_exclude_proof( s->certify_exclude_context = context; s->certify_exclude_proof = exclude_source_proof_create( s->repo->index, context, - wt_status_open_exclude_parent); + wt_status_open_exclude_parent, + EXCLUDE_SOURCE_PROOF_NONBLOCKING); } dir->internal.exclude_source_proof = s->certify_exclude_proof; From 6e3e253671af7c9084e15b5f2138688fb41c36f3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:26 -0500 Subject: [PATCH 228/403] status: answer exact clean status before index deserialization An issued clean-status sidecar has no latency benefit while status still deserializes the index before checking it. Moving the check earlier is safe only if the recorded proof is revalidated around an empty builtin-fsmonitor delta. Attempt the sidecar only for the literal top-level porcelain-v2 command on an eligible main worktree. Load the bounded record, pin the named local-APFS index, recapture excludes without blocking, and check configuration, attributes, repository identity, HEAD, and provider mode. Query the builtin provider directly from the stored token. Keep the attribute and exclude proofs alive across that query. Recheck configuration, HEAD, fresh replacement-ref and repository state, attribute contents and namespace, exclude-source identity, and both the held and named index before accepting an empty delta. Return without deserializing index entries only when every check succeeds; otherwise continue through ordinary status. Unsupported anchored-open platforms take that ordinary path. Register the fast-path source with Make and Meson. Extend the existing sidecar integration suite for read-only hits, dirty worktree shapes, loose, packed, and custom replacement refs, sidecar and exclude FIFOs, changed configuration, attributes, HEAD, null-checksum indexes, and post-query replacement or exclude races. Signed-off-by: Taylor Blau --- Makefile | 1 + builtin/commit.c | 12 + clean-status-fast.c | 252 ++++++++++++++++ clean-status-internal.h | 2 - clean-status.h | 5 + fsmonitor.c | 2 +- fsmonitor.h | 2 + meson.build | 1 + t/t7519-status-fsmonitor.sh | 70 +++++ t/t7530-status-clean-sidecar.sh | 507 +++++++++++++++++++++++++++++++- wt-status.c | 43 +++ wt-status.h | 1 + 12 files changed, 888 insertions(+), 10 deletions(-) create mode 100644 clean-status-fast.c diff --git a/Makefile b/Makefile index 0ee895f3dd106c..46601baeddb8a8 100644 --- a/Makefile +++ b/Makefile @@ -1137,6 +1137,7 @@ LIB_OBJS += clean-status-identity.o LIB_OBJS += clean-status-index.o LIB_OBJS += clean-status-manifest.o LIB_OBJS += clean-status-sidecar.o +LIB_OBJS += clean-status-fast.o LIB_OBJS += clean-status-sidecar-issue.o LIB_OBJS += color.o LIB_OBJS += column.o diff --git a/builtin/commit.c b/builtin/commit.c index feff3c8df156d5..d9bf5270a0e030 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1617,6 +1617,7 @@ struct repository *repo UNUSED) int default_status_command = argc == 1 && (!prefix || !*prefix); int exact_clean_command = argc == 2 && !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); + int exact_clean_query; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1703,6 +1704,17 @@ struct repository *repo UNUSED) default_status_command && !s.pathspec.nr; if (s.allow_clean_status_shortcuts) clean_status_enable_external_history(the_repository); + exact_clean_query = exact_clean_command && + status_format == STATUS_FORMAT_PORCELAIN_V2 && + !s.pathspec.nr && !s.show_branch && !s.show_stash && + !s.show_ignored_mode && !s.null_termination && !s.verbose && + s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES; + s.certify_clean_status = exact_clean_query; + if (exact_clean_query && + clean_status_try_sidecar(the_repository, &clean_digest)) { + wt_status_collect_free_buffers(&s); + return 0; + } if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) diff --git a/clean-status-fast.c b/clean-status-fast.c new file mode 100644 index 00000000000000..f41951079f2094 --- /dev/null +++ b/clean-status-fast.c @@ -0,0 +1,252 @@ +#include "git-compat-util.h" +#include "abspath.h" +#include "attr-fingerprint.h" +#include "clean-status.h" +#include "clean-status-index.h" +#include "clean-status-sidecar.h" +#include "dir.h" +#include "environment.h" +#include "exclude-source-proof.h" +#include "fsmonitor.h" +#include "fsmonitor-settings.h" +#include "object-name.h" +#include "repository.h" +#include "trace2.h" +#include "worktree.h" +#include "wrapper.h" + +#if !EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + +int clean_status_try_sidecar( + struct repository *repo UNUSED, + const struct clean_status_config_digest *config UNUSED) +{ + return 0; +} + +#else + +struct fast_exclude_context { + int root_fd; +}; + +static void trace_miss(struct repository *repo, const char *reason) +{ + trace2_data_string("status", repo, "clean-proof/miss", reason); +} + +static int open_exclude_parent(void *data, const char *path) +{ + struct fast_exclude_context *context = data; + int flags = O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC; + +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + if (is_absolute_path(path)) + return open(path, flags); + return openat(context->root_fd, path, flags); +} + +static int capture_standard_excludes( + struct repository *repo, struct fast_exclude_context *context, + struct exclude_source_proof **proof, struct object_id *digest) +{ + struct dir_struct dir = DIR_INIT; + int ret; + + *proof = exclude_source_proof_create( + repo->index, context, open_exclude_parent, + EXCLUDE_SOURCE_PROOF_NONBLOCKING); + dir.internal.exclude_source_proof = *proof; + setup_standard_excludes(&dir); + ret = exclude_source_proof_digest(*proof, repo->hash_algo, digest); + dir_clear(&dir); + return ret; +} + +static int attr_snapshot_still_matches( + struct repository *repo, const struct attr_source_snapshot *snapshot) +{ + const struct attr_fingerprint *expected = + attr_source_snapshot_fingerprint(snapshot); + struct attr_fingerprint current; + + return expected && + !attr_fingerprint_repository(repo, ¤t) && + current.sources_present == expected->sources_present && + !memcmp(current.content_hash, expected->content_hash, + repo->hash_algo->rawsz) && + !memcmp(current.namespace_hash, expected->namespace_hash, + repo->hash_algo->rawsz); +} + +static int fast_path_test_barrier(void) +{ + const char *ready = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_READY"); + const char *resume = + getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_RESUME"); + struct strbuf buf = STRBUF_INIT; + int fd; + int ret; + + if (!ready && !resume) + return 0; + if (!ready || !resume) + return -1; + fd = open(resume, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + write_file(ready, "ready"); + ret = strbuf_read(&buf, fd, 1) > 0 ? 0 : -1; + close(fd); + strbuf_release(&buf); + return ret; +} + +static int current_worktree_is_main(struct repository *repo) +{ + struct worktree *worktree = get_current_worktree(repo); + int ret = worktree && is_main_worktree(worktree); + + free_worktree(worktree); + return ret; +} + +int clean_status_try_sidecar( + struct repository *repo, + const struct clean_status_config_digest *config) +{ + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct clean_status_index_snapshot index = { .fd = -1 }; + struct attr_source_snapshot *attrs = NULL; + struct exclude_source_proof *excludes = NULL; + struct fast_exclude_context exclude_context = { .root_fd = -1 }; + struct fsmonitor_query_result query = FSMONITOR_QUERY_RESULT_INIT; + struct clean_status_config_digest fresh_config; + struct object_id exclude_digest, head_tree; + struct stat scanned_worktree; + unsigned char repo_hash[GIT_MAX_RAWSZ]; + char *query_token = NULL; + int ret = 0; + + if (!config->finalized || config->filter_configured || + getenv(INDEX_ENVIRONMENT) || is_bare_repository(repo) || + !repo_get_work_tree(repo) || + !current_worktree_is_main(repo) || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC) { + trace_miss(repo, "fast-repository-shape"); + goto done; + } + if (clean_status_sidecar_load( + repo->index_file, repo->hash_algo, &record)) { + trace_miss(repo, "fast-sidecar-missing-or-corrupt"); + goto done; + } + if (clean_status_sidecar_pin_source( + repo->index_file, &record.sidecar, repo->hash_algo, + &index)) { + trace_miss(repo, "fast-index-mismatch"); + goto done; + } + if (memcmp(config->hash, record.sidecar.proof.config_hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-config-changed"); + goto done; + } + if (attr_source_snapshot_repository(repo, &attrs)) { + trace_miss(repo, "fast-attributes"); + goto done; + } + exclude_context.root_fd = open_nofollow( + repo_get_work_tree(repo), + O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC); + if (exclude_context.root_fd < 0 || + fstat(exclude_context.root_fd, &scanned_worktree) || + capture_standard_excludes( + repo, &exclude_context, &excludes, &exclude_digest) || + !oideq(&exclude_digest, + &record.sidecar.proof.exclude_source_digest)) { + trace_miss(repo, "fast-excludes"); + goto done; + } + if (clean_status_repository_fingerprint( + repo, attrs, &index, &scanned_worktree, repo_hash)) { + trace_miss(repo, "fast-repository-unavailable"); + goto done; + } + if (memcmp(repo_hash, record.sidecar.proof.repo_hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-repository-input"); + goto done; + } + if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || + !oideq(&head_tree, &record.sidecar.proof.head_tree)) { + trace_miss(repo, "fast-head-changed"); + goto done; + } + + query_token = xmemdupz( + record.sidecar.token, record.sidecar.token_len); + if (query_builtin_fsmonitor(query_token, &query) != + FSMONITOR_QUERY_DELTA || + query.paths.len) { + trace_miss(repo, "fast-provider-changed"); + goto done; + } + if (fast_path_test_barrier()) { + trace_miss(repo, "fast-test-barrier"); + goto done; + } + + if (clean_status_config_read_repository(repo, &fresh_config) || + fresh_config.filter_configured || + memcmp(fresh_config.hash, config->hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-config-raced"); + goto done; + } + if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || + !oideq(&head_tree, &record.sidecar.proof.head_tree)) { + trace_miss(repo, "fast-head-raced"); + goto done; + } + if (clean_status_repository_fingerprint( + repo, attrs, &index, &scanned_worktree, repo_hash) || + memcmp(repo_hash, record.sidecar.proof.repo_hash, + repo->hash_algo->rawsz)) { + trace_miss(repo, "fast-repository-raced"); + goto done; + } + if (!attr_snapshot_still_matches(repo, attrs)) { + trace_miss(repo, "fast-attributes-raced"); + goto done; + } + if (!exclude_source_proof_validate(excludes)) { + trace_miss(repo, "fast-excludes-raced"); + goto done; + } + if (!clean_status_index_snapshot_still_matches_path( + &index, repo->index_file, repo->hash_algo)) { + trace_miss(repo, "fast-index-raced"); + goto done; + } + + trace2_data_intmax("status", repo, "clean-proof/hit", 1); + ret = 1; + +done: + free(query_token); + fsmonitor_query_result_release(&query); + if (exclude_context.root_fd >= 0) + close(exclude_context.root_fd); + exclude_source_proof_release(excludes); + attr_source_snapshot_free(attrs); + clean_status_index_snapshot_release(&index); + clean_status_sidecar_record_release(&record); + return ret; +} + +#endif /* EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN */ diff --git a/clean-status-internal.h b/clean-status-internal.h index 62f73acfcd00cf..f37fdcad4ca79e 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -47,7 +47,5 @@ struct clean_status_state { }; struct clean_status_state *clean_status_get_state(struct index_state *istate); -int clean_status_revalidated_token_matches( - const struct index_state *istate); #endif /* CLEAN_STATUS_INTERNAL_H */ diff --git a/clean-status.h b/clean-status.h index f3db36e3ea21ff..8aea521a3bb4bf 100644 --- a/clean-status.h +++ b/clean-status.h @@ -51,6 +51,8 @@ void clean_status_release_proof_epoch( int clean_status_fsmonitor_config_mismatch(const struct index_state *istate); int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); +int clean_status_revalidated_token_matches( + const struct index_state *istate); int clean_status_has_persistent_fsmonitor_semantic_history( const struct index_state *istate); @@ -85,6 +87,9 @@ int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, struct lock_file *index_lock); +int clean_status_try_sidecar( + struct repository *repo, + const struct clean_status_config_digest *config); int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); diff --git a/fsmonitor.c b/fsmonitor.c index ee15d75bab4ca5..e8d91d6681cc09 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -839,7 +839,7 @@ enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( return FSMONITOR_QUERY_ERROR; } -static enum fsmonitor_query_outcome query_builtin_fsmonitor( +enum fsmonitor_query_outcome query_builtin_fsmonitor( const char *since_token, struct fsmonitor_query_result *result) { const char *test_sequence = diff --git a/fsmonitor.h b/fsmonitor.h index e6c617bec77f04..136f4769c36fc2 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -37,6 +37,8 @@ struct fsmonitor_query_result { void fsmonitor_query_result_release(struct fsmonitor_query_result *result); enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( const struct strbuf *raw, struct fsmonitor_query_result *result); +enum fsmonitor_query_outcome query_builtin_fsmonitor( + const char *since_token, struct fsmonitor_query_result *result); /* * A pathname monitor cannot prove that every name for a multiply-linked diff --git a/meson.build b/meson.build index 0d2ab0d178df09..e584fe0f7b36dd 100644 --- a/meson.build +++ b/meson.build @@ -342,6 +342,7 @@ libgit_sources = [ 'clean-status-index.c', 'clean-status-manifest.c', 'clean-status-sidecar.c', + 'clean-status-fast.c', 'clean-status-sidecar-issue.c', 'color.c', 'column.c', diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 66c5a3a28bfdf3..a82dd36019f7f6 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -594,6 +594,76 @@ prepare_builtin_closure_repo () { ) } +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'bare status reuses a current tracked fsmonitor proof' ' + test_when_finished "rm -rf builtin-tracked-clean" && + prepare_builtin_closure_repo builtin-tracked-clean && + ( + cd builtin-tracked-clean && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/prime && + test_must_be_empty .git/prime && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status >.git/clean && + test_grep "nothing to commit, working tree clean" \ + .git/clean && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/clean.trace && + test_trace2_data status index/cache-tree-match 1 \ + <.git/clean.trace && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/exact.trace" \ + git status --porcelain=v2 >.git/exact && + test_must_be_empty .git/exact && + ! test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/exact.trace && + test_grep \ + "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/exact.trace && + + test_write_lines changed >tracked && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/dirty.trace" \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + ! test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/dirty.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout -- tracked && + test_write_lines staged >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git write-tree >.git/staged-tree && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain >.git/staged-prime && + test_grep "^M tracked$" .git/staged-prime && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/staged.trace" \ + git status >.git/staged && + test_grep "Changes to be committed:" .git/staged && + test_grep "modified:.*tracked" .git/staged && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/staged.trace && + ! test_trace2_data status index/cache-tree-match 1 \ + <.git/staged.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin closure initializes a new untracked cache' ' test_when_finished "rm -rf builtin-closure-new-uc" && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index e3197d42ca3925..93b3705a089173 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -60,6 +60,138 @@ prime_semantic_history () { test_grep FSCF "$repo/.git/index" } +issue_sidecar () { + repo=$1 && + prime_semantic_history "$repo" && + git -C "$repo" config core.autocrlf false && + bulk_status -C "$repo" status --porcelain=v2 >actual.issue && + test_must_be_empty actual.issue && + test_path_is_file "$repo/.git/index.csts" +} + +assert_fallback_matches_oracle () { + repo=$1 && + sidecar_trace=$2 && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C "$repo" \ + status --porcelain=v2 >expect && + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/$sidecar_trace" \ + git -C "$repo" status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep ! "\"key\":\"clean-proof/hit\"" "$sidecar_trace" +} + +assert_custom_replace_fallback_matches_oracle () { + repo=$1 && + sidecar_trace=$2 && + GIT_REPLACE_REF_BASE=refs/status-replace/ \ + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C "$repo" \ + status --porcelain=v2 >expect && + GIT_REPLACE_REF_BASE=refs/status-replace/ \ + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/$sidecar_trace" \ + git -C "$repo" status --porcelain=v2 >actual && + test_cmp expect actual && + test_grep ! "\"key\":\"clean-proof/hit\"" "$sidecar_trace" +} + +replacement_tree () { + repo=$1 && + blob=$(printf "replacement\n" | + git -C "$repo" hash-object -w --stdin) && + printf "100644 blob %s\ttracked\n" "$blob" | + git -C "$repo" mktree +} + +cleanup_fast_race () { + if test -n "$status_pid" + then + kill "$status_pid" 2>/dev/null || : + wait "$status_pid" 2>/dev/null || : + fi && + status_pid= && + exec 9>&- && + rm -f "$ready" "$resume" +} + +wait_for_fast_ready () { + for i in $(test_seq 1 1000) + do + test "$(cat "$ready" 2>/dev/null)" = ready && return 0 + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +start_fast_raced_status () { + repo=$1 && + ready=$TRASH_DIRECTORY/$repo.fast-ready && + resume=$TRASH_DIRECTORY/$repo.fast-resume && + race_trace=$TRASH_DIRECTORY/$repo.fast-trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + : >"$ready" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_READY="$ready" \ + GIT_TEST_STATUS_CLEAN_SIDECAR_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + git -C "$repo" status --porcelain=v2 \ + >raced.actual 9>&- & + status_pid=$! + } && + wait_for_fast_ready +} + +start_issue_raced_status () { + repo=$1 && + ready=$TRASH_DIRECTORY/$repo.issue-ready && + resume=$TRASH_DIRECTORY/$repo.issue-resume && + race_trace=$TRASH_DIRECTORY/$repo.issue-trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + : >"$ready" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + test_env \ + GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_READY="$ready" \ + GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + bulk_status -C "$repo" status --porcelain=v2 \ + >raced.actual 9>&- & + status_pid=$! + } && + wait_for_fast_ready +} + +stop_after_fast_fallback () { + for i in $(test_seq 1 1000) + do + if grep -q "\"value\":\"fast-excludes-raced\"" \ + "$race_trace" + then + kill "$status_pid" 2>/dev/null || return 1 + wait "$status_pid" 2>/dev/null || : + status_pid= + return 0 + fi + kill -0 "$status_pid" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +finish_fast_raced_status () { + printf "resume\n" >&9 && + exec 9>&- && + wait "$status_pid" && + status_pid= +} + test_expect_success DURABLE_FSMONITOR \ 'exact clean status installs a sidecar without rewriting the index' ' test_when_finished "stop_daemon sidecar-issue" && @@ -85,7 +217,160 @@ test_expect_success DURABLE_FSMONITOR \ issue.trace && test_grep "\"key\":\"preload/bulk_provider_applied\"" issue.trace && test_grep "\"key\":\"clean-proof/sidecar\"" issue.trace && - test_grep ! "\"label\":\"do_write_index\"" issue.trace + test_grep ! "\"label\":\"do_write_index\"" issue.trace && + + GIT_TRACE2_EVENT="$PWD/hit.trace" \ + git -C sidecar-issue status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" hit.trace && + test_grep ! "\"label\":\"do_read_index\"" hit.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'exact clean status certifies an existing untracked cache' ' + test_when_finished "stop_daemon sidecar-untracked-cache" && + setup_repo sidecar-untracked-cache && + git -C sidecar-untracked-cache config core.untrackedCache true && + git -C sidecar-untracked-cache config core.autocrlf false && + bulk_status -C sidecar-untracked-cache status --porcelain=2 \ + >actual.1 && + test_must_be_empty actual.1 && + bulk_status -C sidecar-untracked-cache status --porcelain=2 \ + >actual.2 && + test_must_be_empty actual.2 && + test_grep UNTR sidecar-untracked-cache/.git/index && + + test_env GIT_TRACE2_EVENT="$PWD/untracked-cache-issue.trace" \ + bulk_status -C sidecar-untracked-cache \ + status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_file sidecar-untracked-cache/.git/index.csts && + test_grep ! \ + "\"key\":\"preload/bulk_useful\"" \ + untracked-cache-issue.trace && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + untracked-cache-issue.trace >untracked-cache-read-directory && + test_line_count = 1 untracked-cache-read-directory && + test_grep "\"key\":\"proof_valid\",\"value\":\"1\"" \ + untracked-cache-issue.trace && + test_grep FSUC sidecar-untracked-cache/.git/index && + + GIT_TRACE2_EVENT="$PWD/untracked-cache-hit.trace" \ + git -C sidecar-untracked-cache status --porcelain=v2 \ + >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" \ + untracked-cache-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" \ + untracked-cache-hit.trace +' + +test_expect_success POSIXPERM,DURABLE_FSMONITOR \ + 'an incomplete untracked traversal cannot issue a sidecar' ' + test_when_finished "stop_daemon sidecar-unreadable" && + setup_repo sidecar-unreadable && + git -C sidecar-unreadable config core.untrackedCache true && + git -C sidecar-unreadable config core.autocrlf false && + prime_semantic_history sidecar-unreadable && + mkdir sidecar-unreadable/hidden && + test_when_finished "chmod u+rwx sidecar-unreadable/hidden" && + test_write_lines untracked >sidecar-unreadable/hidden/untracked && + chmod a-r sidecar-unreadable/hidden && + + test_env GIT_TRACE2_EVENT="$PWD/unreadable.trace" \ + bulk_status -C sidecar-unreadable \ + status --porcelain=v2 >actual 2>err && + test_must_be_empty actual && + test_grep "could not open directory .hidden/." err && + test_path_is_missing sidecar-unreadable/.git/index.csts && + test_grep "\"value\":\"issue-scan-or-index-shape\"" \ + unreadable.trace && + + chmod u+rwx sidecar-unreadable/hidden && + git -C sidecar-unreadable status --porcelain=v2 >actual && + test_grep "^? hidden/" actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'a replaced worktree root cannot inherit a sidecar' ' + test_when_finished "stop_daemon sidecar-root-race" && + test_when_finished "stop_daemon sidecar-root-race.scanned" && + test_when_finished "cleanup_fast_race" && + setup_repo sidecar-root-race && + git -C sidecar-root-race config core.untrackedCache true && + prime_semantic_history sidecar-root-race && + git -C sidecar-root-race config core.autocrlf false && + cp -R sidecar-root-race sidecar-root-race.replacement && + test_write_lines replacement-only \ + >sidecar-root-race.replacement/replacement-only && + rm -f sidecar-root-race.replacement/.git/index.csts && + + start_issue_raced_status sidecar-root-race && + mv sidecar-root-race sidecar-root-race.scanned && + mv sidecar-root-race.replacement sidecar-root-race && + finish_fast_raced_status && + + test_must_be_empty raced.actual && + test_path_is_missing sidecar-root-race/.git/index.csts && + test_grep \ + "\"category\":\"dir\",\"label\":\"read_directory\"" \ + "$race_trace" && + test_grep ! \ + "\"key\":\"preload/bulk_untracked_complete\",\"value\":\"1\"" \ + "$race_trace" && + test_grep "\"key\":\"proof_valid\",\"value\":\"1\"" "$race_trace" && + test_grep "\"value\":\"issue-pinned-inputs\"" "$race_trace" +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'an existing per-directory exclude FIFO cannot block sidecar issuance' ' + test_when_finished "stop_daemon sidecar-issue-fifo" && + setup_repo sidecar-issue-fifo && + test_when_finished "rm -f sidecar-issue-fifo/.gitignore" && + git -C sidecar-issue-fifo config core.untrackedCache true && + git -C sidecar-issue-fifo config core.autocrlf false && + prime_semantic_history sidecar-issue-fifo && + mkfifo sidecar-issue-fifo/.gitignore && + + test_env GIT_TRACE2_EVENT="$PWD/issue-fifo.trace" \ + bulk_status -C sidecar-issue-fifo \ + status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_file sidecar-issue-fifo/.git/index.csts && + test_grep "\"key\":\"clean-proof/sidecar\"" issue-fifo.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'a clean exact status replaces a stale sidecar' ' + test_when_finished "stop_daemon sidecar-reissue" && + setup_repo sidecar-reissue && + git -C sidecar-reissue config core.untrackedCache true && + issue_sidecar sidecar-reissue && + test_write_lines untracked >sidecar-reissue/untracked && + git -C sidecar-reissue status --porcelain=2 >dirty && + test_grep "^? untracked$" dirty && + rm sidecar-reissue/untracked && + + test_env GIT_TRACE2_EVENT="$PWD/reissue.trace" \ + bulk_status -C sidecar-reissue status --porcelain=v2 \ + >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/sidecar\"" reissue.trace && + test_grep ! \ + "\"key\":\"preload/bulk_useful\"" \ + reissue.trace && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ + reissue.trace >reissue-read-directory && + test_line_count = 1 reissue-read-directory && + test_grep ! "\"label\":\"do_write_index\"" reissue.trace && + + GIT_TRACE2_EVENT="$PWD/reissued-hit.trace" \ + git -C sidecar-reissue status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" reissued-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" reissued-hit.trace ' test_expect_success DURABLE_FSMONITOR \ @@ -109,7 +394,7 @@ test_expect_success DURABLE_FSMONITOR \ ' test_expect_success DURABLE_FSMONITOR \ - 'external attributes, untracked cache, and alternate indexes are rejected' ' + 'external attributes and alternate indexes are rejected' ' test_when_finished "stop_daemon sidecar-inputs" && setup_repo sidecar-inputs && prime_semantic_history sidecar-inputs && @@ -121,11 +406,6 @@ test_expect_success DURABLE_FSMONITOR \ test_path_is_missing sidecar-inputs/.git/index.csts && rm sidecar-inputs/.git/info/attributes && - git -C sidecar-inputs config core.untrackedCache true && - bulk_status -C sidecar-inputs status --porcelain=v2 >actual && - test_must_be_empty actual && - test_path_is_missing sidecar-inputs/.git/index.csts && - cp sidecar-inputs/.git/index sidecar-inputs/.git/alternate-index && test_env GIT_INDEX_FILE="$PWD/sidecar-inputs/.git/alternate-index" \ bulk_status -C sidecar-inputs status --porcelain=v2 >actual && @@ -133,6 +413,219 @@ test_expect_success DURABLE_FSMONITOR \ test_path_is_missing sidecar-inputs/.git/alternate-index.csts ' +test_expect_success DURABLE_FSMONITOR \ + 'a fast hit remains read-only without optional locks' ' + test_when_finished "stop_daemon sidecar-read-only" && + setup_repo sidecar-read-only && + issue_sidecar sidecar-read-only && + cp sidecar-read-only/.git/index.csts sidecar.before && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/read-only.trace" \ + git -C sidecar-read-only status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" read-only.trace && + test_grep ! "\"label\":\"do_read_index\"" read-only.trace && + test_cmp sidecar.before sidecar-read-only/.git/index.csts +' + +test_expect_success DURABLE_FSMONITOR \ + 'provider changes fall back for each dirty worktree shape' ' + test_when_finished "stop_daemon sidecar-modified" && + test_when_finished "stop_daemon sidecar-deleted" && + test_when_finished "stop_daemon sidecar-renamed" && + test_when_finished "stop_daemon sidecar-untracked" && + + setup_repo sidecar-modified && + issue_sidecar sidecar-modified && + echo changed >sidecar-modified/tracked && + assert_fallback_matches_oracle sidecar-modified modified.trace && + test_grep "^1 .M " actual && + + setup_repo sidecar-deleted && + issue_sidecar sidecar-deleted && + rm sidecar-deleted/tracked && + assert_fallback_matches_oracle sidecar-deleted deleted.trace && + test_grep "^1 .D " actual && + + setup_repo sidecar-renamed && + issue_sidecar sidecar-renamed && + mv sidecar-renamed/tracked sidecar-renamed/renamed && + assert_fallback_matches_oracle sidecar-renamed renamed.trace && + test_grep "^1 .D " actual && + test_grep "^? renamed" actual && + + setup_repo sidecar-untracked && + issue_sidecar sidecar-untracked && + echo untracked >sidecar-untracked/new-file && + assert_fallback_matches_oracle sidecar-untracked untracked.trace && + test_grep "^? new-file" actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'loose and packed replace refs invalidate a sidecar' ' + test_when_finished "stop_daemon sidecar-replace" && + setup_repo sidecar-replace && + issue_sidecar sidecar-replace && + old_tree=$(git -C sidecar-replace rev-parse "HEAD^{tree}") && + new_tree=$(replacement_tree sidecar-replace) && + git -C sidecar-replace replace "$old_tree" "$new_tree" && + + assert_fallback_matches_oracle sidecar-replace replace-loose.trace && + test_grep "^1 M. " actual && + git -C sidecar-replace pack-refs --all && + test_path_is_missing \ + "sidecar-replace/.git/refs/replace/$old_tree" && + assert_fallback_matches_oracle sidecar-replace replace-packed.trace && + test_grep "^1 M. " actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'a custom replace namespace invalidates a sidecar' ' + test_when_finished "stop_daemon sidecar-custom-replace" && + setup_repo sidecar-custom-replace && + issue_sidecar sidecar-custom-replace && + old_tree=$(git -C sidecar-custom-replace rev-parse "HEAD^{tree}") && + new_tree=$(replacement_tree sidecar-custom-replace) && + GIT_REPLACE_REF_BASE=refs/status-replace/ \ + git -C sidecar-custom-replace update-ref \ + "refs/status-replace/$old_tree" "$new_tree" && + + assert_custom_replace_fallback_matches_oracle \ + sidecar-custom-replace replace-custom.trace && + test_grep "^1 M. " actual +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'a sidecar FIFO cannot block or supply a hit' ' + test_when_finished "stop_daemon sidecar-fifo" && + test_when_finished "rm -f sidecar-fifo/.git/index.csts" && + setup_repo sidecar-fifo && + issue_sidecar sidecar-fifo && + rm sidecar-fifo/.git/index.csts && + mkfifo sidecar-fifo/.git/index.csts && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/fifo.trace" \ + git -C sidecar-fifo status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep ! "\"key\":\"clean-proof/hit\"" fifo.trace && + test_grep "\"value\":\"fast-sidecar-missing-or-corrupt\"" fifo.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'non-provider proof inputs invalidate a sidecar' ' + test_when_finished "stop_daemon sidecar-metadata" && + setup_repo sidecar-metadata && + issue_sidecar sidecar-metadata && + + git -C sidecar-metadata config status.relativePaths false && + assert_fallback_matches_oracle sidecar-metadata config.trace && + test_grep "\"value\":\"fast-config-changed\"" config.trace && + git -C sidecar-metadata config --unset status.relativePaths && + + cp sidecar-metadata/.git/info/exclude info-exclude && + test_write_lines ignored >sidecar-metadata/.git/info/exclude && + assert_fallback_matches_oracle sidecar-metadata exclude.trace && + test_grep "\"value\":\"fast-excludes\"" exclude.trace && + mv info-exclude sidecar-metadata/.git/info/exclude && + + test_write_lines "tracked ident" \ + >sidecar-metadata/.git/info/attributes && + assert_fallback_matches_oracle sidecar-metadata attributes.trace && + test_grep "\"value\":\"fast-repository-unavailable\"" attributes.trace && + rm sidecar-metadata/.git/info/attributes && + + blob=$(printf "different\n" | + git -C sidecar-metadata hash-object -w --stdin) && + tree=$(printf "100644 blob %s\ttracked\n" "$blob" | + git -C sidecar-metadata mktree) && + commit=$(printf "different tree\n" | + git -C sidecar-metadata commit-tree "$tree" -p HEAD) && + git -C sidecar-metadata update-ref HEAD "$commit" && + assert_fallback_matches_oracle sidecar-metadata head.trace && + test_grep "\"value\":\"fast-head-changed\"" head.trace && + test_grep "^1 M. " actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'a v4 skipHash index is not certified' ' + test_when_finished "stop_daemon sidecar-v4" && + setup_repo sidecar-v4 && + prime_semantic_history sidecar-v4 && + git -C sidecar-v4 config index.version 4 && + git -C sidecar-v4 config index.skipHash true && + git -C sidecar-v4 update-index --force-write-index && + git -C sidecar-v4 config core.autocrlf false && + + dd if=/dev/zero of=zeros bs=20 count=1 2>/dev/null && + tail -c 20 sidecar-v4/.git/index >trailer && + test_cmp_bin zeros trailer && + test_env GIT_TRACE2_EVENT="$PWD/v4.trace" \ + bulk_status -C sidecar-v4 status --porcelain=v2 >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-v4/.git/index.csts && + test_grep ! "\"key\":\"clean-proof/hit\"" v4.trace +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'an existing exclude FIFO cannot block fast-path capture' ' + test_when_finished "stop_daemon sidecar-exclude-fifo" && + setup_repo sidecar-exclude-fifo && + exclude_file=$(mktemp \ + "${TMPDIR:-/tmp}/git-status-exclude-fifo.XXXXXX") && + test_when_finished "rm -f \"$exclude_file\"" && + git -C sidecar-exclude-fifo config core.excludesFile \ + "$exclude_file" && + issue_sidecar sidecar-exclude-fifo && + rm "$exclude_file" && + mkfifo "$exclude_file" && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/exclude-fifo.trace" \ + git -C sidecar-exclude-fifo status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" exclude-fifo.trace +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'a raced exclude FIFO cannot block sidecar validation' ' + test_when_finished "stop_daemon sidecar-exclude-race" && + test_when_finished "cleanup_fast_race" && + setup_repo sidecar-exclude-race && + exclude_file=$(mktemp \ + "${TMPDIR:-/tmp}/git-status-exclude-race.XXXXXX") && + test_when_finished "rm -f \"$exclude_file\"" && + test_write_lines ignored >"$exclude_file" && + git -C sidecar-exclude-race config core.excludesFile \ + "$exclude_file" && + issue_sidecar sidecar-exclude-race && + + start_fast_raced_status sidecar-exclude-race && + rm "$exclude_file" && + mkfifo "$exclude_file" && + printf "resume\n" >&9 && + exec 9>&- && + stop_after_fast_fallback && + test_must_be_empty raced.actual && + test_grep ! "\"key\":\"clean-proof/hit\"" "$race_trace" && + test_grep "\"value\":\"fast-excludes-raced\"" "$race_trace" +' + +test_expect_success PIPE,DURABLE_FSMONITOR \ + 'a replace ref created after the provider query prevents a hit' ' + test_when_finished "stop_daemon sidecar-replace-race" && + test_when_finished "cleanup_fast_race" && + setup_repo sidecar-replace-race && + issue_sidecar sidecar-replace-race && + old_tree=$(git -C sidecar-replace-race rev-parse "HEAD^{tree}") && + new_tree=$(replacement_tree sidecar-replace-race) && + + start_fast_raced_status sidecar-replace-race && + git -C sidecar-replace-race update-ref \ + "refs/replace/$old_tree" "$new_tree" && + finish_fast_raced_status && + test_grep ! "\"key\":\"clean-proof/hit\"" "$race_trace" && + test_grep "\"value\":\"fast-repository-raced\"" "$race_trace" +' + test_expect_success DURABLE_FSMONITOR \ 'normal status restores namespace-specific history outside the index' ' test_when_finished "stop_daemon external-history" && diff --git a/wt-status.c b/wt-status.c index 4de9940765ceeb..e5d2e958206058 100644 --- a/wt-status.c +++ b/wt-status.c @@ -12,6 +12,7 @@ #include "dir.h" #include "commit.h" #include "clean-status.h" +#include "clean-status-index.h" #include "diff.h" #include "environment.h" #include "exclude-source-proof.h" @@ -716,6 +717,11 @@ static void wt_status_collect_changes_worktree(struct wt_status *s) size_t direct_nr; struct rev_info rev; + if (s->tracked_from_fsmonitor) { + preload_index_bulk_result_consume(s->repo->index); + return; + } + direct = wt_status_collect_preload_changes(s, &direct_nr); repo_init_revisions(s->repo, &rev, NULL); setup_revisions(0, NULL, &rev, NULL); @@ -1137,6 +1143,7 @@ static void wt_status_finish_untracked_cache_preload(struct wt_status *s) if (!index_invalidated) return; + s->tracked_from_fsmonitor = 0; preload_index_bulk_result_clear(istate); trace2_data_intmax("status", s->repo, "fsmonitor/exclude-index-invalidated", @@ -1620,6 +1627,26 @@ wt_status_close_semantic_fsmonitor_token( return WT_STATUS_TOKEN_CLOSURE_ACCEPTED; } +static int wt_status_tracked_fsmonitor_state_is_current( + struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + + return s->allow_clean_status_shortcuts && + !s->certify_clean_status && !s->pathspec.nr && + !getenv(INDEX_ENVIRONMENT) && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && + is_fsmonitor_refreshed(istate) && + !fsmonitor_has_pending_token(istate) && + istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + *istate->fsmonitor_last_update && + clean_status_revalidated_token_matches(istate) && + !clean_status_manifest_global_fallback(istate) && + !clean_status_worktree_manifest_needs_refresh(istate); +} + static int wt_status_close_fsmonitor_token( struct wt_status *s, struct semantic_verify_proof *proof, unsigned int refresh_flags, int require_untracked, @@ -1644,6 +1671,20 @@ static int wt_status_close_fsmonitor_token( wt_status_discard_semantic_verify( s, &proof, "provider-unavailable"); + if (!refreshed_before_closure && attr_inputs_match && + wt_status_tracked_fsmonitor_state_is_current(s) && + clean_status_index_entries_are_certifiable(istate)) { + s->tracked_from_fsmonitor = 1; + trace2_data_intmax( + "status", s->repo, + "fsmonitor/tracked-clean", 1); + return 0; + } + if (refreshed_before_closure && attr_inputs_match && + s->tracked_from_fsmonitor && + wt_status_tracked_fsmonitor_state_is_current(s)) + return closure.refresh_result; + s->tracked_from_fsmonitor = 0; if (!refreshed_before_closure && attr_inputs_match) return refresh_index( istate, refresh_flags, &s->pathspec, @@ -1661,6 +1702,7 @@ static int wt_status_close_fsmonitor_token( return closure.refresh_result; } + s->tracked_from_fsmonitor = 0; closure.can_prime = require_untracked && istate->untracked && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && @@ -1750,6 +1792,7 @@ void wt_status_invalidate_refresh(struct wt_status *s) { struct index_state *istate = s->repo->index; + s->tracked_from_fsmonitor = 0; if (s->untracked_from_token_closure) { string_list_clear(&s->untracked, 0); string_list_clear(&s->ignored, 0); diff --git a/wt-status.h b/wt-status.h index afee3b4ad9840c..6f5300fe8e5481 100644 --- a/wt-status.h +++ b/wt-status.h @@ -145,6 +145,7 @@ struct wt_status { int workdir_dirty; unsigned allow_clean_status_shortcuts : 1; unsigned certify_clean_status : 1; + unsigned tracked_from_fsmonitor : 1; unsigned untracked_from_token_closure : 1; unsigned untracked_from_preload : 1; unsigned bulk_update_index_stat : 1; From 2c8e5e7a4a4cd44993ec12b4ff4742ab91dade1d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 17:20:24 -0500 Subject: [PATCH 229/403] Documentation: describe status clean-proof sidecars A clean-status sidecar is a narrowly scoped proof, not an alternate index or a general cache. Documenting only its serialized bytes would hide the full-scan issuance requirement and the revalidation needed before an empty provider response can answer status. Document the adjacent sidecar path, local-APFS and main-worktree eligibility, fixed-width version-one CSTS fields, a checksum using the repository object-format hash, the separate repository-identity hash, a bounded builtin-provider token, and the 8192-byte read limit. Explain why the source index, configuration, repository, HEAD, attributes, and standard excludes must remain coherent. Describe completed-scan issuance, persistent provider history, held index locks, the post-query race fence, nonblocking source opens, and read-only hits. State that every missing, unsupported, stale, malformed, or raced proof falls back to ordinary status. Register the technical document in both the documentation Makefile and Meson. Also document resumable CSHS history checkpoints separately from CSTS exact-result sidecars. Specify their namespace binding, complete FSMN, UNTR/FSUC, and FSCF contents, canonical logical-index digest, bounded local store, scratch-state validation, publication requirements, and normal-status-only rollback behavior. Signed-off-by: Taylor Blau --- Documentation/Makefile | 1 + Documentation/technical/meson.build | 1 + .../technical/status-clean-proof.adoc | 164 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 Documentation/technical/status-clean-proof.adoc diff --git a/Documentation/Makefile b/Documentation/Makefile index 2699f0b24af192..ff7cee368e3f7d 100644 --- a/Documentation/Makefile +++ b/Documentation/Makefile @@ -142,6 +142,7 @@ TECH_DOCS += technical/send-pack-pipeline TECH_DOCS += technical/shallow TECH_DOCS += technical/sparse-checkout TECH_DOCS += technical/sparse-index +TECH_DOCS += technical/status-clean-proof TECH_DOCS += technical/trivial-merge TECH_DOCS += technical/unambiguous-types TECH_DOCS += technical/unit-tests diff --git a/Documentation/technical/meson.build b/Documentation/technical/meson.build index ec07088c57617f..665977299cdab3 100644 --- a/Documentation/technical/meson.build +++ b/Documentation/technical/meson.build @@ -31,6 +31,7 @@ articles = [ 'shallow.adoc', 'sparse-checkout.adoc', 'sparse-index.adoc', + 'status-clean-proof.adoc', 'trivial-merge.adoc', 'unambiguous-types.adoc', 'unit-tests.adoc', diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc new file mode 100644 index 00000000000000..91ba95c29c6a4a --- /dev/null +++ b/Documentation/technical/status-clean-proof.adoc @@ -0,0 +1,164 @@ +Status clean-proof sidecar +========================== + +The status clean-proof sidecar is an optional cache for one exact clean +`git status --porcelain=v2` result. It is never a source of repository +state. A missing, stale, malformed, unsupported, or raced sidecar makes +status read the index and scan the worktree normally. + +Location and scope +------------------ + +The sidecar for an index at `` is stored at `.csts`. This +keeps a proof for one index from being applied to an alternate index. +Sidecars are currently limited to the main worktree, the builtin file +system monitor, and an index and worktree root on local APFS file +systems. + +Readers pass `O_NONBLOCK` and do not follow symbolic links when opening +the sidecar, then accept only regular files no larger than 8192 bytes. +An open or validation failure falls back to ordinary status. Writers use +the normal lockfile protocol. + +Binary format +------------- + +All integers are stored in network byte order. Hashes use the +repository's object-format hash algorithm. Version 1 consists of: + +* Four-byte magic `CSTS`. + +* A 32-bit version number (currently 1). + +* 32-bit flags (currently zero). + +* Fourteen 64-bit fields describing the source index: device, inode, + mode, link count, uid, gid, size, mtime seconds and nanoseconds, ctime + seconds and nanoseconds, birth time seconds and nanoseconds, and + generation. + +* The 32-bit index format version and 32-bit cache-entry count. + +* The index checksum and `HEAD` tree object ID. + +* Hashes of status-relevant configuration and repository identity. The + repository identity covers the worktree and Git directory paths, the + worktree root identity, the local APFS identifiers of the held index + and worktree root, external-attribute contents, and locale inputs. + +* One object ID digesting the unique standard-exclude observations in + first-observation order. Each observation contains the source path, + symbolic-link lookup policy, presence, and contents. Transient file + system identities used to make an observation coherent are not part + of the digest. + +* A 32-bit provider-token length followed by the token without a + terminating NUL. Version 1 accepts only builtin-fsmonitor tokens. + +* A hash over all preceding bytes in the sidecar. + +Issuance +-------- + +Only a literal, top-level `status --porcelain=v2` invocation can issue a +sidecar. Status first completes the tracked and untracked scan, +semantic-conversion checks, and provider-token closure. The result must +be empty and use the bulk scanner's complete standard-exclude result. +The index must also contain persistent semantic history from an earlier +scan, so the first scan cannot certify itself. + +Status then uses the held attribute snapshot and the scanner-sourced +standard-exclude digest, pins the named index, and checks its ordinary +expanded entries, `HEAD`, and the cache tree. External attribute +sources, effective replacement refs, untracked-cache results, null +index checksums, and other unsupported repository or index shapes +prevent issuance. + +The sidecar is installed while the index lock remains held and after +the pinned index is rechecked. Status then rolls back the index lock, so +issuing a sidecar does not itself rewrite the index. With optional locks +disabled, status does not issue a sidecar. + +Validation and races +-------------------- + +For the same literal command, status attempts validation before loading +the index entries. It checks the bounded sidecar, pins the named index, +and recreates the configuration, attribute, standard-exclude, +repository, and `HEAD` inputs. It then queries the builtin file system +monitor from the recorded token and accepts only an empty delta +response. + +The attribute and exclude snapshots remain held across that query. +Before returning a clean result, status freshly checks configuration, +`HEAD`, uncached replacement refs, the attribute contents and namespace, +the exclude sources, and both the opened and named index identities. +The sidecar's exclude-source opens pass `O_NONBLOCK` and fail closed +when the source cannot be opened and validated; standard-exclude +symbolic links retain their normal lookup behavior. + +A hit produces no output and reads only the 12-byte index header and +the 20- or 32-byte checksum trailer; it does not deserialize cache +entries, write the index, replace the sidecar, or advance its provider +token. Any failed check falls through to ordinary status. + +Resumable history checkpoints +----------------------------- + +The exact-result sidecar above is deliberately tied to one physical +index file. A normal, top-level `git status` has a separate checkpoint +store for resuming clean-status history after another Git implementation +has rewritten or re-encoded the index. The store is consulted only +after the ordinary index entries have been read. It cannot answer a +status command by itself. + +For an index at ``, a checkpoint is stored as +`.csh1.`. The namespace covers the checkpoint +schema, status configuration, semantic inputs, attribute namespace, and +the canonical worktree, Git directory, and common directory paths. It +does not include a Git executable, build prefix, provider token, or +physical index generation. At most eight regular version-1 checkpoint +files are retained next to an index. Each file is limited to 16 MiB; +readers use `O_NONBLOCK`, do not follow symbolic links, and verify an +outer checksum before parsing any section. Publication currently +requires durable index identities on a local APFS file system. + +Each checkpoint is self-contained and co-temporal. It contains: + +* a canonical digest of the ordered logical index entries; + +* the `FSMN` token and dirty bitmap; + +* the serialized untracked cache and its paired `FSUC` token, when an + untracked cache exists; and + +* the `FSCF` semantic proof bound to the same file-system-monitor token. + +The logical index digest includes entry count, path, stage, object ID, +mode, `CE_VALID`, skip-worktree, and intent-to-add state. It excludes +index format, checksum, file identity, cached stat data, and +`CE_FSMONITOR_VALID`. Split and collapsed sparse indexes and +unrecognized transient flags reject matching. A changed logical digest +misses, while publication additionally rejects logical changes during +the command and racy entries. This lets an index-format-only rewrite +find the same logical state without preserving proof across commands +which can change entry membership or persistent flags. + +On a valid hit, status installs all checkpoint sections together in a +scratch index, rechecks the pinned index, and only then replaces the +in-memory acceleration state. It queries the builtin file-system +monitor from the stored token. A trivial response, provider restart, +malformed section, token mismatch, namespace mismatch, or index race +falls back to ordinary validation. + +Status publishes a replacement checkpoint only after closing the +provider token and validating the semantic proof and paired untracked +cache. Publication uses the normal index lock followed by a per-slot +lockfile, and atomically replaces one namespace slot. When publication +succeeds, status rolls back the pending acceleration-only index update. +After an external restore, status also leaves the main index untouched +if republication fails, so one proof namespace is not copied over +another implementation's index extensions. Only literal normal status +enables this lane; commands capable of logical index changes retain the +normal index-writing path. Optional-lock-free commands neither publish +checkpoints nor use this rollback path. From af4c937956cb9e1a15eb03da8296174b87ce80c0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 19:10:21 -0600 Subject: [PATCH 230/403] status: reuse clean proofs for plain status after restore A normal status after another Git implementation rewrites the index can restore an external clean-history checkpoint, but later invocations still read the index and compute both logical-index digests. On a 1,034,481-entry worktree, that left clean status around 0.5 to 1.0 seconds after the original tracked-file thrash was gone. After a successful external-history restore, let literal top-level plain status publish the same physical clean proof used by the exact porcelain-v2 path. A later hit keeps the normal long-status printer and refreshes branch, tracking, and in-progress-operation state, but skips index deserialization, both logical digests, and untracked traversal. The Apple-written index in this workload uses index.skipHash, so its trailer cannot bind the proof. Accept a zero trailer only when the existing local-APFS durable identity binds the parsed and named index. Rewriting the same logical entries still changes that identity and invalidates the proof. A controlled Apple Git rewrite in the same worktree took 2.58 seconds to re-establish the proof; the next warm plain status took 0.01 seconds and traced clean-proof/hit without do_read_index or history_logical_digest. A new physical rewrite still takes the external-history path once before later unchanged-index runs become fast. --- .../technical/status-clean-proof.adoc | 59 ++++++++++------ builtin/commit.c | 65 +++++++++++++++-- clean-status-index.c | 15 +++- clean-status-index.h | 7 ++ clean-status-sidecar-issue.c | 12 ++-- clean-status-sidecar.c | 6 +- clean-status.h | 3 +- t/t7530-status-clean-sidecar.sh | 70 ++++++++++++++++++- t/unit-tests/u-clean-status-index.c | 24 +++++++ t/unit-tests/u-clean-status-sidecar.c | 29 +++++--- 10 files changed, 242 insertions(+), 48 deletions(-) diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc index 91ba95c29c6a4a..0ee74695697bca 100644 --- a/Documentation/technical/status-clean-proof.adoc +++ b/Documentation/technical/status-clean-proof.adoc @@ -1,10 +1,13 @@ Status clean-proof sidecar ========================== -The status clean-proof sidecar is an optional cache for one exact clean -`git status --porcelain=v2` result. It is never a source of repository -state. A missing, stale, malformed, unsupported, or raced sidecar makes -status read the index and scan the worktree normally. +The status clean-proof sidecar is an optional proof for one empty clean +scan. It can answer the exact empty `git status --porcelain=v2` query, +or let a literal plain `git status` print its live long-format metadata +without repeating the scan. It is never a source of repository state or +cached human-readable output. A missing, stale, malformed, unsupported, +or raced sidecar makes status read the index and scan the worktree +normally. Location and scope ------------------ @@ -39,7 +42,8 @@ repository's object-format hash algorithm. Version 1 consists of: * The 32-bit index format version and 32-bit cache-entry count. -* The index checksum and `HEAD` tree object ID. +* The index checksum, which is all zero for an `index.skipHash` index, + and the `HEAD` tree object ID. * Hashes of status-relevant configuration and repository identity. The repository identity covers the worktree and Git directory paths, the @@ -60,19 +64,23 @@ repository's object-format hash algorithm. Version 1 consists of: Issuance -------- -Only a literal, top-level `status --porcelain=v2` invocation can issue a +Only a literal, top-level `status --porcelain=v2` invocation, or a +literal plain `status` after it restored external history, can issue a sidecar. Status first completes the tracked and untracked scan, semantic-conversion checks, and provider-token closure. The result must be empty and use the bulk scanner's complete standard-exclude result. The index must also contain persistent semantic history from an earlier -scan, so the first scan cannot certify itself. +scan, so the first scan cannot certify itself. For plain status, +external checkpoint publication is attempted before the physical-index +proof is installed. Status then uses the held attribute snapshot and the scanner-sourced standard-exclude digest, pins the named index, and checks its ordinary expanded entries, `HEAD`, and the cache tree. External attribute -sources, effective replacement refs, untracked-cache results, null -index checksums, and other unsupported repository or index shapes -prevent issuance. +sources, effective replacement refs, untracked-cache results, and other +unsupported repository or index shapes prevent issuance. A null index +checksum is accepted only when the pinned index is bound by the durable +local-APFS identity used for raced-input checks. The sidecar is installed while the index lock remains held and after the pinned index is rechecked. Status then rolls back the index lock, so @@ -82,9 +90,9 @@ disabled, status does not issue a sidecar. Validation and races -------------------- -For the same literal command, status attempts validation before loading -the index entries. It checks the bounded sidecar, pins the named index, -and recreates the configuration, attribute, standard-exclude, +For either eligible literal command, status attempts validation before +loading the index entries. It checks the bounded sidecar, pins the named +index, and recreates the configuration, attribute, standard-exclude, repository, and `HEAD` inputs. It then queries the builtin file system monitor from the recorded token and accepts only an empty delta response. @@ -97,20 +105,25 @@ The sidecar's exclude-source opens pass `O_NONBLOCK` and fail closed when the source cannot be opened and validated; standard-exclude symbolic links retain their normal lookup behavior. -A hit produces no output and reads only the 12-byte index header and -the 20- or 32-byte checksum trailer; it does not deserialize cache -entries, write the index, replace the sidecar, or advance its provider -token. Any failed check falls through to ordinary status. +An exact porcelain hit produces no output. A plain-status hit refreshes +branch, tracking, and in-progress-operation metadata and passes the +empty tracked and untracked lists to the normal long-status printer. +Either hit reads only the 12-byte index header and the 20- or 32-byte +checksum trailer; for a null trailer, the durable local-APFS identity +is the physical binding. It does not deserialize cache entries, write +the index, replace the sidecar, or advance its provider token. Any +failed check falls through to ordinary status. Resumable history checkpoints ----------------------------- -The exact-result sidecar above is deliberately tied to one physical -index file. A normal, top-level `git status` has a separate checkpoint -store for resuming clean-status history after another Git implementation -has rewritten or re-encoded the index. The store is consulted only -after the ordinary index entries have been read. It cannot answer a -status command by itself. +The physical clean-proof sidecar above is deliberately tied to one +physical index file. A normal, top-level `git status` has a separate +checkpoint store for resuming clean-status history after another Git +implementation has rewritten or re-encoded the index. The store is +consulted only after the ordinary index entries have been read. It +cannot answer a status command by itself, but a successful restore can +publish a new physical proof for the next unchanged-index plain status. For an index at ``, a checkpoint is stored as `.csh1.`. The namespace covers the checkpoint diff --git a/builtin/commit.c b/builtin/commit.c index d9bf5270a0e030..eb266fd1304810 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -33,6 +33,7 @@ #include "path.h" #include "preload-index.h" #include "read-cache.h" +#include "refs.h" #include "repository.h" #include "string-list.h" #include "rerere.h" @@ -1603,6 +1604,37 @@ static int git_status_config(const char *k, const char *v, return git_diff_ui_config(k, v, ctx, NULL); } +/* + * A clean-proof hit certifies the tracked and untracked lists, but it + * deliberately does not cache human-readable status output. Refresh the + * cheap state which the long printer derives from refs and administrative + * files before printing those empty lists. + */ +static int print_normal_clean_sidecar(struct wt_status *s, + const char *prefix) +{ + struct object_id oid; + + if (repo_get_oid(s->repo, s->reference, &oid)) + return 0; + s->is_initial = 0; + oidcpy(&s->oid_commit, &oid); + s->ignore_submodule_arg = ignore_submodule_arg; + s->status_format = status_format; + s->verbose = verbose; + FREE_AND_NULL(s->branch); + s->branch = refs_resolve_refdup(get_main_ref_store(s->repo), + "HEAD", 0, NULL, NULL); + wt_status_get_state(s->repo, &s->state, + s->branch && !strcmp(s->branch, "HEAD")); + if (s->state.merge_in_progress) + s->committable = 1; + if (s->relative_paths) + s->prefix = prefix; + wt_status_print(s); + return 1; +} + int cmd_status(int argc, const char **argv, const char *prefix, @@ -1618,6 +1650,8 @@ struct repository *repo UNUSED) int exact_clean_command = argc == 2 && !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); int exact_clean_query; + int normal_clean_query; + int normal_has_head; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1704,22 +1738,38 @@ struct repository *repo UNUSED) default_status_command && !s.pathspec.nr; if (s.allow_clean_status_shortcuts) clean_status_enable_external_history(the_repository); + normal_has_head = default_status_command && + !repo_get_oid(the_repository, s.reference, &oid); exact_clean_query = exact_clean_command && status_format == STATUS_FORMAT_PORCELAIN_V2 && !s.pathspec.nr && !s.show_branch && !s.show_stash && !s.show_ignored_mode && !s.null_termination && !s.verbose && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES; + normal_clean_query = default_status_command && + status_format == STATUS_FORMAT_NONE && normal_has_head && + !s.pathspec.nr && !s.show_branch && !s.show_stash && + !s.show_ignored_mode && !s.null_termination && !s.verbose && + !s.submodule_summary && + s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !repo_config_values(the_repository)->apply_sparse_checkout; s.certify_clean_status = exact_clean_query; - if (exact_clean_query && + if ((exact_clean_query || normal_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { - wt_status_collect_free_buffers(&s); - return 0; + if (!normal_clean_query || + print_normal_clean_sidecar(&s, prefix)) { + wt_status_collect_free_buffers(&s); + return 0; + } } if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); + if (normal_clean_query && use_optional_locks() && + clean_status_external_history_was_restored( + the_repository->index)) + s.certify_clean_status = 1; wt_status_start_untracked_cache_preload(&s); wt_status_refresh_index( &s, REFRESH_QUIET | REFRESH_UNMERGED | progress_flag | @@ -1751,7 +1801,8 @@ struct repository *repo UNUSED) wt_status_collect(&s); if (exact_clean_command && 0 <= fd && - clean_status_issue_sidecar(&s, &clean_digest, &index_lock)) + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 0)) fd = -1; if (0 <= fd) { int external_restored = @@ -1761,7 +1812,11 @@ struct repository *repo UNUSED) clean_status_save_external_history( the_repository->index); - if (external_restored || external_saved) { + if (normal_clean_query && external_restored && + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 1)) + fd = -1; + else if (external_restored || external_saved) { rollback_lock_file(&index_lock); fd = -1; } diff --git a/clean-status-index.c b/clean-status-index.c index d2303784c9dcd0..0ea48833ed5d0c 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -83,6 +83,13 @@ int clean_status_index_snapshot_open( return snapshot_open(snapshot, path, algo, 0); } +int clean_status_index_snapshot_open_allow_null_checksum( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo) +{ + return snapshot_open(snapshot, path, algo, 1); +} + int clean_status_index_snapshot_still_matches_path( const struct clean_status_index_snapshot *snapshot, const char *path, const struct git_hash_algo *algo) @@ -231,7 +238,13 @@ int clean_status_index_entries_are_certifiable( int clean_status_index_is_certifiable(const struct index_state *istate) { - return !is_null_oid(&istate->oid) && + const struct clean_status_state *state = istate->clean_status; + int checksum_is_bound = + !is_null_oid(&istate->oid) || + (clean_status_identity_is_durable() && state && + state->source_identity_valid); + + return checksum_is_bound && clean_status_index_entries_are_certifiable(istate); } diff --git a/clean-status-index.h b/clean-status-index.h index 2579b20ee437e1..6da2b37b73031a 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -17,6 +17,13 @@ struct clean_status_index_snapshot { int clean_status_index_snapshot_open( struct clean_status_index_snapshot *snapshot, const char *path, const struct git_hash_algo *algo); +/* + * Callers which accept a null trailer must separately require a durable + * source identity before trusting the snapshot. + */ +int clean_status_index_snapshot_open_allow_null_checksum( + struct clean_status_index_snapshot *snapshot, const char *path, + const struct git_hash_algo *algo); int clean_status_index_snapshot_still_matches_path( const struct clean_status_index_snapshot *snapshot, const char *path, const struct git_hash_algo *algo); diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index bdf3c058595a27..06cab58d59699e 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -40,9 +40,12 @@ static int issue_test_barrier(void) return ret; } -static int output_is_certifiable(const struct wt_status *status) +static int output_is_certifiable(const struct wt_status *status, + int normal_clean_query) { - return status->status_format == STATUS_FORMAT_PORCELAIN_V2 && + return (status->status_format == STATUS_FORMAT_PORCELAIN_V2 || + (normal_clean_query && + status->status_format == STATUS_FORMAT_NONE)) && !status->pathspec.nr && !status->show_branch && !status->show_stash && !status->show_ignored_mode && !status->null_termination && !status->verbose && @@ -94,7 +97,8 @@ static int untracked_scan_is_certifiable( int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, - struct lock_file *index_lock) + struct lock_file *index_lock, + int normal_clean_query) { struct repository *repo = status->repo; struct index_state *istate = repo->index; @@ -107,7 +111,7 @@ int clean_status_issue_sidecar( if (!is_lock_file_locked(index_lock) || !config->finalized || config->filter_configured || - !output_is_certifiable(status)) { + !output_is_certifiable(status, normal_clean_query)) { trace_miss(repo, "issue-command-or-output"); goto done; } diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index 95db486a47b289..f387881a79368a 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -53,7 +53,8 @@ static int proof_valid(const struct clean_status_proof *proof, const struct git_hash_algo *algo) { return proof->index_version >= 2 && proof->index_version <= 4 && - !is_null_oid(&proof->index_checksum) && + (!is_null_oid(&proof->index_checksum) || + clean_status_identity_is_durable()) && !is_null_oid(&proof->head_tree) && !is_null_oid(&proof->exclude_source_digest) && proof->index_checksum.algo == hash_algo_by_ptr(algo) && @@ -252,7 +253,8 @@ int clean_status_sidecar_pin_source( const struct git_hash_algo *algo, struct clean_status_index_snapshot *snapshot) { - if (clean_status_index_snapshot_open(snapshot, index_path, algo)) + if (clean_status_index_snapshot_open_allow_null_checksum( + snapshot, index_path, algo)) return -1; if (sidecar_matches_snapshot( index_path, sidecar, snapshot, algo)) diff --git a/clean-status.h b/clean-status.h index 8aea521a3bb4bf..4432903437dd27 100644 --- a/clean-status.h +++ b/clean-status.h @@ -86,7 +86,8 @@ int clean_status_verify_null_index(const struct index_state *istate, int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, - struct lock_file *index_lock); + struct lock_file *index_lock, + int normal_clean_query); int clean_status_try_sidecar( struct repository *repo, const struct clean_status_config_digest *config); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 93b3705a089173..0459708491cd78 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -547,13 +547,14 @@ test_expect_success DURABLE_FSMONITOR \ ' test_expect_success DURABLE_FSMONITOR \ - 'a v4 skipHash index is not certified' ' + 'a v4 skipHash index uses durable identity for a clean proof' ' test_when_finished "stop_daemon sidecar-v4" && setup_repo sidecar-v4 && prime_semantic_history sidecar-v4 && git -C sidecar-v4 config index.version 4 && git -C sidecar-v4 config index.skipHash true && git -C sidecar-v4 update-index --force-write-index && + prime_semantic_history sidecar-v4 && git -C sidecar-v4 config core.autocrlf false && dd if=/dev/zero of=zeros bs=20 count=1 2>/dev/null && @@ -562,8 +563,22 @@ test_expect_success DURABLE_FSMONITOR \ test_env GIT_TRACE2_EVENT="$PWD/v4.trace" \ bulk_status -C sidecar-v4 status --porcelain=v2 >actual && test_must_be_empty actual && - test_path_is_missing sidecar-v4/.git/index.csts && - test_grep ! "\"key\":\"clean-proof/hit\"" v4.trace + test_path_is_file sidecar-v4/.git/index.csts && + test_grep "\"key\":\"clean-proof/sidecar\"" v4.trace && + + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/v4-hit.trace" \ + git -C sidecar-v4 status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep "\"key\":\"clean-proof/hit\"" v4-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" v4-hit.trace && + + # Rewriting the same zero-checksum entries changes the durable identity. + git -C sidecar-v4 update-index --force-write-index && + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/v4-rewrite.trace" \ + git -C sidecar-v4 status --porcelain=v2 >actual && + test_must_be_empty actual && + test_grep ! "\"key\":\"clean-proof/hit\"" v4-rewrite.trace && + test_grep "\"value\":\"fast-index-mismatch\"" v4-rewrite.trace ' test_expect_success PIPE,DURABLE_FSMONITOR \ @@ -631,6 +646,7 @@ test_expect_success DURABLE_FSMONITOR \ test_when_finished "stop_daemon external-history" && setup_repo external-history && git -C external-history config core.untrackedCache true && + git -C external-history config index.skipHash true && git -C external-history config status.renameLimit 100 && git -C external-history update-index \ --index-version=4 --force-write-index && @@ -641,6 +657,9 @@ test_expect_success DURABLE_FSMONITOR \ test_grep UNTR external-history/.git/index && test_grep FSCF external-history/.git/index && test_grep FSUC external-history/.git/index && + dd if=/dev/zero of=external-zeros bs=20 count=1 2>/dev/null && + tail -c 20 external-history/.git/index >external-trailer && + test_cmp_bin external-zeros external-trailer && git -C external-history ls-files --stage >baseline.stage && cp external-history/.git/index namespace-a-v4.index && @@ -653,6 +672,7 @@ test_expect_success DURABLE_FSMONITOR \ test_cmp seed.before external-history/.git/index && test_trace2_data fsmonitor history/external-stored 1 \ external-sidecars && @@ -673,6 +693,8 @@ test_expect_success DURABLE_FSMONITOR \ test_grep UNTR external-history/.git/index && test_grep FSCF external-history/.git/index && test_grep FSUC external-history/.git/index && + tail -c 20 external-history/.git/index >external-trailer && + test_cmp_bin external-zeros external-trailer && test_cmp sidecar.before-rewrite "$sidecar" && git -C external-history config status.renameLimit 200 && @@ -688,6 +710,9 @@ test_expect_success DURABLE_FSMONITOR \ actual.fast && + test_cmp actual.restore actual.fast && + test_trace2_data status clean-proof/hit 1 \ + actual.branch && + test_grep "^On branch sidecar-live$" actual.branch && + test_trace2_data status clean-proof/hit 1 \ + external-history/.git/MERGE_HEAD && + GIT_OPTIONAL_LOCKS=0 GIT_TRACE2_EVENT="$PWD/external-merge.trace" \ + git -C external-history status >actual.merge && + test_grep "All conflicts fixed but you are still merging" \ + actual.merge && + test_trace2_data status clean-proof/hit 1 \ + rawsz); - assert_parse_fails(&fixture, algo); - memset(fixture.encoded.buf + index_checksum_offset(), 2, algo->rawsz); - memset(fixture.encoded.buf + head_tree_offset(algo), 0, algo->rawsz); assert_parse_fails(&fixture, algo); memset(fixture.encoded.buf + head_tree_offset(algo), 3, algo->rawsz); @@ -197,6 +193,26 @@ void test_clean_status_sidecar__rejects_invalid_proofs(void) fixture_release(&fixture); } +void test_clean_status_sidecar__accepts_null_checksum_with_durable_identity(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + + fixture_init(&fixture, algo); + oidclr(&fixture.sidecar.proof.index_checksum, algo); + cl_assert_equal_i(clean_status_sidecar_write( + &fixture.encoded, &fixture.sidecar, algo), + clean_status_identity_is_durable() ? 0 : -1); + if (clean_status_identity_is_durable()) { + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, + fixture.encoded.len, algo), 0); + cl_assert(is_null_oid(&parsed.proof.index_checksum)); + } + fixture_release(&fixture); +} + void test_clean_status_sidecar__rejects_invalid_tokens(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -270,11 +286,6 @@ void test_clean_status_sidecar__rejects_invalid_writes(void) unsigned char *token; fixture_init(&fixture, algo); - oidclr(&fixture.sidecar.proof.index_checksum, algo); - cl_assert_equal_i(clean_status_sidecar_write( - &fixture.encoded, &fixture.sidecar, algo), -1); - fill_oid(&fixture.sidecar.proof.index_checksum, 2, algo); - token = xmalloc(FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); memset(token, 'x', FSMONITOR_CLEAN_PROOF_TOKEN_MAX + 1); memcpy(token, "builtin:", strlen("builtin:")); From 4ec1853f373be4bec73945b3a273e766511b659a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 23:58:32 -0500 Subject: [PATCH 231/403] fsmonitor: restore recursive validity for paired untracked state FSUC records each directory validity bit, but valid_recursive is an in-memory summary rebuilt while scanning. External clean-history restore parses UNTR and FSUC into scratch state and pairs their token with FSMN, then hands that cache to status without rebuilding the summary. A root-only fsmonitor event therefore leaves the restored root looking non-recursive, and read_directory walks every cached subtree. Recompute valid_recursive when matching FSMN and FSUC tokens make the cache valid. This only folds already-validated child bits; later invalidation still clears ancestors through the existing path. Extend the FSUC parser test with a root and child so the paired-token transition checks both directory bits and their recursive summary. --- dir.c | 8 ++++++++ dir.h | 2 ++ fsmonitor.c | 3 +++ t/helper/test-read-cache.c | 7 +++++++ 4 files changed, 20 insertions(+) diff --git a/dir.c b/dir.c index c1057362ea573f..4645f4a42a911c 100644 --- a/dir.c +++ b/dir.c @@ -608,6 +608,14 @@ static int compute_untracked_cache_fsmonitor_valid_recursive( return valid; } +void untracked_cache_recompute_fsmonitor_valid_recursive( + struct untracked_cache *uc) +{ + if (!uc || !uc->root) + return; + compute_untracked_cache_fsmonitor_valid_recursive(uc->root); +} + static void untracked_cache_preload_join( struct untracked_cache_preload *preload) { diff --git a/dir.h b/dir.h index 088e06c1ada4d8..de1782a3f254f6 100644 --- a/dir.h +++ b/dir.h @@ -624,6 +624,8 @@ void untracked_cache_invalidate_all(struct index_state *); void untracked_cache_invalidate_trimmed_path(struct index_state *, const char *path, int safe_path); +void untracked_cache_recompute_fsmonitor_valid_recursive( + struct untracked_cache *); void untracked_cache_remove_from_index(struct index_state *, const char *); void untracked_cache_add_to_index(struct index_state *, const char *); diff --git a/fsmonitor.c b/fsmonitor.c index e8d91d6681cc09..76bb7e051176a0 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -261,6 +261,9 @@ void prepare_fsmonitor_untracked(struct index_state *istate) istate->fsmonitor_untracked_token && !strcmp(istate->fsmonitor_last_update, istate->fsmonitor_untracked_token))); + if (istate->fsmonitor_untracked_valid) + untracked_cache_recompute_fsmonitor_valid_recursive( + istate->untracked); } static struct ewah_bitmap *fsmonitor_bitmap_from_index( diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 5228c2065e4404..3009e38d3b0bb6 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -66,6 +66,8 @@ static int test_fsuc_parser(void) struct index_state truncated = INDEX_STATE_INIT(the_repository); struct untracked_cache untracked = { 0 }; struct untracked_cache_dir root = { 0 }; + struct untracked_cache_dir child = { 0 }; + struct untracked_cache_dir *dirs[] = { &child }; struct strbuf encoded = STRBUF_INIT; struct strbuf written = STRBUF_INIT; uint32_t version; @@ -89,9 +91,14 @@ static int test_fsuc_parser(void) duplicate.fsmonitor_token_valid = 1; duplicate.untracked = &untracked; untracked.root = &root; + root.valid = child.valid = 1; + root.dirs = dirs; + root.dirs_nr = ARRAY_SIZE(dirs); prepare_fsmonitor_untracked(&duplicate); if (!duplicate.fsmonitor_untracked_valid) return error("matching FSMN and FSUC tokens were not paired"); + if (!root.valid_recursive || !child.valid_recursive) + return error("matching FSUC did not restore recursive validity"); free(duplicate.fsmonitor_last_update); duplicate.fsmonitor_last_update = xstrdup("other"); prepare_fsmonitor_untracked(&duplicate); From dfe59c7624a164f12406a07e1cc4377d084afa5c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 00:03:34 -0500 Subject: [PATCH 232/403] status: bind external history to physical index sources 5f74c83117 (status: checkpoint clean history outside the index, 2026-08-07) binds each CSH1 payload to a logical digest of every ordered cache entry. A plain status after a foreign index rewrite must compute that digest before restore, then compute it again before reissuing the checkpoint. On the OpenAI checkout those two walks were the bulk of the roughly 900ms touch-x regression. Add a v2 CSH1 source alias for local APFS: durable stat identity, index version, entry count, and trailer checksum. Once repo_read_index() and a pinned snapshot prove the parsed index is that exact physical source, reuse the checkpoint logical hash instead of hashing every entry. Old v1 records remain readable and fall back to the digest. After status, reuse that source hash only when cache_changed contains only FSMN/UNTR acceleration changes, no cache entry changed, all flags remain in the digest accepted set, and sparse checkout is off. Any other state keeps the old digest-and-compare path. This lets an untracked-only status publish its advanced external FSMN/UNTR checkpoint without writing the main index or paying a second digest. Cover v1/v2 parsing and physical snapshot matching in unit tests. Extend the external-history test with a nested cache and a root-only dirty event; it must restore through the alias, avoid both digest regions, visit only the root, and publish updated external history. --- clean-status-history-store.c | 85 +++++++++++++++++++-- clean-status-history-store.h | 11 +++ clean-status-history.c | 38 +++++++-- clean-status-index.c | 61 +++++++++++++-- clean-status-index.h | 2 + t/t7530-status-clean-sidecar.sh | 26 +++++++ t/unit-tests/u-clean-status-history-store.c | 9 +++ 7 files changed, 209 insertions(+), 23 deletions(-) diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 572264ffafb4b2..1dbaf505f6b2eb 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -15,7 +15,8 @@ #include "wrapper.h" #define CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC "CSHS" -#define CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION 1 +#define CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION 2 +#define CLEAN_STATUS_HISTORY_CHECKPOINT_LEGACY_VERSION 1 #define CLEAN_STATUS_HISTORY_CHECKPOINT_MAX_SIZE (16 * 1024 * 1024) #define CLEAN_STATUS_HISTORY_STORE_MAX_FILES 8 #define CLEAN_STATUS_HISTORY_HAS_FSMN (1U << 0) @@ -195,7 +196,7 @@ int clean_status_history_checkpoint_parse( unsigned char expected_namespace[GIT_MAX_RAWSZ]; size_t minimum = 4 + 2 * sizeof(uint32_t) + 2 * algo->rawsz + 4 * sizeof(uint32_t) + algo->rawsz; - uint32_t flags, lengths[4]; + uint32_t version, flags, lengths[4]; memset(checkpoint, 0, sizeof(*checkpoint)); if (!proof_namespace || !*proof_namespace || len < minimum || @@ -205,9 +206,17 @@ int clean_status_history_checkpoint_parse( return -1; end = p + len - algo->rawsz; p += 4; - if (get_be32(p) != CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) + version = get_be32(p); + if (version != CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION && + version != CLEAN_STATUS_HISTORY_CHECKPOINT_LEGACY_VERSION) return -1; p += sizeof(uint32_t); + if (version == CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) { + minimum += CLEAN_STATUS_IDENTITY_SIZE + + 2 * sizeof(uint32_t) + algo->rawsz; + if (len < minimum) + return -1; + } flags = get_be32(p); p += sizeof(uint32_t); if ((flags & (CLEAN_STATUS_HISTORY_HAS_FSMN | @@ -227,6 +236,21 @@ int clean_status_history_checkpoint_parse( p += algo->rawsz; memcpy(checkpoint->index_hash, p, algo->rawsz); p += algo->rawsz; + if (version == CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION) { + if (clean_status_identity_read( + &p, end, &checkpoint->source_identity)) + return -1; + checkpoint->source_version = get_be32(p); + p += sizeof(uint32_t); + checkpoint->source_cache_nr = get_be32(p); + p += sizeof(uint32_t); + oidread(&checkpoint->source_checksum, p, algo); + p += algo->rawsz; + if (checkpoint->source_version < 2 || + checkpoint->source_version > 4) + return -1; + checkpoint->source_alias_valid = 1; + } for (size_t i = 0; i < ARRAY_SIZE(lengths); i++) { lengths[i] = get_be32(p); p += sizeof(uint32_t); @@ -274,6 +298,9 @@ int clean_status_history_checkpoint_write( { unsigned char namespace_hash[GIT_MAX_RAWSZ]; uint32_t value, flags = 0; + uint32_t version = checkpoint->source_alias_valid ? + CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION : + CLEAN_STATUS_HISTORY_CHECKPOINT_LEGACY_VERSION; strbuf_reset(out); if (!proof_namespace || !*proof_namespace || @@ -288,6 +315,10 @@ int clean_status_history_checkpoint_write( !!checkpoint->fsmonitor_config_len) || (!!checkpoint->fsmonitor_untracked != !!checkpoint->fsmonitor_untracked_len) || + (checkpoint->source_alias_valid && + (checkpoint->source_version < 2 || + checkpoint->source_version > 4 || + checkpoint->source_checksum.algo != hash_algo_by_ptr(algo))) || !checkpoint->fsmonitor_len || !checkpoint->fsmonitor_config_len || (!!checkpoint->untracked_cache_len != !!checkpoint->fsmonitor_untracked_len)) @@ -301,12 +332,21 @@ int clean_status_history_checkpoint_write( flags |= CLEAN_STATUS_HISTORY_HAS_FSUC; proof_namespace_hash(proof_namespace, algo, namespace_hash); strbuf_add(out, CLEAN_STATUS_HISTORY_CHECKPOINT_MAGIC, 4); - put_be32(&value, CLEAN_STATUS_HISTORY_CHECKPOINT_VERSION); + put_be32(&value, version); strbuf_add(out, &value, sizeof(value)); put_be32(&value, flags); strbuf_add(out, &value, sizeof(value)); strbuf_add(out, namespace_hash, algo->rawsz); strbuf_add(out, checkpoint->index_hash, algo->rawsz); + if (checkpoint->source_alias_valid) { + clean_status_identity_write(out, &checkpoint->source_identity); + put_be32(&value, checkpoint->source_version); + strbuf_add(out, &value, sizeof(value)); + put_be32(&value, checkpoint->source_cache_nr); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, checkpoint->source_checksum.hash, + algo->rawsz); + } put_be32(&value, checkpoint->fsmonitor_len); strbuf_add(out, &value, sizeof(value)); put_be32(&value, checkpoint->untracked_cache_len); @@ -397,6 +437,27 @@ static int local_apfs_id(int fd MAYBE_UNUSED, #endif } +int clean_status_history_checkpoint_source_matches( + const char *index_path, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo) +{ + struct clean_status_filesystem_id fsid; + + return checkpoint && checkpoint->source_alias_valid && + clean_status_identity_is_durable() && + snapshot && snapshot->fd >= 0 && + !local_apfs_id(snapshot->fd, &fsid) && + clean_status_identity_equal( + &checkpoint->source_identity, &snapshot->identity) && + checkpoint->source_version == snapshot->version && + checkpoint->source_cache_nr == snapshot->cache_nr && + oideq(&checkpoint->source_checksum, &snapshot->checksum) && + clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo); +} + int clean_status_history_store_install( const char *index_path, const char *proof_namespace, const struct clean_status_history_checkpoint *checkpoint, @@ -404,6 +465,7 @@ int clean_status_history_store_install( const struct git_hash_algo *algo) { struct clean_status_filesystem_id fsid; + struct clean_status_history_checkpoint aliased; struct clean_status_history_store_record current = CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; struct strbuf encoded = STRBUF_INIT; @@ -416,9 +478,16 @@ int clean_status_history_store_install( if (!clean_status_identity_is_durable() || !snapshot || snapshot->fd < 0 || local_apfs_id(snapshot->fd, &fsid) || !clean_status_index_snapshot_still_matches_path( - snapshot, index_path, algo) || - clean_status_history_checkpoint_write( - &encoded, proof_namespace, checkpoint, algo)) + snapshot, index_path, algo)) + goto done; + aliased = *checkpoint; + aliased.source_alias_valid = 1; + aliased.source_identity = snapshot->identity; + aliased.source_version = snapshot->version; + aliased.source_cache_nr = snapshot->cache_nr; + oidcpy(&aliased.source_checksum, &snapshot->checksum); + if (clean_status_history_checkpoint_write( + &encoded, proof_namespace, &aliased, algo)) goto done; current_is_regular = !lstat(path, &st) && S_ISREG(st.st_mode); if (!clean_status_history_store_load( @@ -430,7 +499,7 @@ int clean_status_history_store_install( /* * If this namespace is new, make room before the atomic install so a * successful publication never takes the bounded store above eight - * regular schema-v1 slots. No other checkpoint schema is considered. + * regular checkpoint slots. No other checkpoint schema is considered. */ if (prune_history_store( index_path, path, algo, diff --git a/clean-status-history-store.h b/clean-status-history-store.h index 22f3f6d5a085fd..82c7a267efb5bc 100644 --- a/clean-status-history-store.h +++ b/clean-status-history-store.h @@ -1,6 +1,7 @@ #ifndef CLEAN_STATUS_HISTORY_STORE_H #define CLEAN_STATUS_HISTORY_STORE_H +#include "clean-status-identity.h" #include "hash.h" #include "strbuf.h" @@ -8,6 +9,11 @@ struct clean_status_index_snapshot; struct clean_status_history_checkpoint { unsigned char index_hash[GIT_MAX_RAWSZ]; + unsigned int source_alias_valid : 1; + struct clean_status_identity source_identity; + uint32_t source_version; + uint32_t source_cache_nr; + struct object_id source_checksum; const unsigned char *fsmonitor; size_t fsmonitor_len; const unsigned char *untracked_cache; @@ -46,5 +52,10 @@ int clean_status_history_store_install( const struct clean_status_history_checkpoint *checkpoint, const struct clean_status_index_snapshot *snapshot, const struct git_hash_algo *algo); +int clean_status_history_checkpoint_source_matches( + const char *index_path, + const struct clean_status_history_checkpoint *checkpoint, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo); #endif /* CLEAN_STATUS_HISTORY_STORE_H */ diff --git a/clean-status-history.c b/clean-status-history.c index 28f32092325d06..03f9c209b56104 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -369,8 +369,12 @@ clean_status_prepare_external_history(struct index_state *istate) "history/external-save-reject", "namespace"); goto fail; } - if (clean_status_index_logical_digest_after_status( - istate, checkpoint->checkpoint.index_hash)) { + if (clean_status_index_can_reuse_source_logical_hash(istate)) { + memcpy(checkpoint->checkpoint.index_hash, + state->source_logical_hash, + istate->repo->hash_algo->rawsz); + } else if (clean_status_index_logical_digest_after_status( + istate, checkpoint->checkpoint.index_hash)) { trace2_data_string("fsmonitor", istate->repo, "history/external-save-reject", "logical-flags"); goto fail; @@ -488,6 +492,7 @@ int clean_status_restore_external_history(struct index_state *istate) struct index_state parsed = INDEX_STATE_INIT(istate->repo); unsigned char index_hash[GIT_MAX_RAWSZ]; char proof_namespace[GIT_MAX_HEXSZ + 1]; + int record_loaded = 0; int restored = 0; if (!clean_status_external_history_enabled(istate) || !state || @@ -496,16 +501,33 @@ int clean_status_restore_external_history(struct index_state *istate) !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || on_index_history_is_coherent(istate) || - clean_status_index_snapshot_pin(&snapshot, istate) || - clean_status_index_logical_digest(istate, index_hash)) + clean_status_index_snapshot_pin(&snapshot, istate)) goto done; + if (external_history_namespace(istate, proof_namespace)) + goto done; + if (!clean_status_history_store_load( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo, &record)) { + record_loaded = 1; + if (clean_status_index_can_reuse_source_logical_hash(istate) && + clean_status_history_checkpoint_source_matches( + istate->repo->index_file, &record.checkpoint, + &snapshot, istate->repo->hash_algo)) { + memcpy(index_hash, record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz); + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-physical-alias", 1); + goto have_index_hash; + } + } + if (clean_status_index_logical_digest(istate, index_hash)) + goto done; + +have_index_hash: memcpy(state->source_logical_hash, index_hash, istate->repo->hash_algo->rawsz); state->source_logical_hash_valid = 1; - if (external_history_namespace(istate, proof_namespace) || - clean_status_history_store_load( - istate->repo->index_file, proof_namespace, - istate->repo->hash_algo, &record) || + if (!record_loaded || memcmp(index_hash, record.checkpoint.index_hash, istate->repo->hash_algo->rawsz)) goto done; diff --git a/clean-status-index.c b/clean-status-index.c index 0ea48833ed5d0c..4ea3c4bfc9d746 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -9,6 +9,11 @@ #include "trace2.h" #include "wrapper.h" +#define LOGICAL_INDEX_PERSISTENT_FLAGS \ + (CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS) +#define LOGICAL_INDEX_BENIGN_FLAGS \ + (CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID) + static int snapshot_read( int fd, const struct stat *st, const struct git_hash_algo *algo, uint32_t *version, uint32_t *cache_nr, struct object_id *checksum) @@ -248,15 +253,56 @@ int clean_status_index_is_certifiable(const struct index_state *istate) clean_status_index_entries_are_certifiable(istate); } +static int index_entry_logical_state_is_supported( + const struct cache_entry *ce, unsigned int extra_benign_flags) +{ + return !(ce->ce_flags & ~(LOGICAL_INDEX_PERSISTENT_FLAGS | + LOGICAL_INDEX_BENIGN_FLAGS | + extra_benign_flags)); +} + +static int index_logical_state_is_supported( + const struct index_state *istate, unsigned int extra_benign_flags) +{ + if (!istate->repo || !istate->repo->hash_algo || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + istate->cache_nr > UINT32_MAX) + return 0; + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + if (!index_entry_logical_state_is_supported( + ce, extra_benign_flags)) + return 0; + } + return 1; +} + +int clean_status_index_can_reuse_source_logical_hash( + const struct index_state *istate) +{ + const unsigned int acceleration_changes = + FSMONITOR_CHANGED | UNTRACKED_CHANGED; + + /* + * Reading or refreshing acceleration extensions may mark only FSMN/UNTR + * state dirty. Reject any cache-entry change, while the flag walk + * preserves every reject condition which the logical digest enforced + * before a physical alias could skip it. Sparse-checkout post-processing + * may clear CE_SKIP_WORKTREE without setting cache_changed, so leave that + * mode on the digest path. + */ + return istate->repo && istate->repo->initialized && + !repo_config_values(istate->repo)->apply_sparse_checkout && + !(istate->cache_changed & ~acceleration_changes) && + index_logical_state_is_supported(istate, 0); +} + static int index_logical_digest(const struct index_state *istate, unsigned int extra_benign_flags, unsigned char *out) { static const char domain[] = "git-clean-status-logical-index-v1"; - const unsigned int persistent_flags = - CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; - const unsigned int benign_flags = - CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID; struct git_hash_ctx ctx; uint32_t value; int initialized = 0, ret = -1; @@ -282,12 +328,13 @@ static int index_logical_digest(const struct index_state *istate, * CE_CONTENT_CHECK_REQUIRED must not disappear with the process * which raised it. */ - if (ce->ce_flags & ~(persistent_flags | benign_flags | - extra_benign_flags)) + if (!index_entry_logical_state_is_supported( + ce, extra_benign_flags)) goto done; put_be32(&value, ce->ce_mode); hash_length_delimited(&ctx, &value, sizeof(value)); - put_be32(&value, ce->ce_flags & persistent_flags); + put_be32(&value, + ce->ce_flags & LOGICAL_INDEX_PERSISTENT_FLAGS); hash_length_delimited(&ctx, &value, sizeof(value)); hash_length_delimited(&ctx, ce->oid.hash, istate->repo->hash_algo->rawsz); diff --git a/clean-status-index.h b/clean-status-index.h index 6da2b37b73031a..fc473d6c6f1330 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -48,5 +48,7 @@ int clean_status_index_logical_digest(const struct index_state *istate, unsigned char *out); int clean_status_index_logical_digest_after_status( const struct index_state *istate, unsigned char *out); +int clean_status_index_can_reuse_source_logical_hash( + const struct index_state *istate); #endif /* CLEAN_STATUS_INDEX_H */ diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 0459708491cd78..b0f61b2d9f6d19 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -645,6 +645,10 @@ test_expect_success DURABLE_FSMONITOR \ 'normal status restores namespace-specific history outside the index' ' test_when_finished "stop_daemon external-history" && setup_repo external-history && + mkdir -p external-history/cached/deep && + test_commit -C external-history nested cached/deep/tracked && + test-tool -C external-history chmtime =-60 cached/deep/tracked && + git -C external-history update-index --refresh && git -C external-history config core.untrackedCache true && git -C external-history config index.skipHash true && git -C external-history config status.renameLimit 100 && @@ -763,6 +767,28 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" external-merge.trace && rm external-history/.git/MERGE_HEAD && + # A root-only dirty event stays shallow after external FSUC restore and + # advances only the external acceleration history. + rm -f external-history/.git/index.csts && + cp "$sidecar" sidecar.before-dirty && + : >external-history/root-probe && + sleep 1 && + test_env GIT_TRACE2_EVENT_NESTING=10 \ + GIT_TRACE2_EVENT="$PWD/external-dirty.trace" \ + git -C external-history status >actual.dirty && + test_grep root-probe actual.dirty && + test_trace2_data fsmonitor history/external-physical-alias 1 \ + Date: Mon, 10 Aug 2026 11:43:40 -0500 Subject: [PATCH 233/403] status: refresh external history before exact proofs An exact clean porcelain-v2 status can publish a physical CSTS proof after advancing the builtin fsmonitor boundary, while leaving the external CSH1 checkpoint at an older token. If another Git later rewrites the index, plain status restores that stale checkpoint and can receive a trivial response from a token which no longer replays. Enable external-history publication for the exact clean producer. Capture the source logical hash before refresh, publish CSH1 for the closed token first, and issue CSTS only after that publication succeeds. If either acceleration-only publication succeeds but the physical proof cannot be installed, keep the existing rollback behavior instead of writing the main index. External checkpoints carry fsmonitor and untracked-cache acceleration state, but not cache-entry stat data. A fresh status can repair that data, set CE_ENTRY_CHANGED, and then take the publication path above. Do not let a fresh checkpoint roll back the write which makes those repairs durable. After restoring a foreign checkpoint, keep the existing no-spill behavior. Treat absent optional UNTR and FSUC payloads as NULL while encoding CSH1. Repositories without an untracked cache otherwise reject the checkpoint which CSTS now depends on. Cover both plain bootstrap status and exact porcelain status: the first repair writes the index without publishing CSH1, and the following exact status can publish CSTS without another index write. --- .../technical/status-clean-proof.adoc | 10 +++- builtin/commit.c | 53 +++++++++++++---- clean-status-history.c | 51 ++++++++++++++++- clean-status.h | 2 + t/t7530-status-clean-sidecar.sh | 57 +++++++++++++++++-- 5 files changed, 151 insertions(+), 22 deletions(-) diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc index 0ee74695697bca..cbfd8fc892de04 100644 --- a/Documentation/technical/status-clean-proof.adoc +++ b/Documentation/technical/status-clean-proof.adoc @@ -124,6 +124,9 @@ implementation has rewritten or re-encoded the index. The store is consulted only after the ordinary index entries have been read. It cannot answer a status command by itself, but a successful restore can publish a new physical proof for the next unchanged-index plain status. +The exact clean porcelain-v2 producer also refreshes this checkpoint +before publishing its physical proof, so both files name the same +provider boundary after a later foreign index rewrite. For an index at ``, a checkpoint is stored as `.csh1.`. The namespace covers the checkpoint @@ -172,6 +175,7 @@ succeeds, status rolls back the pending acceleration-only index update. After an external restore, status also leaves the main index untouched if republication fails, so one proof namespace is not copied over another implementation's index extensions. Only literal normal status -enables this lane; commands capable of logical index changes retain the -normal index-writing path. Optional-lock-free commands neither publish -checkpoints nor use this rollback path. +and the exact clean porcelain-v2 producer enable this lane; commands +capable of logical index changes retain the normal index-writing path. +Optional-lock-free commands neither publish checkpoints nor use this +rollback path. diff --git a/builtin/commit.c b/builtin/commit.c index eb266fd1304810..7804bcef724670 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1736,8 +1736,6 @@ struct repository *repo UNUSED) prefix, argv); s.allow_clean_status_shortcuts = default_status_command && !s.pathspec.nr; - if (s.allow_clean_status_shortcuts) - clean_status_enable_external_history(the_repository); normal_has_head = default_status_command && !repo_get_oid(the_repository, s.reference, &oid); exact_clean_query = exact_clean_command && @@ -1752,6 +1750,8 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; + if (s.allow_clean_status_shortcuts || exact_clean_query) + clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; if ((exact_clean_query || normal_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { @@ -1766,6 +1766,9 @@ struct repository *repo UNUSED) status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); + if (exact_clean_query && use_optional_locks()) + clean_status_capture_external_history_source( + the_repository->index); if (normal_clean_query && use_optional_locks() && clean_status_external_history_was_restored( the_repository->index)) @@ -1800,23 +1803,49 @@ struct repository *repo UNUSED) wt_status_collect(&s); - if (exact_clean_command && 0 <= fd && - clean_status_issue_sidecar( - &s, &clean_digest, &index_lock, 0)) - fd = -1; if (0 <= fd) { int external_restored = clean_status_external_history_was_restored( the_repository->index); - int external_saved = - clean_status_save_external_history( + int external_saved = 0; + int preserve_entry_changes = + !external_restored && + (the_repository->index->cache_changed & + CE_ENTRY_CHANGED); + + /* + * Publish resumable history before the physical clean proof. + * A later foreign index rewrite can only recover the proof if + * both files name the same provider boundary. + * + * CSH1 carries acceleration state, not refreshed stat data. + * A fresh checkpoint names the pre-repair physical index; do + * not let publishing it roll back the write which makes an + * entry repair durable. Restored checkpoints stay no-spill + * for foreign index writers. + */ + if (!preserve_entry_changes && + (!exact_clean_query || + (clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index) && + !s.change.nr && !s.untracked.nr && !s.ignored.nr))) + external_saved = clean_status_save_external_history( the_repository->index); - if (normal_clean_query && external_restored && - clean_status_issue_sidecar( - &s, &clean_digest, &index_lock, 1)) + if (exact_clean_query) { + if (external_saved && + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 0)) + fd = -1; + else if (external_restored || external_saved) { + rollback_lock_file(&index_lock); + fd = -1; + } + } else if (normal_clean_query && external_restored && + clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 1)) { fd = -1; - else if (external_restored || external_saved) { + } else if (external_restored || external_saved) { rollback_lock_file(&index_lock); fd = -1; } diff --git a/clean-status-history.c b/clean-status-history.c index 03f9c209b56104..82f8f1af0faccc 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -343,6 +343,49 @@ static int external_history_namespace(struct index_state *istate, char *out) return ret; } +void clean_status_capture_external_history_source( + struct index_state *istate) +{ + struct clean_status_history_store_record record = + CLEAN_STATUS_HISTORY_STORE_RECORD_INIT; + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + struct clean_status_state *state = istate->clean_status; + char proof_namespace[GIT_MAX_HEXSZ + 1]; + + if (!clean_status_external_history_enabled(istate) || + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || + !state) + goto done; + if (state->source_logical_hash_valid) + goto done; + if (!clean_status_has_persistent_fsmonitor_semantic_history(istate)) + goto done; + if (clean_status_index_snapshot_pin(&snapshot, istate)) + goto done; + if (!external_history_namespace(istate, proof_namespace) && + !clean_status_history_store_load( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo, &record) && + clean_status_index_can_reuse_source_logical_hash(istate) && + clean_status_history_checkpoint_source_matches( + istate->repo->index_file, &record.checkpoint, + &snapshot, istate->repo->hash_algo)) { + memcpy(state->source_logical_hash, + record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz); + } else if (clean_status_index_logical_digest( + istate, state->source_logical_hash)) { + goto done; + } + if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + goto done; + state->source_logical_hash_valid = 1; + +done: + clean_status_index_snapshot_release(&snapshot); + clean_status_history_store_record_release(&record); +} + static struct clean_status_external_checkpoint * clean_status_prepare_external_history(struct index_state *istate) { @@ -408,7 +451,9 @@ clean_status_prepare_external_history(struct index_state *istate) (const unsigned char *)checkpoint->fsmonitor.buf; checkpoint->checkpoint.fsmonitor_len = checkpoint->fsmonitor.len; checkpoint->checkpoint.untracked_cache = - (const unsigned char *)checkpoint->untracked_cache.buf; + checkpoint->untracked_cache.len ? + (const unsigned char *)checkpoint->untracked_cache.buf : + NULL; checkpoint->checkpoint.untracked_cache_len = checkpoint->untracked_cache.len; checkpoint->checkpoint.fsmonitor_config = @@ -416,7 +461,9 @@ clean_status_prepare_external_history(struct index_state *istate) checkpoint->checkpoint.fsmonitor_config_len = checkpoint->fsmonitor_config.len; checkpoint->checkpoint.fsmonitor_untracked = - (const unsigned char *)checkpoint->fsmonitor_untracked.buf; + checkpoint->fsmonitor_untracked.len ? + (const unsigned char *)checkpoint->fsmonitor_untracked.buf : + NULL; checkpoint->checkpoint.fsmonitor_untracked_len = checkpoint->fsmonitor_untracked.len; return checkpoint; diff --git a/clean-status.h b/clean-status.h index 4432903437dd27..a50ef3af709840 100644 --- a/clean-status.h +++ b/clean-status.h @@ -106,6 +106,8 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, int clean_status_restore_external_history(struct index_state *istate); int clean_status_external_history_was_restored( const struct index_state *istate); +void clean_status_capture_external_history_source( + struct index_state *istate); int clean_status_save_external_history(struct index_state *istate); void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index b0f61b2d9f6d19..c46acb880d0375 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -62,8 +62,8 @@ prime_semantic_history () { issue_sidecar () { repo=$1 && - prime_semantic_history "$repo" && git -C "$repo" config core.autocrlf false && + prime_semantic_history "$repo" && bulk_status -C "$repo" status --porcelain=v2 >actual.issue && test_must_be_empty actual.issue && test_path_is_file "$repo/.git/index.csts" @@ -201,10 +201,9 @@ test_expect_success DURABLE_FSMONITOR \ >actual.first && test_must_be_empty actual.first && test_path_is_missing sidecar-issue/.git/index.csts && - test_grep "\"value\":\"issue-coherent-history\"" first-scan.trace && - prime_semantic_history sidecar-issue && git -C sidecar-issue config core.autocrlf false && + prime_semantic_history sidecar-issue && cp sidecar-issue/.git/index index.before && test_env GIT_TRACE2_EVENT="$PWD/issue.trace" \ @@ -212,6 +211,8 @@ test_expect_success DURABLE_FSMONITOR \ test_must_be_empty actual && test_cmp index.before sidecar-issue/.git/index && test_path_is_file sidecar-issue/.git/index.csts && + test_trace2_data fsmonitor history/external-stored 1 \ + actual && + ! test_trace2_data fsmonitor history/external-stored 1 \ + flush.out && + test_env GIT_TRACE2_EVENT="$PWD/external-stat-exact.trace" \ + bulk_status -C external-stat-exact status --porcelain=v2 \ + >actual && + test_must_be_empty actual && + ! test_trace2_data fsmonitor history/external-stored 1 \ + actual && + test_must_be_empty actual && + test_trace2_data fsmonitor history/external-stored 1 \ + sidecar-root-race.replacement/replacement-only && @@ -554,8 +601,8 @@ test_expect_success DURABLE_FSMONITOR \ git -C sidecar-v4 config index.version 4 && git -C sidecar-v4 config index.skipHash true && git -C sidecar-v4 update-index --force-write-index && - prime_semantic_history sidecar-v4 && git -C sidecar-v4 config core.autocrlf false && + prime_semantic_history sidecar-v4 && dd if=/dev/zero of=zeros bs=20 count=1 2>/dev/null && tail -c 20 sidecar-v4/.git/index >trailer && From 9ecbcff3eb023dcd5f20ff0963872db1e62ea445 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 11:49:08 -0500 Subject: [PATCH 234/403] status: keep a forward fsmonitor boundary over stale history A foreign writer can leave a newer usable FSMN token in the named index while CSH1 still carries an older token from a prior daemon epoch. Restoring that checkpoint replaces the forward boundary with one which cannot replay. The subsequent trivial response drives semantic strong invalidation and a full content scan. When the named index and CSH1 carry different builtin IPC tokens, probe the checkpoint token before installing it. Restore only if the daemon can still return a delta from that boundary. Otherwise keep the named index token, so the ordinary forward-baseline path can query the live boundary without borrowing stale FSCF or FSUC state. The extra query runs only for differing builtin IPC tokens. Other provider schemes keep the existing restore path because their replay semantics are not established here. The focused daemon-epoch case asserts that the stale checkpoint is not restored and refresh queries the main index token. --- .../technical/status-clean-proof.adoc | 5 ++- clean-status-history.c | 44 +++++++++++++++++++ t/t7530-status-clean-sidecar.sh | 32 +++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc index cbfd8fc892de04..fb5f24da58a133 100644 --- a/Documentation/technical/status-clean-proof.adoc +++ b/Documentation/technical/status-clean-proof.adoc @@ -163,7 +163,10 @@ which can change entry membership or persistent flags. On a valid hit, status installs all checkpoint sections together in a scratch index, rechecks the pinned index, and only then replaces the in-memory acceleration state. It queries the builtin file-system -monitor from the stored token. A trivial response, provider restart, +monitor from the stored token. If the named index already carries a +different usable builtin token, status first requires the stored token +to return a delta; otherwise it keeps the named boundary for the +forward-baseline fallback. A trivial response, provider restart, malformed section, token mismatch, namespace mismatch, or index race falls back to ordinary validation. diff --git a/clean-status-history.c b/clean-status-history.c index 82f8f1af0faccc..793d930fa55633 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -6,8 +6,10 @@ #include "clean-status-internal.h" #include "dir.h" #include "environment.h" +#include "fsmonitor.h" #include "fsmonitor-clean-proof.h" #include "fsmonitor-ll.h" +#include "fsmonitor-settings.h" #include "hash-framing.h" #include "hex.h" #include "read-cache-ll.h" @@ -530,6 +532,28 @@ static int on_index_history_is_coherent(struct index_state *istate) (!istate->untracked || istate->fsmonitor_untracked_valid); } +static int has_usable_on_index_builtin_token( + const struct index_state *istate) +{ + return istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + *istate->fsmonitor_last_update && + starts_with(istate->fsmonitor_last_update, "builtin:") && + strcmp(istate->fsmonitor_last_update, "builtin:fake"); +} + +static int external_token_is_replayable(const char *token) +{ + struct fsmonitor_query_result result = + FSMONITOR_QUERY_RESULT_INIT; + int replayable = + query_builtin_fsmonitor(token, &result) == + FSMONITOR_QUERY_DELTA; + + fsmonitor_query_result_release(&result); + return replayable; +} + int clean_status_restore_external_history(struct index_state *istate) { struct clean_status_history_store_record record = @@ -608,6 +632,26 @@ int clean_status_restore_external_history(struct index_state *istate) if (!current_proof_is_writable(&parsed) || (!!parsed.untracked && !parsed.fsmonitor_untracked_valid)) goto done; + /* + * Provider tokens are opaque. A logical-index match says that the + * checkpoint names the same staged entries; it does not say that its + * token can still replay the interval which the named index already + * crossed. Probe a differing checkpoint token before replacing a + * usable on-index boundary when builtin IPC can answer that question. + * A successful delta is queried again by the normal refresh path; a + * trivial or failed probe leaves the named index intact so its token + * can take the forward-baseline fallback. + */ + if (fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + has_usable_on_index_builtin_token(istate) && + starts_with(parsed.fsmonitor_last_update, "builtin:") && + strcmp(istate->fsmonitor_last_update, + parsed.fsmonitor_last_update) && + !external_token_is_replayable(parsed.fsmonitor_last_update)) { + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-token-unreplayable", 1); + goto done; + } if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) goto done; clean_status_invalidate_current_proof(istate); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index c46acb880d0375..521f3e8cb81c6d 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -839,7 +839,6 @@ test_expect_success DURABLE_FSMONITOR \ # A failed checkpoint refresh must not spill namespace B into main. cp namespace-a-v2.index namespace-a-v2.rewrite && mv namespace-a-v2.rewrite external-history/.git/index && - test-tool -C external-history fsmonitor-client flush >flush.out && : >"$sidecar.lock" && test_when_finished "rm -f \"$sidecar.lock\"" && cp external-history/.git/index locked.before && @@ -851,7 +850,36 @@ test_expect_success DURABLE_FSMONITOR \ flush.out && + git -C external-history config status.renameLimit 100 && + test_env GIT_TRACE2_EVENT="$PWD/external-main-token.trace" \ + git -C external-history status --porcelain=v2 \ + --untracked-files=normal >actual.main-token && + test_grep "\"label\":\"do_write_index\"" \ + external-main-token.trace && + test-tool -C external-history dump-fsmonitor >main-token && + main_token=$(sed -n "s/^fsmonitor last update //p" main-token) && + + git -C external-history config status.renameLimit 200 && + test_env GIT_TRACE2_EVENT="$PWD/external-token.trace" \ + git -C external-history status >actual.token && + test_grep "nothing to commit, working tree clean" actual.token && + test_trace2_data fsmonitor history/external-token-unreplayable 1 \ + Date: Mon, 10 Aug 2026 22:48:56 -0500 Subject: [PATCH 235/403] status: preserve external history for dirty root-wide queries --- builtin/commit.c | 21 +++--- clean-status-history-store.c | 17 +++-- clean-status-history.c | 39 ++++++++--- t/t7530-status-clean-sidecar.sh | 75 ++++++++++++++++++++- t/unit-tests/u-clean-status-history-store.c | 2 - 5 files changed, 121 insertions(+), 33 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 7804bcef724670..92d635134fee24 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1750,7 +1750,7 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; - if (s.allow_clean_status_shortcuts || exact_clean_query) + if (!s.pathspec.nr) clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; if ((exact_clean_query || normal_clean_query) && @@ -1766,7 +1766,7 @@ struct repository *repo UNUSED) status_format != STATUS_FORMAT_PORCELAIN_V2) progress_flag = REFRESH_PROGRESS; repo_read_index(the_repository); - if (exact_clean_query && use_optional_locks()) + if (!s.pathspec.nr && use_optional_locks()) clean_status_capture_external_history_source( the_repository->index); if (normal_clean_query && use_optional_locks() && @@ -1824,28 +1824,27 @@ struct repository *repo UNUSED) * entry repair durable. Restored checkpoints stay no-spill * for foreign index writers. */ - if (!preserve_entry_changes && - (!exact_clean_query || - (clean_status_has_persistent_fsmonitor_semantic_history( - the_repository->index) && - !s.change.nr && !s.untracked.nr && !s.ignored.nr))) + if (!s.pathspec.nr) external_saved = clean_status_save_external_history( the_repository->index); if (exact_clean_query) { - if (external_saved && + if (!preserve_entry_changes && external_saved && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 0)) fd = -1; - else if (external_restored || external_saved) { + else if (!preserve_entry_changes && + (external_restored || external_saved)) { rollback_lock_file(&index_lock); fd = -1; } - } else if (normal_clean_query && external_restored && + } else if (!preserve_entry_changes && + normal_clean_query && external_restored && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 1)) { fd = -1; - } else if (external_restored || external_saved) { + } else if (!preserve_entry_changes && + (external_restored || external_saved)) { rollback_lock_file(&index_lock); fd = -1; } diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 1dbaf505f6b2eb..49b37166ce56fd 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -475,17 +475,20 @@ int clean_status_history_store_install( int current_is_regular, encoded_matches = 0; int checkpoint_fd = -1, ret = -1; - if (!clean_status_identity_is_durable() || !snapshot || - snapshot->fd < 0 || local_apfs_id(snapshot->fd, &fsid) || + if (!snapshot || snapshot->fd < 0 || !clean_status_index_snapshot_still_matches_path( snapshot, index_path, algo)) goto done; aliased = *checkpoint; - aliased.source_alias_valid = 1; - aliased.source_identity = snapshot->identity; - aliased.source_version = snapshot->version; - aliased.source_cache_nr = snapshot->cache_nr; - oidcpy(&aliased.source_checksum, &snapshot->checksum); + aliased.source_alias_valid = + clean_status_identity_is_durable() && + !local_apfs_id(snapshot->fd, &fsid); + if (aliased.source_alias_valid) { + aliased.source_identity = snapshot->identity; + aliased.source_version = snapshot->version; + aliased.source_cache_nr = snapshot->cache_nr; + oidcpy(&aliased.source_checksum, &snapshot->checksum); + } if (clean_status_history_checkpoint_write( &encoded, proof_namespace, &aliased, algo)) goto done; diff --git a/clean-status-history.c b/clean-status-history.c index 793d930fa55633..ba4c822db01f2a 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -362,7 +362,7 @@ void clean_status_capture_external_history_source( goto done; if (!clean_status_has_persistent_fsmonitor_semantic_history(istate)) goto done; - if (clean_status_index_snapshot_pin(&snapshot, istate)) + if (clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate)) goto done; if (!external_history_namespace(istate, proof_namespace) && !clean_status_history_store_load( @@ -379,7 +379,8 @@ void clean_status_capture_external_history_source( istate, state->source_logical_hash)) { goto done; } - if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + if (!clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, istate)) goto done; state->source_logical_hash_valid = 1; @@ -397,12 +398,28 @@ clean_status_prepare_external_history(struct index_state *istate) CE_ENTRY_CHANGED | FSMONITOR_CHANGED | UNTRACKED_CHANGED; if (!clean_status_external_history_enabled(istate) || - getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || - !state || !state->source_logical_hash_valid || - !current_proof_is_writable(istate) || - (istate->cache_changed & ~acceleration_changes) || - has_racy_timestamp(istate)) + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index) + return NULL; + if (!state || !state->source_logical_hash_valid) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "missing-source"); + return NULL; + } + if (!current_proof_is_writable(istate)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "unwritable-proof"); + return NULL; + } + if (istate->cache_changed & ~acceleration_changes) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "logical-flags"); return NULL; + } + if (has_racy_timestamp(istate)) { + trace2_data_string("fsmonitor", istate->repo, + "history/external-save-reject", "racy-index"); + return NULL; + } CALLOC_ARRAY(checkpoint, 1); checkpoint->fsmonitor = (struct strbuf)STRBUF_INIT; checkpoint->untracked_cache = (struct strbuf)STRBUF_INIT; @@ -482,7 +499,8 @@ static int clean_status_install_external_history( struct clean_status_index_snapshot snapshot = { .fd = -1 }; int installed = 0; - if (!checkpoint || clean_status_index_snapshot_pin(&snapshot, istate) || + if (!checkpoint || + clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate) || clean_status_history_store_install( istate->repo->index_file, checkpoint->proof_namespace, &checkpoint->checkpoint, &snapshot, @@ -572,7 +590,7 @@ int clean_status_restore_external_history(struct index_state *istate) !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || on_index_history_is_coherent(istate) || - clean_status_index_snapshot_pin(&snapshot, istate)) + clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate)) goto done; if (external_history_namespace(istate, proof_namespace)) goto done; @@ -652,7 +670,8 @@ int clean_status_restore_external_history(struct index_state *istate) "history/external-token-unreplayable", 1); goto done; } - if (!clean_status_index_snapshot_still_matches(&snapshot, istate)) + if (!clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, istate)) goto done; clean_status_invalidate_current_proof(istate); clean_status_copy_fsmonitor_history(istate, &parsed); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 521f3e8cb81c6d..911ea85a9b714e 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -57,7 +57,8 @@ prime_semantic_history () { test_must_be_empty actual.1 && bulk_status -C "$repo" status --porcelain=2 >actual.2 && test_must_be_empty actual.2 && - test_grep FSCF "$repo/.git/index" + test_grep FSCF "$repo/.git/index" && + rm -f "$repo"/.git/index.csh1.* } issue_sidecar () { @@ -227,6 +228,73 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'dirty exact status checkpoints history without certifying cleanliness' ' + test_when_finished "stop_daemon external-dirty-exact" && + setup_repo external-dirty-exact && + git -C external-dirty-exact config core.untrackedCache true && + prime_semantic_history external-dirty-exact && + test_write_lines changed >external-dirty-exact/tracked && + test-tool chmtime -60 external-dirty-exact/tracked && + bulk_status -C external-dirty-exact status --porcelain=2 \ + >external-dirty-exact.primed && + test_env GIT_TRACE2_EVENT="$PWD/external-dirty-exact.trace" \ + bulk_status -C external-dirty-exact status --porcelain=v2 \ + >actual && + test_grep "^1 \.M .* tracked$" actual && + test_trace2_data fsmonitor history/external-stored 1 \ + external-dirty-exact.checkpoints && + test_line_count = 1 external-dirty-exact.checkpoints +' + +test_expect_success DURABLE_FSMONITOR \ + 'daemon-shaped dirty status checkpoints resumable history' ' + test_when_finished "stop_daemon external-daemon-shape" && + setup_repo external-daemon-shape && + git -C external-daemon-shape config core.untrackedCache true && + prime_semantic_history external-daemon-shape && + test_write_lines changed >external-daemon-shape/tracked && + test-tool chmtime -60 external-daemon-shape/tracked && + bulk_status -C external-daemon-shape status --porcelain=2 \ + >external-daemon-shape.primed && + test_env GIT_TRACE2_EVENT="$PWD/external-daemon-shape.trace" \ + bulk_status -C external-daemon-shape \ + status --porcelain=v2 -z --branch --show-stash \ + --no-ahead-behind --untracked-files=normal \ + --ignore-submodules=all >actual && + test_trace2_data fsmonitor history/external-stored 1 \ + external-daemon-shape.checkpoints && + test_line_count = 1 external-daemon-shape.checkpoints +' + +test_expect_success DURABLE_FSMONITOR \ + 'nested status uses root-wide resumable history' ' + test_when_finished "stop_daemon external-nested-status" && + setup_repo external-nested-status && + git -C external-nested-status config core.untrackedCache true && + prime_semantic_history external-nested-status && + mkdir -p external-nested-status/deep/inside && + test_write_lines changed >external-nested-status/tracked && + test-tool chmtime -60 external-nested-status/tracked && + bulk_status -C external-nested-status status --porcelain=2 \ + >external-nested-status.primed && + test_env GIT_TRACE2_EVENT="$PWD/external-nested-status.trace" \ + bulk_status -C external-nested-status/deep/inside \ + status --porcelain=v2 >actual && + test_grep "^1 \.M .* \.\./\.\./tracked$" actual && + test_trace2_data fsmonitor history/external-stored 1 \ + external-nested-status.checkpoints && + test_line_count = 1 external-nested-status.checkpoints +' + test_expect_success DURABLE_FSMONITOR \ 'normal status persists bootstrap stat repairs' ' test_when_finished "stop_daemon external-stat-bootstrap" && @@ -234,7 +302,7 @@ test_expect_success DURABLE_FSMONITOR \ git -C external-stat-bootstrap update-index --fsmonitor && test_env GIT_TRACE2_EVENT="$PWD/external-stat-bootstrap.trace" \ git -C external-stat-bootstrap status >actual && - ! test_trace2_data fsmonitor history/external-stored 1 \ + test_trace2_data fsmonitor history/external-stored 1 \ flush.out && git -C external-history config status.renameLimit 100 && - test_env GIT_TRACE2_EVENT="$PWD/external-main-token.trace" \ + test_env GIT_INDEX_FILE="$PWD/external-history/.git/index" \ + GIT_TRACE2_EVENT="$PWD/external-main-token.trace" \ git -C external-history status --porcelain=v2 \ --untracked-files=normal >actual.main-token && test_grep "\"label\":\"do_write_index\"" \ diff --git a/t/unit-tests/u-clean-status-history-store.c b/t/unit-tests/u-clean-status-history-store.c index f6a8d1062e34fb..73719517ecc3f8 100644 --- a/t/unit-tests/u-clean-status-history-store.c +++ b/t/unit-tests/u-clean-status-history-store.c @@ -189,7 +189,6 @@ void test_clean_status_history_store__keeps_namespaces_independent(void) static const unsigned char second_fsmn[] = "second-fsmn"; static const unsigned char second_fscf[] = "second-fscf"; - require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); fixture_init(&fixture, algo); memset(first.index_hash, 1, algo->rawsz); first.fsmonitor = first_fsmn; @@ -292,7 +291,6 @@ void test_clean_status_history_store__bounds_namespaces(void) struct utimbuf times; char namespace[32]; - require_local_apfs(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); fixture_init(&fixture, algo); checkpoint.fsmonitor = fsmn; checkpoint.fsmonitor_len = sizeof(fsmn) - 1; From 29a5c60adb8689d24527b933c53d4d224c396d87 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:49:03 -0500 Subject: [PATCH 236/403] status: show delayed progress during semantic refresh --- builtin/commit.c | 5 +++- clean-status.c | 48 ++++++++++++++++++++++++++++++++++++++ clean-status.h | 7 ++++++ semantic-verify-internal.h | 2 ++ semantic-verify-worker.c | 13 +++++++++-- semantic-verify.c | 6 +++++ worktree-attr-manifest.c | 19 +++++++++++++-- 7 files changed, 95 insertions(+), 5 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 92d635134fee24..7d564109df7454 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1763,8 +1763,11 @@ struct repository *repo UNUSED) } if (status_format != STATUS_FORMAT_PORCELAIN && - status_format != STATUS_FORMAT_PORCELAIN_V2) + status_format != STATUS_FORMAT_PORCELAIN_V2) { progress_flag = REFRESH_PROGRESS; + if (isatty(2)) + clean_status_enable_progress(the_repository); + } repo_read_index(the_repository); if (!s.pathspec.nr && use_optional_locks()) clean_status_capture_external_history_source( diff --git a/clean-status.c b/clean-status.c index 374738e81cbdb0..1597f76e64881e 100644 --- a/clean-status.c +++ b/clean-status.c @@ -3,18 +3,27 @@ #include "clean-status.h" #include "clean-status-internal.h" #include "fsmonitor-clean-proof.h" +#include "progress.h" #include "read-cache-ll.h" #include "repository.h" +#include "thread-utils.h" #include "trace2.h" static struct repository *configured_repo; static unsigned char configured_hash[GIT_MAX_RAWSZ]; static unsigned char configured_semantic_hash[GIT_MAX_RAWSZ]; static struct repository *external_history_repo; +static struct repository *progress_repo; static int configured_hash_valid; static int configured_filter_configured; static int configured_semantic_explicit; +struct clean_status_progress { + struct progress *display; + pthread_mutex_t mutex; + uint64_t completed; +}; + void clean_status_enable_external_history(struct repository *repo) { external_history_repo = repo; @@ -25,6 +34,45 @@ int clean_status_external_history_enabled(const struct index_state *istate) return istate && istate->repo == external_history_repo; } +void clean_status_enable_progress(struct repository *repo) +{ + progress_repo = repo; +} + +struct clean_status_progress *clean_status_start_progress( + struct repository *repo, const char *title, uint64_t total) +{ + struct clean_status_progress *progress; + + if (repo != progress_repo) + return NULL; + CALLOC_ARRAY(progress, 1); + if (pthread_mutex_init(&progress->mutex, NULL)) + BUG("could not initialize clean status progress mutex"); + progress->display = start_delayed_progress(repo, title, total); + return progress; +} + +void clean_status_update_progress(struct clean_status_progress *progress, + uint64_t completed) +{ + if (!progress || !completed) + return; + pthread_mutex_lock(&progress->mutex); + progress->completed += completed; + display_progress(progress->display, progress->completed); + pthread_mutex_unlock(&progress->mutex); +} + +void clean_status_stop_progress(struct clean_status_progress **progress) +{ + if (!progress || !*progress) + return; + stop_progress(&(*progress)->display); + pthread_mutex_destroy(&(*progress)->mutex); + FREE_AND_NULL(*progress); +} + struct clean_status_state *clean_status_get_state(struct index_state *istate) { if (!istate->clean_status) { diff --git a/clean-status.h b/clean-status.h index a50ef3af709840..0988e9ba318f32 100644 --- a/clean-status.h +++ b/clean-status.h @@ -5,6 +5,7 @@ struct index_state; struct attr_source_snapshot; +struct clean_status_progress; struct clean_status_proof_epoch; struct lock_file; struct repository; @@ -22,6 +23,12 @@ void clean_status_set_config_digest( const struct clean_status_config_digest *digest); void clean_status_enable_external_history(struct repository *repo); int clean_status_external_history_enabled(const struct index_state *istate); +void clean_status_enable_progress(struct repository *repo); +struct clean_status_progress *clean_status_start_progress( + struct repository *repo, const char *title, uint64_t total); +void clean_status_update_progress(struct clean_status_progress *progress, + uint64_t completed); +void clean_status_stop_progress(struct clean_status_progress **progress); void clean_status_attach_config(struct index_state *istate); int clean_status_filter_scope_needs_validation( const struct index_state *istate); diff --git a/semantic-verify-internal.h b/semantic-verify-internal.h index e784cf4d78f955..b4a896fc8195f5 100644 --- a/semantic-verify-internal.h +++ b/semantic-verify-internal.h @@ -31,6 +31,7 @@ struct attr_check; struct repository; struct clean_status_proof_epoch; +struct clean_status_progress; struct cache_entry; struct git_hash_algo; struct index_state; @@ -107,6 +108,7 @@ struct semantic_verify_worker { struct index_state *istate; struct semantic_verify_root *root; struct semantic_verify_result *results; + struct clean_status_progress *progress; struct attr_check *check; size_t start; size_t end; diff --git a/semantic-verify-worker.c b/semantic-verify-worker.c index 591da8aa70703d..d63461335472db 100644 --- a/semantic-verify-worker.c +++ b/semantic-verify-worker.c @@ -2,6 +2,7 @@ #include "git-compat-util.h" #include "attr.h" +#include "clean-status.h" #include "convert.h" #include "object.h" #include "read-cache-ll.h" @@ -9,6 +10,8 @@ #include "semantic-verify.h" #include "semantic-verify-internal.h" +#define SEMANTIC_VERIFY_PROGRESS_BATCH 128 + static void record_stat_update(struct semantic_verify_worker *worker, uint32_t cache_pos, const struct stat_data *stat_data) @@ -58,7 +61,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) semantic_verify_path_new(worker->root); struct attr_check *check = worker->check; void *buffer = xmalloc(SEMANTIC_VERIFY_HASH_BUFFER_SIZE); - size_t unstable_from = SIZE_MAX; + size_t unstable_from = SIZE_MAX, completed = 0; worker->check = NULL; if (!check) @@ -79,7 +82,7 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) worker->active_filters++; } count_result(worker, result->kind); - continue; + goto counted; } active_filter = file.active_filter; @@ -103,7 +106,13 @@ void semantic_verify_worker_run(struct semantic_verify_worker *worker) record_stat_update(worker, i, &file.stat_data); } count_result(worker, result->kind); + counted: + if (++completed == SEMANTIC_VERIFY_PROGRESS_BATCH) { + clean_status_update_progress(worker->progress, completed); + completed = 0; + } } + clean_status_update_progress(worker->progress, completed); semantic_verify_path_free(path, &worker->namespace_unstable, &unstable_from); diff --git a/semantic-verify.c b/semantic-verify.c index cdd179fdef4215..dc6af79c2ce796 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -5,6 +5,7 @@ #include "clean-status.h" #include "convert.h" #include "fsmonitor.h" +#include "gettext.h" #include "object.h" #include "read-cache-ll.h" #include "repository.h" @@ -80,6 +81,7 @@ int semantic_verify_prepare(struct index_state *istate, { struct semantic_verify_proof *proof; struct semantic_verify_worker *workers; + struct clean_status_progress *progress; unsigned int nr_threads; size_t updates_nr = 0; int create_threads = 1; @@ -179,6 +181,8 @@ int semantic_verify_prepare(struct index_state *istate, "threads", nr_threads); trace2_data_intmax("semantic_verify", istate->repo, "result-bytes", sizeof(struct semantic_verify_result)); + progress = clean_status_start_progress( + istate->repo, _("Verifying tracked files"), proof->cache_nr); for (unsigned int i = 0; i < nr_threads; i++) { struct semantic_verify_worker *worker = &workers[i]; @@ -187,6 +191,7 @@ int semantic_verify_prepare(struct index_state *istate, worker->istate = istate; worker->root = proof->root; worker->results = proof->results; + worker->progress = progress; worker->start = st_mult(proof->cache_nr, i) / nr_threads; worker->end = st_mult(proof->cache_nr, i + 1) / nr_threads; worker->validate_filter_scope = proof->filter_scope_checked; @@ -214,6 +219,7 @@ int semantic_verify_prepare(struct index_state *istate, die("could not join semantic verifier thread: %s", strerror(err)); } + clean_status_stop_progress(&progress); for (unsigned int i = 0; i < nr_threads; i++) updates_nr += workers[i].updates_nr; diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c index 1a0234360c67cb..116ff10b8e3fb7 100644 --- a/worktree-attr-manifest.c +++ b/worktree-attr-manifest.c @@ -1,7 +1,9 @@ #include "git-compat-util.h" #include "attr-manifest.h" +#include "clean-status.h" #include "dir.h" #include "environment.h" +#include "gettext.h" #include "hash-framing.h" #include "object.h" #include "odb.h" @@ -17,6 +19,7 @@ #define ATTR_MANIFEST_FILES_PER_THREAD 256 #define ATTR_MANIFEST_MAX_THREADS 32 +#define ATTR_MANIFEST_PROGRESS_BATCH 128 struct attr_manifest_candidate { unsigned char worktree_hash[GIT_MAX_RAWSZ]; @@ -30,6 +33,7 @@ struct attr_manifest_probe_data { struct string_list *candidates; struct semantic_verify_root *root; const struct git_hash_algo *algo; + struct clean_status_progress *progress; size_t start; size_t end; unsigned int namespace_unstable; @@ -130,7 +134,7 @@ static void *probe_attr_manifest_candidates(void *cb_data) struct attr_manifest_probe_data *data = cb_data; struct semantic_verify_path *path = semantic_verify_path_new(data->root); - size_t i; + size_t i, completed = 0; for (i = data->start; i < data->end; i++) { struct string_list_item *item = &data->candidates->items[i]; @@ -142,7 +146,12 @@ static void *probe_attr_manifest_candidates(void *cb_data) candidate->error = 1; else candidate->worktree_present = found; + if (++completed == ATTR_MANIFEST_PROGRESS_BATCH) { + clean_status_update_progress(data->progress, completed); + completed = 0; + } } + clean_status_update_progress(data->progress, completed); semantic_verify_path_free(path, &data->namespace_unstable, NULL); return NULL; } @@ -178,15 +187,19 @@ static int create_probe_thread(struct attr_manifest_thread *worker, } static int probe_candidates(struct string_list *candidates, + struct repository *repo, struct semantic_verify_root *root, const struct git_hash_algo *algo, struct worktree_attr_manifest_stats *stats) { struct attr_manifest_thread *workers; + struct clean_status_progress *progress; size_t thread_id, threads = select_thread_count(candidates->nr); int create_threads = HAVE_THREADS; int ret = 0; + progress = clean_status_start_progress( + repo, _("Refreshing worktree metadata"), candidates->nr); CALLOC_ARRAY(workers, threads); for (thread_id = 0; thread_id < threads; thread_id++) { struct attr_manifest_thread *worker = &workers[thread_id]; @@ -196,6 +209,7 @@ static int probe_candidates(struct string_list *candidates, data->candidates = candidates; data->root = root; data->algo = algo; + data->progress = progress; data->start = st_mult(candidates->nr, thread_id) / threads; data->end = st_mult(candidates->nr, thread_id + 1) / threads; if (threads == 1 || !create_threads) { @@ -219,6 +233,7 @@ static int probe_candidates(struct string_list *candidates, ret |= worker->probe.namespace_unstable; } stats->threads = threads; + clean_status_stop_progress(&progress); free(workers); return ret ? -1 : 0; } @@ -243,7 +258,7 @@ int worktree_attr_manifest_build( collect_index_sources(istate, &candidates)) goto done; stats->candidates = candidates.nr; - if (probe_candidates(&candidates, root, algo, stats)) + if (probe_candidates(&candidates, istate->repo, root, algo, stats)) goto done; attr_manifest_writer_init(&writer, manifest, algo); for (i = 0; i < candidates.nr; i++) { From 2d1c363c37f6ed710f7fe13d38c0e0b7f9c29680 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:06:36 -0500 Subject: [PATCH 237/403] preload-index: avoid bulk scans for sparse provider deltas --- preload-index.c | 25 ++++++++++++++++-------- t/t7530-status-clean-sidecar.sh | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/preload-index.c b/preload-index.c index 892f1204676829..1bea4a5d3f3b66 100644 --- a/preload-index.c +++ b/preload-index.c @@ -33,6 +33,7 @@ #define THREAD_COST (500) #define BULK_MAX_PARALLEL (32) #define BULK_ENTRIES_PER_THREAD (5000) +#define BULK_MIN_CANDIDATE_DIVISOR (8) struct progress_data { unsigned long n; @@ -454,17 +455,25 @@ int preload_index_bulk_can_close_provider(struct index_state *index) { #ifdef HAVE_PRELOAD_INDEX_BULK int core_preload_index = 1; + size_t useful; repo_config_get_bool(index->repo, "core.preloadindex", &core_preload_index); - return core_preload_index && - preload_bulk_config_enabled(index) && - preload_bulk_available() && - index->sparse_index == INDEX_EXPANDED && - fsm_settings__get_mode(index->repo) == FSMONITOR_MODE_IPC && - fsmonitor_pending_token_from_provider(index) && - (preload_bulk_useful_candidates(index, 1) || - index->preload_untracked); + if (!core_preload_index || !preload_bulk_config_enabled(index) || + !preload_bulk_available() || + index->sparse_index != INDEX_EXPANDED || + fsm_settings__get_mode(index->repo) != FSMONITOR_MODE_IPC || + !fsmonitor_pending_token_from_provider(index)) + return 0; + useful = preload_bulk_useful_candidates(index, 1); + if (!index->preload_untracked && + useful < DIV_ROUND_UP(index->cache_nr, + BULK_MIN_CANDIDATE_DIVISOR)) { + trace2_data_intmax("index", index->repo, + "preload/bulk_sparse_skip", useful); + return 0; + } + return useful || index->preload_untracked; #else (void)index; return 0; diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 911ea85a9b714e..2f66978038829c 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -295,6 +295,40 @@ test_expect_success DURABLE_FSMONITOR \ test_line_count = 1 external-nested-status.checkpoints ' +test_expect_success DURABLE_FSMONITOR \ + 'exact dirty status avoids a sparse full-worktree bulk scan' ' + test_when_finished "stop_daemon external-sparse-exact" && + setup_repo external-sparse-exact && + for i in $(test_seq 1 31) + do + test_write_lines "$i" >external-sparse-exact/clean-$i || + return 1 + done && + git -C external-sparse-exact add . && + git -C external-sparse-exact commit -m clean-files && + test-tool chmtime -120 external-sparse-exact/tracked \ + external-sparse-exact/clean-* && + git -C external-sparse-exact update-index --refresh && + git -C external-sparse-exact config core.untrackedCache true && + prime_semantic_history external-sparse-exact && + test_write_lines changed >external-sparse-exact/tracked && + test-tool chmtime -60 external-sparse-exact/tracked && + bulk_status -C external-sparse-exact status --porcelain=2 \ + >external-sparse-exact.primed && + bulk_status -C external-sparse-exact status --porcelain=v2 \ + -z --branch --show-stash --no-ahead-behind \ + --untracked-files=normal --ignore-submodules=all \ + >external-sparse-exact.daemon && + test_env GIT_TRACE2_EVENT="$PWD/external-sparse-exact.trace" \ + bulk_status -C external-sparse-exact status --porcelain=v2 \ + >actual && + test_grep "^1 \.M .* tracked$" actual && + test_trace2_data index preload/bulk_sparse_skip 1 \ + Date: Mon, 10 Aug 2026 23:24:17 -0500 Subject: [PATCH 238/403] fsmonitor: keep external history disabled on Windows --- clean-status-history-store.c | 4 ++++ t/unit-tests/u-clean-status-history-store.c | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 49b37166ce56fd..06e6a7219a92b9 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -475,6 +475,10 @@ int clean_status_history_store_install( int current_is_regular, encoded_matches = 0; int checkpoint_fd = -1, ret = -1; +#ifdef GIT_WINDOWS_NATIVE + /* Preserve the unsupported Windows path's original fail-closed behavior. */ + goto done; +#endif if (!snapshot || snapshot->fd < 0 || !clean_status_index_snapshot_still_matches_path( snapshot, index_path, algo)) diff --git a/t/unit-tests/u-clean-status-history-store.c b/t/unit-tests/u-clean-status-history-store.c index 73719517ecc3f8..c5f85a6be87ff5 100644 --- a/t/unit-tests/u-clean-status-history-store.c +++ b/t/unit-tests/u-clean-status-history-store.c @@ -128,6 +128,13 @@ static void require_local_apfs(const char *path MAYBE_UNUSED) #endif } +static void require_supported_history_store(void) +{ +#ifdef GIT_WINDOWS_NATIVE + cl_skip(); +#endif +} + void test_clean_status_history_store__rejects_incomplete_checkpoints(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; @@ -189,6 +196,7 @@ void test_clean_status_history_store__keeps_namespaces_independent(void) static const unsigned char second_fsmn[] = "second-fsmn"; static const unsigned char second_fscf[] = "second-fscf"; + require_supported_history_store(); fixture_init(&fixture, algo); memset(first.index_hash, 1, algo->rawsz); first.fsmonitor = first_fsmn; @@ -291,6 +299,7 @@ void test_clean_status_history_store__bounds_namespaces(void) struct utimbuf times; char namespace[32]; + require_supported_history_store(); fixture_init(&fixture, algo); checkpoint.fsmonitor = fsmn; checkpoint.fsmonitor_len = sizeof(fsmn) - 1; From 5ea24eb7099f1ec1d698d5dd252ce20c88c9e9ae Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:24:24 -0500 Subject: [PATCH 239/403] fsmonitor: honor explicitly invalidated external history --- clean-status-history.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/clean-status-history.c b/clean-status-history.c index ba4c822db01f2a..d2f487d46296e9 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -592,6 +592,19 @@ int clean_status_restore_external_history(struct index_state *istate) on_index_history_is_coherent(istate) || clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate)) goto done; + /* + * An unbound proof for the current configuration records deliberate + * invalidation. A legacy writer removes FSCF entirely, while a proof + * from another configuration must not hide this namespace's checkpoint. + */ + if (state->disk_config_valid && + !memcmp(state->disk_config_hash, state->current_config_hash, + istate->repo->hash_algo->rawsz) && + !clean_status_has_persistent_fsmonitor_semantic_history(istate)) { + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-proof-invalidated", 1); + goto done; + } if (external_history_namespace(istate, proof_namespace)) goto done; if (!clean_status_history_store_load( From dc6b2880a6afea3274938d7945fb50b4c3be88a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:09:31 -0500 Subject: [PATCH 240/403] status: preserve semantic history for scoped queries A pathspec prevents status from closing its fsmonitor token, so every scoped invocation invalidates the attribute manifest and rewrites the index. Repeated commands like "git status -- api" consequently rescan the entire worktree metadata. Allow scoped status to close and checkpoint global semantic history. Validate the complete untracked cache when establishing that proof, but discard its unfiltered results and collect the requested pathspec separately. Keep clean-worktree sidecars restricted to root-wide queries so dirt outside the selected paths cannot be hidden. --- builtin/commit.c | 10 ++++----- t/t7530-status-clean-sidecar.sh | 40 +++++++++++++++++++++++++++++++++ wt-status.c | 20 +++++++++++++---- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 7d564109df7454..a700085df5e4d1 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1750,8 +1750,7 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; - if (!s.pathspec.nr) - clean_status_enable_external_history(the_repository); + clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; if ((exact_clean_query || normal_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { @@ -1769,7 +1768,7 @@ struct repository *repo UNUSED) clean_status_enable_progress(the_repository); } repo_read_index(the_repository); - if (!s.pathspec.nr && use_optional_locks()) + if (use_optional_locks()) clean_status_capture_external_history_source( the_repository->index); if (normal_clean_query && use_optional_locks() && @@ -1827,9 +1826,8 @@ struct repository *repo UNUSED) * entry repair durable. Restored checkpoints stay no-spill * for foreign index writers. */ - if (!s.pathspec.nr) - external_saved = clean_status_save_external_history( - the_repository->index); + external_saved = clean_status_save_external_history( + the_repository->index); if (exact_clean_query) { if (!preserve_entry_changes && external_saved && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 2f66978038829c..b7ab718e009cc6 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -295,6 +295,46 @@ test_expect_success DURABLE_FSMONITOR \ test_line_count = 1 external-nested-status.checkpoints ' +test_expect_success DURABLE_FSMONITOR \ + 'pathspec status preserves history without certifying outside paths' ' + test_when_finished "stop_daemon external-pathspec-status" && + setup_repo external-pathspec-status && + mkdir external-pathspec-status/scoped && + test_commit -C external-pathspec-status scoped scoped/tracked && + test-tool -C external-pathspec-status chmtime -120 \ + tracked scoped/tracked && + git -C external-pathspec-status update-index --refresh && + git -C external-pathspec-status config core.untrackedCache true && + prime_semantic_history external-pathspec-status && + git -C external-pathspec-status config core.autocrlf false && + test_write_lines changed >external-pathspec-status/tracked && + test_write_lines selected >external-pathspec-status/scoped/new && + test_write_lines outside >external-pathspec-status/outside-new && + bulk_status -C external-pathspec-status \ + status --porcelain=v2 -- scoped >external-pathspec-status.first && + test_grep "^? scoped/new$" external-pathspec-status.first && + ! test_grep "tracked\|outside-new" external-pathspec-status.first && + test_path_is_missing external-pathspec-status/.git/index.csts && + cp external-pathspec-status/.git/index \ + external-pathspec-status.before && + test_env GIT_TRACE2_EVENT="$PWD/external-pathspec-status.trace" \ + bulk_status -C external-pathspec-status \ + status --porcelain=v2 -- scoped \ + >external-pathspec-status.second && + test_cmp external-pathspec-status.first \ + external-pathspec-status.second && + test_cmp external-pathspec-status.before \ + external-pathspec-status/.git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + external-pathspec-status.root && + test_grep "^1 \.M .* tracked$" external-pathspec-status.root +' + test_expect_success DURABLE_FSMONITOR \ 'exact dirty status avoids a sparse full-worktree bulk scan' ' test_when_finished "stop_daemon external-sparse-exact" && diff --git a/wt-status.c b/wt-status.c index e5d2e958206058..888377dbe36145 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1229,7 +1229,7 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( int ret; if (!fstat_is_reliable() || istate->split_index || - s->show_ignored_mode || s->pathspec.nr || + s->show_ignored_mode || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || istate->sparse_index != INDEX_EXPANDED || !fsmonitor_has_pending_token(istate) || @@ -1296,12 +1296,24 @@ static void wt_status_discard_staged_untracked( static int wt_status_stage_untracked( struct wt_status_token_closure *closure) { + struct wt_status *s = closure->status; + struct pathspec pathspec = s->pathspec; + wt_status_discard_staged_untracked(closure); + /* A provider token can certify only a complete untracked traversal. */ + if (pathspec.nr) + memset(&s->pathspec, 0, sizeof(s->pathspec)); closure->staged_untracked_ready = wt_status_collect_untracked_1( - closure->status, + s, &closure->staged_untracked, &closure->staged_ignored); + if (pathspec.nr) { + s->pathspec = pathspec; + /* The ordinary scoped traversal supplies the displayed results. */ + string_list_clear(&closure->staged_untracked, 0); + string_list_clear(&closure->staged_ignored, 0); + } if (!closure->staged_untracked_ready) wt_status_discard_staged_untracked(closure); return closure->staged_untracked_ready; @@ -1312,7 +1324,7 @@ static void wt_status_publish_staged_untracked( { struct wt_status *s = closure->status; - if (!closure->staged_untracked_ready) + if (!closure->staged_untracked_ready || s->pathspec.nr) return; if (s->untracked.nr || s->ignored.nr) BUG("publishing untracked results over collected status"); @@ -1663,7 +1675,7 @@ static int wt_status_close_fsmonitor_token( enum wt_status_token_closure_result result; refresh_fsmonitor(istate); - if (!fsmonitor_has_pending_token(istate) || s->pathspec.nr || + if (!fsmonitor_has_pending_token(istate) || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) { int attr_inputs_match = wt_status_attr_snapshot_matches(s) && From 8355433475bd072eb638ebd492f27c632e199153 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:48:56 -0500 Subject: [PATCH 241/403] read-cache: factor refreshed entry construction Once refresh_cache_ent() verifies that an entry's content still matches, it allocates and copies a replacement, fills its stat data, and preserves a caller-cleared CE_VALID bit under assume_unchanged. Keeping that sequence in one caller would require another verified refresh path to duplicate the allocation and validity handling. Extract make_refreshed_cache_entry() as a private helper and keep refresh_cache_ent() as its first consumer. Pass !ignore_valid through the existing condition so the entry name, observed stat data, and CE_VALID behavior remain unchanged. This is a behavior-preserving refactor. It introduces no new index write, configuration, test claim, or independent performance claim. Signed-off-by: Taylor Blau --- read-cache.c | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/read-cache.c b/read-cache.c index f606bf81ddaca2..088f83559a6b56 100644 --- a/read-cache.c +++ b/read-cache.c @@ -205,6 +205,23 @@ void fill_stat_cache_info(struct index_state *istate, struct cache_entry *ce, st } } +static struct cache_entry *make_refreshed_cache_entry( + struct index_state *istate, const struct cache_entry *ce, + struct stat *st, int preserve_valid) +{ + struct cache_entry *updated = + make_empty_cache_entry(istate, ce_namelen(ce)); + + copy_cache_entry(updated, ce); + memcpy(updated->name, ce->name, ce->ce_namelen + 1); + fill_stat_cache_info(istate, updated, st); + /* Do not let assume-unchanged reacquire a caller-cleared CE_VALID. */ + if (preserve_valid && assume_unchanged && + !(ce->ce_flags & CE_VALID)) + updated->ce_flags &= ~CE_VALID; + return updated; +} + static unsigned int st_mode_from_ce(const struct cache_entry *ce) { switch (ce->ce_mode & S_IFMT) { @@ -1458,19 +1475,7 @@ static struct cache_entry *refresh_cache_ent(struct index_state *istate, return NULL; } - updated = make_empty_cache_entry(istate, ce_namelen(ce)); - copy_cache_entry(updated, ce); - memcpy(updated->name, ce->name, ce->ce_namelen + 1); - fill_stat_cache_info(istate, updated, &st); - /* - * If ignore_valid is not set, we should leave CE_VALID bit - * alone. Otherwise, paths marked with --no-assume-unchanged - * (i.e. things to be edited) will reacquire CE_VALID bit - * automatically, which is not really what we want. - */ - if (!ignore_valid && assume_unchanged && - !(ce->ce_flags & CE_VALID)) - updated->ce_flags &= ~CE_VALID; + updated = make_refreshed_cache_entry(istate, ce, &st, !ignore_valid); /* istate->cache_changed is updated in the caller */ return updated; From df5ea02b42c9bd5a3ffff9c8ac27fb3eb0caa701 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:57 -0500 Subject: [PATCH 242/403] status: reuse closed proofs for scoped queries A root-wide clean proof also proves that a literal scoped status is clean, but scoped status must not create a new root-wide proof. Reuse an existing clean sidecar and render the current branch and HEAD state. Dirty scoped status bypasses the untracked cache because directory traversal disables it for non-empty pathspecs. Reuse the selected cached subtree after checking its builtin fsmonitor token, directory flags, exclusion identities, and expanded index. An fsmonitor event below a directory containing tracked entries cannot make that directory an untracked collapsed parent. Keep its cached ancestors valid and mark their recursive proofs stale. For an ordinary file event, retain the authenticated directory contents and reconcile only that path against its ignore rules instead of reopening the entire directory. Recompute proofs only along the affected ancestor path. Accept a legacy exclude identity containing the parser's synthetic newline when the actual file still matches the indexed blob. Otherwise the next root status invalidates and reopens its entire cached tree. Preserve ordinary invalidation for index additions and removals. Fall back to ordinary traversal for directories, changed exclusions, complex pathspecs, sparse indexes, and unsupported directories. --- builtin/commit.c | 12 +- dir.c | 333 ++++++++++++++++++++++++++++++-- dir.h | 7 + t/t7530-status-clean-sidecar.sh | 101 +++++++++- wt-status.c | 175 ++++++++++++++++- 5 files changed, 598 insertions(+), 30 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index a700085df5e4d1..0c38b9c1d32fbb 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1651,6 +1651,7 @@ struct repository *repo UNUSED) !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); int exact_clean_query; int normal_clean_query; + int scoped_clean_query; int normal_has_head; struct object_id oid; static struct option builtin_status_options[] = { @@ -1750,11 +1751,18 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; + scoped_clean_query = s.pathspec.nr && + status_format == STATUS_FORMAT_NONE && + !s.show_branch && !s.show_stash && !s.show_ignored_mode && + !s.null_termination && !s.verbose && !s.submodule_summary && + s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && + !repo_config_values(the_repository)->apply_sparse_checkout && + !repo_get_oid(the_repository, s.reference, &oid); clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; - if ((exact_clean_query || normal_clean_query) && + if ((exact_clean_query || normal_clean_query || scoped_clean_query) && clean_status_try_sidecar(the_repository, &clean_digest)) { - if (!normal_clean_query || + if (exact_clean_query || print_normal_clean_sidecar(&s, prefix)) { wt_status_collect_free_buffers(&s); return 0; diff --git a/dir.c b/dir.c index 4645f4a42a911c..e2069134249857 100644 --- a/dir.c +++ b/dir.c @@ -211,7 +211,8 @@ static int exclude_path_matches_fd(const char *path, static int cached_exclude_file_matches( const struct git_hash_algo *algo, const char *path, const struct object_id *cached_oid, - struct object_id *raw_oid_out, unsigned int *mode_out) + struct object_id *raw_oid_out, struct object_id *normalized_oid_out, + unsigned int *mode_out) { struct object_id raw_oid, normalized_oid; struct stat st, st_after; @@ -242,14 +243,17 @@ static int cached_exclude_file_matches( hash_object_file(algo, buf, size, OBJ_BLOB, &raw_oid); if (raw_oid_out) oidcpy(raw_oid_out, &raw_oid); - if (oideq(&raw_oid, cached_oid)) { + if (oideq(&raw_oid, cached_oid) && !normalized_oid_out) { ret = 1; goto out; } buf[size] = '\n'; hash_object_file(algo, buf, size + 1, OBJ_BLOB, &normalized_oid); - ret = oideq(&normalized_oid, cached_oid); + if (normalized_oid_out) + oidcpy(normalized_oid_out, &normalized_oid); + ret = oideq(&raw_oid, cached_oid) || + oideq(&normalized_oid, cached_oid); out: free(buf); out_close: @@ -481,11 +485,11 @@ static void *preload_untracked_cache_thread(void *_data) strbuf_release(&exclude_path); continue; } - task->exclude_matches = cached_exclude_file_matches( + task->exclude_matches = cached_exclude_file_matches( preload->repo->hash_algo, exclude_path.buf, &task->exclude_oid, &raw_oid, - &task->exclude_mode); + NULL, &task->exclude_mode); if (task->exclude_matches && task->exclude_index_present && oideq(&preload->exclude_index_oids[i], @@ -527,7 +531,7 @@ static void *preload_untracked_cache_thread(void *_data) strbuf_addstr(&exclude_path, preload->exclude_per_dir); task->exclude_matches = cached_exclude_file_matches( preload->repo->hash_algo, exclude_path.buf, - &task->exclude_oid, NULL, NULL); + &task->exclude_oid, NULL, NULL, NULL); strbuf_release(&exclude_path); } return NULL; @@ -598,13 +602,18 @@ static int compute_untracked_cache_fsmonitor_valid_recursive( struct untracked_cache_dir *ucd) { size_t i; - int valid = ucd->valid; + int valid = ucd->valid && !ucd->fsmonitor_dirty; + int has_untracked = !!ucd->untracked_nr; - for (i = 0; i < ucd->dirs_nr; i++) + for (i = 0; i < ucd->dirs_nr; i++) { if (!compute_untracked_cache_fsmonitor_valid_recursive( ucd->dirs[i])) valid = 0; + if (ucd->dirs[i]->recurse && ucd->dirs[i]->has_untracked) + has_untracked = 1; + } ucd->valid_recursive = valid; + ucd->has_untracked = has_untracked; return valid; } @@ -1967,6 +1976,7 @@ static void do_invalidate_gitignore(struct untracked_cache_dir *dir) int i; dir->valid = 0; dir->valid_recursive = 0; + dir->fsmonitor_dirty = 0; dir->has_untracked = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); @@ -2007,6 +2017,7 @@ static void invalidate_directory(struct untracked_cache *uc, dir->valid = 0; dir->valid_recursive = 0; + dir->fsmonitor_dirty = 0; for (size_t i = 0; i < dir->untracked_nr; i++) free(dir->untracked[i]); dir->untracked_nr = 0; @@ -2729,7 +2740,19 @@ static void prep_exclude(struct dir_struct *dir, */ if (untracked && !oideq(&oid_stat.oid, &untracked->exclude_oid)) { - invalidate_gitignore(dir->untracked, untracked); + struct object_id raw_oid, normalized_oid; + int compatible; + + /* Older caches include the parser's synthetic final LF. */ + compatible = oid_stat.valid && + cached_exclude_file_matches(the_hash_algo, pl->src, + &untracked->exclude_oid, + &raw_oid, &normalized_oid, + NULL) && + (oideq(&raw_oid, &oid_stat.oid) || + oideq(&normalized_oid, &oid_stat.oid)); + if (!compatible) + invalidate_gitignore(dir->untracked, untracked); oidcpy(&untracked->exclude_oid, &oid_stat.oid); } dir->internal.exclude_stack = stk; @@ -3474,6 +3497,88 @@ static void add_untracked(struct untracked_cache_dir *dir, const char *name) dir->has_untracked = 1; } +static int refresh_cached_fsmonitor_files( + struct dir_struct *dir, + struct index_state *istate, + struct untracked_cache_dir *untracked, + struct strbuf *directory) +{ + struct untracked_cache *uc = dir->untracked; + struct strbuf path = STRBUF_INIT; + const char *event, *end; + size_t base_len, refreshed = 0; + int valid; + + if (!uc || !untracked->valid || !untracked->fsmonitor_dirty || + !uc->fsmonitor_dirty_paths.len) + return 0; + + strbuf_addbuf(&path, directory); + strbuf_complete(&path, '/'); + base_len = path.len; + event = uc->fsmonitor_dirty_paths.buf; + end = event + uc->fsmonitor_dirty_paths.len; + while (event < end) { + struct cached_dir cdir = { 0 }; + enum path_treatment state; + const char *name; + size_t i; + + if (strncmp(event, path.buf, base_len)) + goto next; + name = event + base_len; + if (!*name || strchr(name, '/')) + goto next; + + for (i = 0; i < untracked->untracked_nr; i++) { + if (strcmp(untracked->untracked[i], name)) + continue; + free(untracked->untracked[i]); + MOVE_ARRAY(untracked->untracked + i, + untracked->untracked + i + 1, + untracked->untracked_nr - i - 1); + untracked->untracked_nr--; + break; + } + + cdir.d_name = name; + cdir.d_type = DT_UNKNOWN; + state = treat_path(dir, untracked, &cdir, istate, &path, + base_len, NULL); + dir->internal.visited_paths++; + if (state == path_recurse) { + strbuf_release(&path); + return 0; + } + if (state == path_untracked) + add_untracked(untracked, name); + refreshed++; + +next: + event += strlen(event) + 1; + strbuf_setlen(&path, base_len); + } + strbuf_release(&path); + if (!refreshed || !untracked->valid) + return 0; + + untracked->fsmonitor_dirty = 0; + untracked->has_untracked = !!untracked->untracked_nr; + valid = untracked->valid; + for (size_t i = 0; i < untracked->dirs_nr; i++) { + struct untracked_cache_dir *child = untracked->dirs[i]; + + if (!child->valid_recursive) + valid = 0; + if (child->recurse && child->has_untracked) + untracked->has_untracked = 1; + } + untracked->valid_recursive = valid; + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/targeted-refresh", refreshed); + return 1; +} + static int valid_cached_dir(struct dir_struct *dir, struct untracked_cache_dir *untracked, struct index_state *istate, @@ -3529,7 +3634,12 @@ static int valid_cached_dir(struct dir_struct *dir, prep_exclude(dir, istate, path->buf, path->len); /* hopefully prep_exclude() haven't invalidated this entry... */ - return untracked->valid; + if (!untracked->valid) + return 0; + if (untracked->fsmonitor_dirty && + !refresh_cached_fsmonitor_files(dir, istate, untracked, path)) + return 0; + return 1; } static int open_cached_dir(struct cached_dir *cdir, @@ -4214,6 +4324,96 @@ int read_directory(struct dir_struct *dir, struct index_state *istate, return dir->nr; } +static void recompute_cached_fsmonitor_ancestors( + struct untracked_cache *uc, + const char *path, + int len) +{ + struct untracked_cache_dir **parents = NULL; + struct untracked_cache_dir *current = uc->root; + size_t nr = 0, alloc = 0; + int offset = 0; + + ALLOC_GROW(parents, nr + 1, alloc); + parents[nr++] = current; + while (offset < len) { + const char *slash; + int component_len; + + while (offset < len && path[offset] == '/') + offset++; + if (offset == len) + break; + slash = memchr(path + offset, '/', len - offset); + component_len = slash ? slash - (path + offset) : len - offset; + current = lookup_untracked(uc, current, + path + offset, component_len); + ALLOC_GROW(parents, nr + 1, alloc); + parents[nr++] = current; + offset += component_len; + } + + while (nr) { + struct untracked_cache_dir *parent = parents[--nr]; + int valid = parent->valid && !parent->fsmonitor_dirty; + int has_untracked = !!parent->untracked_nr; + + for (size_t i = 0; i < parent->dirs_nr; i++) { + struct untracked_cache_dir *child = parent->dirs[i]; + + if (!child->valid_recursive) + valid = 0; + if (child->recurse && child->has_untracked) + has_untracked = 1; + } + parent->valid_recursive = valid; + parent->has_untracked = has_untracked; + } + free(parents); +} + +int read_directory_cached_subtree(struct dir_struct *dir, + struct index_state *istate, + struct untracked_cache_dir *untracked, + const char *path, int len, + const struct pathspec *pathspec) +{ + int repaired = 0; + + if (!untracked || !dir->untracked || + dir->untracked != istate->untracked || + !dir->untracked->use_fsmonitor || + !istate->fsmonitor_untracked_valid || + has_symlink_leading_path(path, len)) + return -1; + + trace2_region_enter("dir", "read_cached_subtree", istate->repo); + dir->internal.visited_paths = 0; + dir->internal.visited_directories = 0; + if (treat_leading_path(dir, istate, path, len, pathspec)) { + if (untracked->valid && untracked->fsmonitor_dirty) { + struct strbuf directory = STRBUF_INIT; + + strbuf_add(&directory, path, len); + repaired = valid_cached_dir( + dir, untracked, istate, &directory, 0) && + untracked->valid_recursive; + strbuf_release(&directory); + } + if (!repaired) { + read_directory_recursive(dir, istate, path, len, + untracked, 0, 0, pathspec); + compute_untracked_cache_fsmonitor_valid_recursive(untracked); + } + } + QSORT(dir->entries, dir->nr, cmp_dir_entry); + QSORT(dir->ignored, dir->ignored_nr, cmp_dir_entry); + recompute_cached_fsmonitor_ancestors(dir->untracked, path, len); + emit_traversal_statistics(dir, istate->repo, path, len); + trace2_region_leave("dir", "read_cached_subtree", istate->repo); + return dir->internal.traversal_failed ? -1 : dir->nr; +} + int file_exists(const char *f) { struct stat sb; @@ -4781,6 +4981,7 @@ void free_untracked_cache(struct untracked_cache *uc) free(uc->exclude_per_dir_to_free); strbuf_release(&uc->ident); + strbuf_release(&uc->fsmonitor_dirty_paths); free_untracked(uc->root); free(uc); } @@ -5011,11 +5212,90 @@ static void invalidate_one_directory(struct untracked_cache *uc, uc->dir_invalidated++; ucd->valid = 0; ucd->valid_recursive = 0; + ucd->fsmonitor_dirty = 0; for (size_t i = 0; i < ucd->untracked_nr; i++) free(ucd->untracked[i]); ucd->untracked_nr = 0; } +static int directory_has_indexed_children( + struct index_state *istate, + const char *path, + size_t len) +{ + int pos = index_name_pos(istate, path, len); + + if (pos >= 0) + return 0; + pos = -pos - 1; + return pos < istate->cache_nr && + ce_namelen(istate->cache[pos]) > len && + istate->cache[pos]->name[len] == '/' && + !strncmp(istate->cache[pos]->name, path, len); +} + +static int record_cached_fsmonitor_file( + struct untracked_cache *uc, + struct untracked_cache_dir *dir, + struct index_state *istate, + const char *full_path, + const char *name) +{ + struct stat st; + const char *event, *end; + size_t parent_len, path_len = strlen(full_path); + int first, last; + + if (!istate->fsmonitor_untracked_valid || + istate->fsmonitor_legacy_untracked_fallback || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + !dir->valid || !dir->recurse || + dir->check_only || name == full_path || + (uc->exclude_per_dir && !strcmp(name, uc->exclude_per_dir))) + return 0; + parent_len = name - full_path - 1; + if (!directory_has_indexed_children(istate, full_path, parent_len) || + directory_has_indexed_children(istate, full_path, path_len)) + return 0; + if (lstat(full_path, &st)) { + if (!is_missing_file_error(errno)) + return 0; + } else if (!S_ISREG(st.st_mode) && !S_ISLNK(st.st_mode)) { + return 0; + } + + first = 0; + last = dir->dirs_nr; + while (last > first) { + int next = first + ((last - first) >> 1); + int compare = strcmp(name, dir->dirs[next]->name); + + if (!compare) + return 0; + if (compare < 0) + last = next; + else + first = next + 1; + } + + if (uc->fsmonitor_dirty_paths.len) { + event = uc->fsmonitor_dirty_paths.buf; + end = event + uc->fsmonitor_dirty_paths.len; + while (event < end) { + if (!strcmp(event, full_path)) + goto recorded; + event += strlen(event) + 1; + } + } + strbuf_addstr(&uc->fsmonitor_dirty_paths, full_path); + strbuf_addch(&uc->fsmonitor_dirty_paths, '\0'); + +recorded: + dir->fsmonitor_dirty = 1; + dir->valid_recursive = 0; + return 1; +} + /* * Normally when an entry is added or removed from a directory, * invalidating that directory is enough. No need to touch its @@ -5042,7 +5322,10 @@ static void invalidate_one_directory(struct untracked_cache *uc, */ static int invalidate_one_component(struct untracked_cache *uc, struct untracked_cache_dir *dir, - const char *path, int len) + struct index_state *istate, + const char *path, int len, + const char *full_path, + int allow_tracked_stop) { const char *rest = strchr(path, '/'); @@ -5051,14 +5334,30 @@ static int invalidate_one_component(struct untracked_cache *uc, struct untracked_cache_dir *d = lookup_untracked(uc, dir, path, component_len); int ret = - invalidate_one_component(uc, d, rest + 1, - len - (component_len + 1)); - if (ret) - invalidate_one_directory(uc, dir); + invalidate_one_component(uc, d, istate, rest + 1, + len - (component_len + 1), + full_path, allow_tracked_stop); + if (ret) { + size_t directory_len = rest - full_path; + if (allow_tracked_stop && uc->use_fsmonitor && + directory_has_indexed_children( + istate, full_path, directory_len)) { + dir->valid_recursive = 0; + ret = 0; + } else { + invalidate_one_directory(uc, dir); + } + } + if (!d->valid_recursive) + dir->valid_recursive = 0; return ret; } - invalidate_one_directory(uc, dir); + if (!allow_tracked_stop || + !record_cached_fsmonitor_file(uc, dir, istate, full_path, path)) + invalidate_one_directory(uc, dir); + else + return 0; return uc->dir_flags & DIR_SHOW_OTHER_DIRECTORIES; } @@ -5070,7 +5369,7 @@ void untracked_cache_invalidate_path(struct index_state *istate, if (!safe_path && !verify_path(path, 0)) return; invalidate_one_component(istate->untracked, istate->untracked->root, - path, strlen(path)); + istate, path, strlen(path), path, !safe_path); } void untracked_cache_invalidate_trimmed_path(struct index_state *istate, diff --git a/dir.h b/dir.h index de1782a3f254f6..d674df9a493a18 100644 --- a/dir.h +++ b/dir.h @@ -190,6 +190,7 @@ struct untracked_cache_dir { unsigned int stat_matches : 1; unsigned int exclude_matches : 1; unsigned int valid_recursive : 1; + unsigned int fsmonitor_dirty : 1; /* * A null object ID means this directory does not have .gitignore. * The empty-tree ID records a present source that could not be read. @@ -215,6 +216,7 @@ struct untracked_cache { int gitignore_invalidated; int dir_invalidated; int dir_opened; + struct strbuf fsmonitor_dirty_paths; /* fsmonitor invalidation data */ unsigned int use_fsmonitor : 1; }; @@ -420,6 +422,11 @@ int fill_directory(struct dir_struct *dir, int read_directory(struct dir_struct *, struct index_state *istate, const char *path, int len, const struct pathspec *pathspec); +int read_directory_cached_subtree(struct dir_struct *, + struct index_state *istate, + struct untracked_cache_dir *untracked, + const char *path, int len, + const struct pathspec *pathspec); enum pattern_match_result { UNDECIDED = -1, diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index b7ab718e009cc6..9a59a18ec4f3e6 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -313,7 +313,7 @@ test_expect_success DURABLE_FSMONITOR \ bulk_status -C external-pathspec-status \ status --porcelain=v2 -- scoped >external-pathspec-status.first && test_grep "^? scoped/new$" external-pathspec-status.first && - ! test_grep "tracked\|outside-new" external-pathspec-status.first && + test_grep ! "tracked\|outside-new" external-pathspec-status.first && test_path_is_missing external-pathspec-status/.git/index.csts && cp external-pathspec-status/.git/index \ external-pathspec-status.before && @@ -335,6 +335,105 @@ test_expect_success DURABLE_FSMONITOR \ test_grep "^1 \.M .* tracked$" external-pathspec-status.root ' +test_expect_success DURABLE_FSMONITOR \ + 'clean pathspec status reuses an existing root-wide clean proof' ' + test_when_finished "stop_daemon clean-pathspec-status" && + setup_repo clean-pathspec-status && + mkdir clean-pathspec-status/scoped && + test_commit -C clean-pathspec-status scoped scoped/tracked && + test-tool -C clean-pathspec-status chmtime -120 \ + tracked scoped/tracked && + git -C clean-pathspec-status update-index --refresh && + git -C clean-pathspec-status config core.untrackedCache true && + issue_sidecar clean-pathspec-status && + cp clean-pathspec-status/.git/index clean-pathspec-status.before && + git -C clean-pathspec-status status >clean-pathspec-status.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/clean-pathspec-status.trace" \ + git -C clean-pathspec-status status -- scoped \ + >clean-pathspec-status.actual && + test_cmp clean-pathspec-status.expect clean-pathspec-status.actual && + test_cmp clean-pathspec-status.before clean-pathspec-status/.git/index && + test_trace2_data status clean-proof/hit 1 \ + clean-pathspec-nested.actual && + test_cmp clean-pathspec-status.expect clean-pathspec-nested.actual && + test_trace2_data status clean-proof/hit 1 \ + clean-pathspec-status/tracked && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/clean-pathspec-outside.trace" \ + git -C clean-pathspec-status status -- scoped \ + >clean-pathspec-outside.actual && + test_grep "nothing to commit, working tree clean" \ + clean-pathspec-outside.actual && + test_grep ! "\"key\":\"clean-proof/hit\"" \ + clean-pathspec-outside.trace && + git -C clean-pathspec-status status --porcelain=v2 \ + >clean-pathspec-outside.root && + test_grep "^1 \.M .* tracked$" clean-pathspec-outside.root && + + test_write_lines selected >clean-pathspec-status/scoped/new && + mkdir clean-pathspec-status/scoped/newdir && + test_write_lines nested >clean-pathspec-status/scoped/newdir/file && + test_write_lines outside >clean-pathspec-status/outside-new && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/clean-pathspec-selected.trace" \ + git -C clean-pathspec-status status -- scoped \ + >clean-pathspec-selected.actual && + test_grep "scoped/new" clean-pathspec-selected.actual && + test_grep "scoped/newdir/" clean-pathspec-selected.actual && + test_grep ! "outside-new" clean-pathspec-selected.actual && + test_grep ! "\"key\":\"clean-proof/hit\"" \ + clean-pathspec-selected.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'tracked-directory pathspec reuses a valid untracked-cache subtree' ' + test_when_finished "stop_daemon cached-pathspec-status" && + setup_repo cached-pathspec-status && + mkdir cached-pathspec-status/scoped && + test_commit -C cached-pathspec-status scoped scoped/tracked && + test-tool -C cached-pathspec-status chmtime -120 \ + tracked scoped/tracked && + git -C cached-pathspec-status update-index --refresh && + git -C cached-pathspec-status config core.untrackedCache true && + test_write_lines selected >cached-pathspec-status/scoped/new && + test_write_lines outside >cached-pathspec-status/outside-new && + git -C cached-pathspec-status status >cached-pathspec-status.root && + git -C cached-pathspec-status status >/dev/null && + cp cached-pathspec-status/.git/index cached-pathspec-status.before && + GIT_TRACE2_EVENT="$PWD/cached-pathspec-status.trace" \ + git -C cached-pathspec-status status -- scoped \ + >cached-pathspec-status.actual && + test_grep "scoped/new" cached-pathspec-status.actual && + test_grep ! "outside-new" cached-pathspec-status.actual && + test_cmp cached-pathspec-status.before \ + cached-pathspec-status/.git/index && + test_trace2_data status untracked/pathspec-cache 1 \ + cached-pathspec-nested.actual && + test_grep "new" cached-pathspec-nested.actual && + test_grep ! "outside-new" cached-pathspec-nested.actual && + test_trace2_data status untracked/pathspec-cache 1 \ + dirs_nr; i++) { + struct untracked_cache_dir *candidate = dir->dirs[i]; + + if (strlen(candidate->name) == component_len && + !strncmp(candidate->name, path, component_len)) { + child = candidate; + break; + } + } + if (!child || !child->recurse || child->check_only) + return NULL; + dir = child; + path += component_len; + if (path < end) + path++; + } + return dir; +} + +static void wt_status_collect_cached_directory( + const struct untracked_cache_dir *dir, + struct strbuf *path, + struct index_state *istate, + const struct pathspec *pathspec, + struct string_list *untracked) +{ + size_t base_len = path->len; + + if (!dir->has_untracked) + return; + for (size_t i = 0; i < dir->untracked_nr; i++) { + const char *name = dir->untracked[i]; + + strbuf_setlen(path, base_len); + strbuf_addstr(path, name); + if (index_name_is_other(istate, path->buf, path->len) && + match_pathspec(istate, pathspec, + path->buf, path->len, 0, NULL, + path->len && path->buf[path->len - 1] == '/')) + string_list_append(untracked, path->buf); + } + for (size_t i = 0; i < dir->dirs_nr; i++) { + const struct untracked_cache_dir *child = dir->dirs[i]; + + if (!child->recurse || child->check_only || + !child->has_untracked) + continue; + strbuf_setlen(path, base_len); + strbuf_addstr(path, child->name); + strbuf_addch(path, '/'); + wt_status_collect_cached_directory( + child, path, istate, pathspec, untracked); + } + strbuf_setlen(path, base_len); +} + +static int wt_status_collect_cached_pathspec( + struct wt_status *s, + struct dir_struct *dir, + struct string_list *untracked) +{ + struct index_state *istate = s->repo->index; + struct untracked_cache *uc = istate->untracked; + const struct pathspec_item *item; + const struct cache_entry *ce; + struct untracked_cache_dir *selected; + struct strbuf path = STRBUF_INIT; + size_t len; + int pos; + + if (s->pathspec.nr != 1 || s->pathspec.has_wildcard || + (s->pathspec.magic & ~(PATHSPEC_FROMTOP | PATHSPEC_LITERAL)) || + s->show_ignored_mode || + s->show_untracked_files != SHOW_NORMAL_UNTRACKED_FILES || + istate->sparse_index != INDEX_EXPANDED || + !istate->fsmonitor_untracked_valid || + fsmonitor_has_pending_token(istate) || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || + !uc || !uc->root || !uc->use_fsmonitor || + dir->untracked != uc || dir->flags != uc->dir_flags || + dir->internal.unmanaged_exclude_files || + dir->internal.exclude_list_group[EXC_CMDL].nr || + !oideq(&dir->internal.ss_info_exclude.oid, + &uc->ss_info_exclude.oid) || + !oideq(&dir->internal.ss_excludes_file.oid, + &uc->ss_excludes_file.oid)) + return 0; + + item = &s->pathspec.items[0]; + if (item->nowildcard_len != item->len) + return 0; + len = item->len; + if (len && item->match[len - 1] == '/') + len--; + if (!len) + return 0; + + pos = index_name_pos(istate, item->match, len); + if (pos >= 0) + return 0; + pos = -pos - 1; + if (pos >= istate->cache_nr) + return 0; + ce = istate->cache[pos]; + if (ce_namelen(ce) <= len || ce->name[len] != '/' || + strncmp(ce->name, item->match, len)) + return 0; + + selected = wt_status_find_cached_directory( + uc->root, item->match, len); + if (!selected) + return 0; + + strbuf_add(&path, item->match, len); + strbuf_addch(&path, '/'); + if (!uc->root->valid || !selected->valid || + !selected->valid_recursive) { + if (read_directory_cached_subtree( + dir, istate, selected, path.buf, path.len, + &s->pathspec) < 0) { + strbuf_release(&path); + return 0; + } + trace2_data_intmax("status", s->repo, + "untracked/pathspec-refreshed", 1); + } + wt_status_collect_cached_directory( + selected, &path, istate, &s->pathspec, untracked); + strbuf_release(&path); + trace2_data_intmax("status", s->repo, + "untracked/pathspec-cache", 1); + return 1; +} + static int wt_status_collect_untracked_1( struct wt_status *s, struct string_list *untracked, @@ -1182,16 +1333,20 @@ static int wt_status_collect_untracked_1( dir.internal.untracked_cache_preloaded = s->untracked_cache_preloaded; - fill_directory(&dir, istate, &s->pathspec); - if (s->certify_clean_status && dir.internal.traversal_failed) - s->certify_untracked_scan_failed = 1; - used_untracked_cache = dir.untracked && - dir.untracked == istate->untracked; - - for (i = 0; i < dir.nr; i++) { - struct dir_entry *ent = dir.entries[i]; - if (index_name_is_other(istate, ent->name, ent->len)) - string_list_append(untracked, ent->name); + if (wt_status_collect_cached_pathspec(s, &dir, untracked)) { + used_untracked_cache = 1; + } else { + fill_directory(&dir, istate, &s->pathspec); + if (s->certify_clean_status && dir.internal.traversal_failed) + s->certify_untracked_scan_failed = 1; + used_untracked_cache = dir.untracked && + dir.untracked == istate->untracked; + + for (i = 0; i < dir.nr; i++) { + struct dir_entry *ent = dir.entries[i]; + if (index_name_is_other(istate, ent->name, ent->len)) + string_list_append(untracked, ent->name); + } } string_list_sort_u(untracked, 0); From a730f03ad9858e0b96134f6b4d61ab9c3e8aa8e9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 10:50:14 -0700 Subject: [PATCH 243/403] read-cache: reject out-of-bounds index extensions load_index_extensions() enters its extension loop only when a complete eight-byte header fits before the trailing checksum. It nevertheless trusts the declared payload size. An oversized payload can send an extension parser beyond the mapped extension area, while an incomplete trailing header is silently ignored. Compute the checksum boundary once and require the initial offset, each complete header, and each declared payload to fit within it. Advance only by checked header and payload sizes, reject partial trailing headers, and report framing failures as index file corruption. Add a PERL_TEST_HELPERS regression test that overwrites an FSMN payload length with 0xffffffff and checks that porcelain-v2 status fails with the existing corruption diagnostic. Signed-off-by: Taylor Blau --- read-cache.c | 42 ++++++++++++++++++++++++++++++------- t/t7519-status-fsmonitor.sh | 25 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/read-cache.c b/read-cache.c index 6c449f393d8d4d..76941114ba8498 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1999,27 +1999,55 @@ struct load_index_extensions static void *load_index_extensions(void *_data) { struct load_index_extensions *p = _data; - unsigned long src_offset = p->src_offset; + size_t src_offset = p->src_offset; + size_t end; + int extension_error = 0; - while (src_offset <= p->mmap_size - the_hash_algo->rawsz - 8) { + if (p->mmap_size < the_hash_algo->rawsz) { + extension_error = 1; + goto done; + } + end = p->mmap_size - the_hash_algo->rawsz; + if (src_offset > end) { + extension_error = 1; + goto done; + } + + while (src_offset < end) { /* After an array of active_nr index entries, * there can be arbitrary number of extended * sections, each of which is prefixed with * extension name (4-byte) and section length * in 4-byte network byte order. */ - uint32_t extsize = get_be32(p->mmap + src_offset + 4); + uint32_t extsize; + + if (end - src_offset < 8) { + extension_error = 1; + break; + } + extsize = get_be32(p->mmap + src_offset + 4); + if (extsize > end - src_offset - 8) { + extension_error = 1; + break; + } if (read_index_extension(p->istate, p->mmap + src_offset, p->mmap + src_offset + 8, extsize) < 0) { - munmap((void *)p->mmap, p->mmap_size); - die(_("index file corrupt")); + extension_error = 1; + break; } - src_offset += 8; - src_offset += extsize; + src_offset += 8 + extsize; } + if (src_offset != end) + extension_error = 1; +done: + if (extension_error) { + munmap((void *)p->mmap, p->mmap_size); + die(_("index file corrupt")); + } return NULL; } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 93973ed25a448b..2e90955b52c374 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -55,6 +55,31 @@ test_lazy_prereq UNTRACKED_CACHE ' test $ret -ne 1 ' +test_expect_success PERL_TEST_HELPERS \ + 'index reader rejects an out-of-bounds extension size' ' + test_when_finished "rm -rf oversized-index-extension" && + test_create_repo oversized-index-extension && + ( + cd oversized-index-extension && + test_commit base tracked && + test_hook --setup fsmonitor-test <<-\EOF && + printf "token\0" + EOF + git config core.fsmonitor .git/hooks/fsmonitor-test && + git config core.fsmonitorHookVersion 2 && + git update-index --fsmonitor && + test_grep FSMN .git/index >/dev/null && + perl -0777 -pe " + \$pos = index(\$_, q(FSMN)); + die q(FSMN-not-found) if \$pos < 0; + substr(\$_, \$pos + 4, 4) = pack(q(N), 0xffffffff); + " .git/index >.git/index.bad && + mv .git/index.bad .git/index && + test_must_fail git status --porcelain=v2 2>err && + test_grep "index file corrupt" err + ) +' + # Test that we detect and disallow repos that are incompatible with FSMonitor. test_expect_success 'incompatible bare repo' ' test_when_finished "rm -rf ./bare-clone actual expect" && From 6252ce847c3e8dc8af8e9017e0f409420f834d20 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:33 -0500 Subject: [PATCH 244/403] cache-tree: avoid allocations and searches while reading read_one() allocates a subtree array even for leaf cache-tree nodes. It also inserts each serialized child through cache_tree_sub(), which searches children that the writer already emits in increasing order. Allocate a child array only for non-leaf nodes and append increasing child names directly. Retain subtree_nr + 2 pointer slots for each non-leaf, but allocate them without zeroing because only populated slots are inspected. Keep cache_tree_sub() as the compatibility fallback for older, unsorted input. Existing t/t0090-cache-tree.sh tests exercise ordinary cache-tree decoding. This change adds no dedicated unsorted-input regression or isolated benchmark. Signed-off-by: Taylor Blau --- cache-tree.c | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cache-tree.c b/cache-tree.c index d92f5132865f13..c811e23b14705b 100644 --- a/cache-tree.c +++ b/cache-tree.c @@ -676,20 +676,34 @@ static struct cache_tree *read_one(const char **buffer, unsigned long *size_p) /* * Just a heuristic -- we do not add directories that often but * we do not want to have to extend it immediately when we do, - * hence +2. + * hence +2. Avoid a separate allocation for the common leaf case. */ - it->subtree_alloc = subtree_nr + 2; - CALLOC_ARRAY(it->down, it->subtree_alloc); + if (subtree_nr) { + it->subtree_alloc = subtree_nr + 2; + ALLOC_ARRAY(it->down, it->subtree_alloc); + } for (i = 0; i < subtree_nr; i++) { /* read each subtree */ struct cache_tree *sub; struct cache_tree_sub *subtree; const char *name = buf; + int namelen; sub = read_one(&buf, &size); if (!sub) goto free_return; - subtree = cache_tree_sub(it, name); + namelen = strlen(name); + if (!it->subtree_nr || + subtree_name_cmp(it->down[it->subtree_nr - 1]->name, + it->down[it->subtree_nr - 1]->namelen, + name, namelen) < 0) { + FLEX_ALLOC_MEM(subtree, name, name, namelen); + subtree->namelen = namelen; + it->down[it->subtree_nr++] = subtree; + } else { + /* Be liberal in what we accept from older writers. */ + subtree = cache_tree_sub(it, name); + } subtree->cache_tree = sub; } if (subtree_nr != it->subtree_nr) From 3b5e72e83fbf7652dadfa13f2465194b66cfecce Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:53 -0500 Subject: [PATCH 245/403] read-cache: decode bounded TREE and UNTR extensions in parallel The index extension worker decodes TREE and UNTR serially even though their parsers read the same immutable mapping and publish to different index_state fields. An unconditional additional worker would consume cache-entry workers and interfere with split-index assembly. Use the bounded framing from S02/P01 to select exactly one TREE and one UNTR extension. Require extension-offset metadata and at least four index workers; start an additional TREE worker only when both payloads reach 1 MiB. Leave at least two cache-entry workers available and join the TREE worker before unmapping the index. Keep LINK, duplicate or missing extensions, insufficient workers, small payloads, and auxiliary-worker creation failures on the existing serial path. Malformed framing still reports index file corruption. Allow GIT_TEST_PARALLEL_INDEX_EXTENSIONS to bypass only the payload threshold. Add a PTHREADS, UNTRACKED_CACHE, and SHA1 regression that compares parallel and serial status, cache-tree, and untracked-cache results and checks the extension/parallel/tree-untracked Trace2 marker. The regression unsets GIT_TEST_SPLIT_INDEX because split indexes intentionally remain on the serial path. The eligible path adds one auxiliary worker and its stack. The benchmark covers the complete series, not this patch in isolation. Signed-off-by: Taylor Blau --- read-cache.c | 117 +++++++++++++++++++++++++++++++++--- t/t7519-status-fsmonitor.sh | 39 ++++++++++++ 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/read-cache.c b/read-cache.c index 76941114ba8498..5558c83b01e092 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1994,23 +1994,104 @@ struct load_index_extensions const char *mmap; size_t mmap_size; unsigned long src_offset; + int allow_parallel; + int force_parallel; }; +struct load_index_extension { + pthread_t pthread; + struct index_state *istate; + const char *ext; + const char *data; + unsigned long size; + int result; +}; + +#define PARALLEL_INDEX_EXTENSION_THRESHOLD (1024 * 1024) + +static void *load_one_index_extension(void *_data) +{ + struct load_index_extension *p = _data; + + trace2_thread_start("index-extension"); + trace2_data_intmax("index", p->istate->repo, + "extension/parallel/tree-untracked", 1); + p->result = read_index_extension(p->istate, p->ext, p->data, p->size); + trace2_thread_exit(); + return NULL; +} + +/* + * TREE and UNTR are usually the two largest index extensions. They read the + * same immutable mmap but publish to separate index_state fields, so they can + * be decoded concurrently. Keep split indexes on the established serial + * path because LINK changes how the completed index is assembled. + */ +static int find_parallel_index_extensions(struct load_index_extensions *p, + struct load_index_extension *tree) +{ + size_t offset = p->src_offset; + size_t end = p->mmap_size - the_hash_algo->rawsz; + int tree_nr = 0, untracked_nr = 0, link_nr = 0; + uint32_t untracked_size = 0; + + if (!p->allow_parallel) + return 0; + + while (offset <= end - 8) { + const char *ext = p->mmap + offset; + uint32_t size = get_be32(ext + 4); + + if (size > end - offset - 8) + return 0; + + switch (CACHE_EXT(ext)) { + case CACHE_EXT_TREE: + tree_nr++; + tree->istate = p->istate; + tree->ext = ext; + tree->data = ext + 8; + tree->size = size; + break; + case CACHE_EXT_UNTRACKED: + untracked_nr++; + untracked_size = size; + break; + case CACHE_EXT_LINK: + link_nr++; + break; + } + + offset += 8 + size; + } + + return offset == end && tree_nr == 1 && untracked_nr == 1 && !link_nr && + (p->force_parallel || + (tree->size >= PARALLEL_INDEX_EXTENSION_THRESHOLD && + untracked_size >= PARALLEL_INDEX_EXTENSION_THRESHOLD)); +} + static void *load_index_extensions(void *_data) { struct load_index_extensions *p = _data; size_t src_offset = p->src_offset; size_t end; + struct load_index_extension tree = { 0 }; + int tree_thread = 0; int extension_error = 0; if (p->mmap_size < the_hash_algo->rawsz) { extension_error = 1; - goto done; + goto join_tree; } end = p->mmap_size - the_hash_algo->rawsz; if (src_offset > end) { extension_error = 1; - goto done; + goto join_tree; + } + if (find_parallel_index_extensions(p, &tree) && + !pthread_create(&tree.pthread, NULL, load_one_index_extension, &tree)) { + tree_thread = 1; } while (src_offset < end) { @@ -2021,20 +2102,19 @@ static void *load_index_extensions(void *_data) * in 4-byte network byte order. */ uint32_t extsize; + const char *ext = p->mmap + src_offset; if (end - src_offset < 8) { extension_error = 1; break; } - extsize = get_be32(p->mmap + src_offset + 4); + extsize = get_be32(ext + 4); if (extsize > end - src_offset - 8) { extension_error = 1; break; } - if (read_index_extension(p->istate, - p->mmap + src_offset, - p->mmap + src_offset + 8, - extsize) < 0) { + if ((!tree_thread || CACHE_EXT(ext) != CACHE_EXT_TREE) && + read_index_extension(p->istate, ext, ext + 8, extsize) < 0) { extension_error = 1; break; } @@ -2043,11 +2123,22 @@ static void *load_index_extensions(void *_data) if (src_offset != end) extension_error = 1; -done: +join_tree: + if (tree_thread) { + int err = pthread_join(tree.pthread, NULL); + + if (err) + die(_("unable to join load_index_extension thread: %s"), + strerror(err)); + if (tree.result < 0) + extension_error = 1; + } + if (extension_error) { munmap((void *)p->mmap, p->mmap_size); die(_("index file corrupt")); } + return NULL; } @@ -2279,6 +2370,8 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) p.istate = istate; p.mmap = mmap; p.mmap_size = mmap_size; + p.allow_parallel = 0; + p.force_parallel = git_env_bool("GIT_TEST_PARALLEL_INDEX_EXTENSIONS", 0); src_offset = sizeof(*hdr); @@ -2300,13 +2393,21 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) extension_offset = read_eoie_extension(mmap, mmap_size); if (extension_offset) { int err; + struct load_index_extension tree = { 0 }; p.src_offset = extension_offset; + /* Keep at least two workers available for cache entries. */ + p.allow_parallel = nr_threads > 3; + if (p.allow_parallel) + p.allow_parallel = + find_parallel_index_extensions(&p, &tree); err = pthread_create(&p.pthread, NULL, load_index_extensions, &p); if (err) die(_("unable to create load_index_extensions thread: %s"), strerror(err)); nr_threads--; + if (p.allow_parallel) + nr_threads--; } } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 2e90955b52c374..273cda0a0f6bc8 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -502,4 +502,43 @@ test_expect_success 'status succeeds with sparse index' ' ) ' +test_expect_success PTHREADS,UNTRACKED_CACHE,SHA1 'load cache-tree and untracked-cache extensions in parallel' ' + test_create_repo parallel-extensions && + ( + cd parallel-extensions && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir dir && + echo tracked >dir/tracked && + git config index.threads 4 && + git add dir/tracked && + git commit -m initial && + git config core.untrackedCache true && + git status --porcelain >/dev/null && + echo modified >>dir/tracked && + echo untracked >dir/untracked && + GIT_TEST_INDEX_THREADS=1 \ + git --no-optional-locks status --porcelain >"$TRASH_DIRECTORY/parallel-serial.status" && + GIT_TEST_INDEX_THREADS=1 \ + test-tool dump-cache-tree >"$TRASH_DIRECTORY/parallel-serial.tree" && + GIT_TEST_INDEX_THREADS=1 \ + test-tool dump-untracked-cache >"$TRASH_DIRECTORY/parallel-serial.untracked" && + GIT_TEST_INDEX_THREADS=4 \ + GIT_TEST_PARALLEL_INDEX_EXTENSIONS=1 \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/parallel-extensions.trace" \ + git --no-optional-locks status --porcelain >"$TRASH_DIRECTORY/parallel-parallel.status" && + GIT_TEST_INDEX_THREADS=4 GIT_TEST_PARALLEL_INDEX_EXTENSIONS=1 \ + test-tool dump-cache-tree >"$TRASH_DIRECTORY/parallel-parallel.tree" && + GIT_TEST_INDEX_THREADS=4 GIT_TEST_PARALLEL_INDEX_EXTENSIONS=1 \ + test-tool dump-untracked-cache >"$TRASH_DIRECTORY/parallel-parallel.untracked" && + test_grep "extension/parallel/tree-untracked" \ + "$TRASH_DIRECTORY/parallel-extensions.trace" && + test_cmp "$TRASH_DIRECTORY/parallel-serial.status" \ + "$TRASH_DIRECTORY/parallel-parallel.status" && + test_cmp "$TRASH_DIRECTORY/parallel-serial.tree" \ + "$TRASH_DIRECTORY/parallel-parallel.tree" && + test_cmp "$TRASH_DIRECTORY/parallel-serial.untracked" \ + "$TRASH_DIRECTORY/parallel-parallel.untracked" + ) +' + test_done From 0497ef08338ee59155a8d84fe3b05197f5f9d052 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:51:15 -0500 Subject: [PATCH 246/403] status: refresh verified writable bulk-preload entries Bulk preload defers metadata-mismatched entries to run_diff_files() for a content check. When writable status confirms that such an entry is clean, it still leaves old stat data in the index. The next status must therefore repeat a content check already known to match. Request DIFF_UPDATE_INDEX_STAT only when status holds the index lock and bulk preload covers every indexed entry. After a real stat and a successful content and mode check, refresh only entries marked CE_CONTENT_CHECK_REQUIRED. Build the replacement with the helper from S15/P01 and install it with replace_index_entry(), preserving existing CE_VALID and index-change handling. Read-only status, incomplete bulk scans, dirty entries, and ordinary diff callers keep their existing behavior. The APFS regression compares both status output and the written index with ordinary status, and requires one bulk content check with no refresh-time lstat. Signed-off-by: Taylor Blau --- builtin/commit.c | 4 ++++ diff-lib.c | 10 ++++++++-- diff.h | 2 ++ read-cache-ll.h | 3 +++ read-cache.c | 7 +++++++ t/t7529-preload-index-apfs.sh | 30 ++++++++++++++++++++++++++++++ wt-status.c | 3 ++- wt-status.h | 1 + 8 files changed, 57 insertions(+), 3 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index fa64ba01f2a5e7..be04f9a6943590 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1638,6 +1638,10 @@ struct repository *repo UNUSED) fd = repo_hold_locked_index(the_repository, &index_lock, 0); else fd = -1; + s.bulk_update_index_stat = + 0 <= fd && + the_repository->index->preload_bulk_tracked_nr == + the_repository->index->cache_nr; s.is_initial = repo_get_oid(the_repository, s.reference, &oid) ? 1 : 0; if (!s.is_initial) diff --git a/diff-lib.c b/diff-lib.c index 33b4233738cac3..8f8aba3038a8a6 100644 --- a/diff-lib.c +++ b/diff-lib.c @@ -132,6 +132,8 @@ void run_diff_files(struct rev_info *revs, unsigned int option) unsigned int oldmode, newmode; int fsmonitor_valid = 0; struct cache_entry *ce = istate->cache[i]; + struct stat st; + int has_stat = 0; int changed; unsigned dirty_submodule = 0; const struct object_id *old_oid, *new_oid; @@ -253,8 +255,6 @@ void run_diff_files(struct rev_info *revs, unsigned int option) fsmonitor_valid = !!(ce->ce_flags & CE_FSMONITOR_VALID); } else { - struct stat st; - changed = check_removed(ce, &st); if (changed) { if (changed < 0) { @@ -276,11 +276,17 @@ void run_diff_files(struct rev_info *revs, unsigned int option) changed = match_stat_with_submodule(&revs->diffopt, ce, &st, ce_option, &dirty_submodule); + has_stat = 1; newmode = ce_mode_from_stat(revs->repo, ce, st.st_mode); fsmonitor_valid = fsmonitor_stat_can_be_valid(&st); } if (!changed && !dirty_submodule) { + if ((option & DIFF_UPDATE_INDEX_STAT) && has_stat && + (ce->ce_flags & CE_CONTENT_CHECK_REQUIRED)) { + refresh_index_entry_stat(istate, i, &st); + ce = istate->cache[i]; + } ce_mark_uptodate(ce); if (fsmonitor_valid) mark_fsmonitor_valid(istate, ce); diff --git a/diff.h b/diff.h index bb5cddaf3499e9..eb81289415f8f3 100644 --- a/diff.h +++ b/diff.h @@ -698,6 +698,8 @@ void diff_get_merge_base(const struct rev_info *revs, struct object_id *mb); #define DIFF_SILENT_ON_REMOVED 01 /* report racily-clean paths as modified */ #define DIFF_RACY_IS_MODIFIED 02 +/* update index stat data for content-checked entries */ +#define DIFF_UPDATE_INDEX_STAT 04 void run_diff_files(struct rev_info *revs, unsigned int option); #define DIFF_INDEX_CACHED 01 diff --git a/read-cache-ll.h b/read-cache-ll.h index 99b95d8e8d5325..84d5d1a015b2b9 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -497,6 +497,9 @@ int repo_refresh_and_write_index(struct repository*, unsigned int refresh_flags, struct cache_entry *refresh_cache_entry(struct index_state *, struct cache_entry *, unsigned int); +/* The caller must first verify the entry's content and mode against st. */ +void refresh_index_entry_stat(struct index_state *, int, struct stat *); + void set_alternate_index_output(const char *); extern int verify_index_checksum; diff --git a/read-cache.c b/read-cache.c index 088f83559a6b56..8c61bc7b7253e9 100644 --- a/read-cache.c +++ b/read-cache.c @@ -222,6 +222,13 @@ static struct cache_entry *make_refreshed_cache_entry( return updated; } +void refresh_index_entry_stat(struct index_state *istate, int nr, + struct stat *st) +{ + replace_index_entry(istate, nr, make_refreshed_cache_entry( + istate, istate->cache[nr], st, 1)); +} + static unsigned int st_mode_from_ce(const struct cache_entry *ce) { switch (ce->ce_mode & S_IFMT) { diff --git a/t/t7529-preload-index-apfs.sh b/t/t7529-preload-index-apfs.sh index c7c399045f2e1e..46c265196b8881 100755 --- a/t/t7529-preload-index-apfs.sh +++ b/t/t7529-preload-index-apfs.sh @@ -59,6 +59,22 @@ bulk_status () { git -C "$repo" status --porcelain=v2 >"$output" } +writable_ordinary_status () { + git -C "$1" -c core.preloadIndex=false \ + status --porcelain=v2 >"$2" +} + +writable_bulk_status () { + repo=$1 && + output=$2 && + writable_trace=$TRASH_DIRECTORY/$3 && + rm -f "$writable_trace" && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_PRELOAD_INDEX_BULK=1 \ + GIT_TRACE2_EVENT="$writable_trace" \ + git -C "$repo" status --porcelain=v2 >"$output" +} + check_data () { test_trace2_data index "$2" "$3" <"$TRASH_DIRECTORY/$1" } @@ -241,6 +257,20 @@ test_expect_success PIPE \ check_data tracked-types.trace refresh/sum_lstat 2 ' +test_expect_success 'writable status retains refreshed stat data' ' + setup_repo writable-stat && + test-tool chmtime +60 writable-stat/root && + cp writable-stat/.git/index before.index && + writable_ordinary_status writable-stat expect && + cp writable-stat/.git/index ordinary.index && + cp before.index writable-stat/.git/index && + writable_bulk_status writable-stat actual writable-stat.trace && + test_cmp expect actual && + test_cmp ordinary.index writable-stat/.git/index && + check_data writable-stat.trace preload/bulk_content_check 1 && + check_data writable-stat.trace refresh/sum_lstat 0 +' + test_expect_success CASE_INSENSITIVE_FS \ 'case aliases retain parallel preload' ' setup_repo case-alias && diff --git a/wt-status.c b/wt-status.c index 7ea206bdc9609b..f9734d63c75494 100644 --- a/wt-status.c +++ b/wt-status.c @@ -723,7 +723,8 @@ static void wt_status_collect_changes_worktree(struct wt_status *s) rev.diffopt.rename_limit = s->rename_limit >= 0 ? s->rename_limit : rev.diffopt.rename_limit; rev.diffopt.rename_score = s->rename_score >= 0 ? s->rename_score : rev.diffopt.rename_score; copy_pathspec(&rev.prune_data, &s->pathspec); - run_diff_files(&rev, 0); + run_diff_files(&rev, s->bulk_update_index_stat ? + DIFF_UPDATE_INDEX_STAT : 0); wt_status_release_preload_changes(direct, direct_nr); release_revisions(&rev); } diff --git a/wt-status.h b/wt-status.h index 34beac22576fc9..e5cdc803f885e4 100644 --- a/wt-status.h +++ b/wt-status.h @@ -140,6 +140,7 @@ struct wt_status { int committable; int workdir_dirty; unsigned untracked_from_token_closure : 1; + unsigned bulk_update_index_stat : 1; const char *index_file; FILE *fp; const char *prefix; From 3f0cc180f781692f7db79afa3efff13dc7e855d2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:48:46 -0500 Subject: [PATCH 247/403] fsmonitor: preserve authenticated legacy daemon history Bound fsmonitor queries prevent a shared Git directory from borrowing events from another worktree. An older daemon cannot interpret those queries, though, and replacing it discards the index token and all existing event history. Concurrent legacy clients can also recreate the socket before the replacement observes the original daemon exit. Authenticate a legacy Unix-socket peer against its effective user and watched worktree before replaying the existing token. Verify the daemon's open root on macOS and its root inotify watch on Linux. Cache successful checks under the canonical root, peer identity and start time, and socket generation so large Linux watch lists are read once. Track socket generations when an incompatible daemon must be replaced. The untracked-cache identity also changed between versions, causing add_untracked_cache() to discard the legacy directory tree before the daemon can be authenticated. Retain only the matching older identity until a nontrivial response proves the daemon watches this worktree; then upgrade the identity, preserve invalid directory frontiers, and close the existing forward-baseline proof. Keep ordinary invalidation for unmatched roots, missing tokens, weak stat settings, and failed authentication. Linux system Git without daemon support instead writes the placeholder token "builtin:fake" while retaining the legacy directory tree. That token is not a usable event boundary. When an authenticated daemon returns a full invalidation for it, preserve the old cache identity and validate tracked entries and directory timestamps normally. Avoid semantic fast paths, private FSUC/FSCF index extensions, and optional rewrites of an otherwise unchanged shared index during this fallback. Continue accepting unbound token requests from older clients once a bound-aware daemon is running. Mixed-version clients can therefore share one correctly identified daemon without restart loops or whole-worktree cache rebuilds. --- builtin/commit.c | 6 +- builtin/fsmonitor--daemon.c | 3 +- dir.c | 54 ++++- dir.h | 2 + fsmonitor-ipc.c | 372 ++++++++++++++++++++++++++++--- fsmonitor-ipc.h | 3 +- fsmonitor.c | 79 ++++++- fsmonitor.h | 1 + read-cache-ll.h | 2 + read-cache.c | 4 +- t/helper/test-fsmonitor-client.c | 4 +- t/t7527-builtin-fsmonitor.sh | 31 +++ wt-status.c | 37 +-- 13 files changed, 549 insertions(+), 49 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index f2737389e9c643..b27ac2e201180c 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1841,7 +1841,11 @@ struct repository *repo UNUSED) external_saved = clean_status_save_external_history( the_repository->index); - if (exact_clean_query) { + if (the_repository->index->fsmonitor_legacy_untracked_fallback && + !preserve_entry_changes && !external_saved) { + rollback_lock_file(&index_lock); + fd = -1; + } else if (exact_clean_query) { if (!preserve_entry_changes && external_saved && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 0)) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 953f68b4fc1185..65780205798554 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -710,7 +710,8 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, if (strcmp(command, "quit") && strcmp(command, "flush") && - strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { + strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND) && + !starts_with(command, "builtin:")) { const char *identity; const char *query; diff --git a/dir.c b/dir.c index e2069134249857..27f13a51569848 100644 --- a/dir.c +++ b/dir.c @@ -4043,6 +4043,19 @@ static int ident_in_untracked(const struct untracked_cache *uc) return !strcmp(uc->ident.buf, get_ident_string()); } +static int legacy_ident_in_untracked(const struct untracked_cache *uc) +{ + static const char suffix[] = ", cache version 2"; + const char *current = get_ident_string(); + size_t current_len = strlen(current); + size_t suffix_len = sizeof(suffix) - 1; + + return current_len > suffix_len && + !strcmp(current + current_len - suffix_len, suffix) && + strlen(uc->ident.buf) == current_len - suffix_len && + !memcmp(uc->ident.buf, current, current_len - suffix_len); +} + static void set_untracked_ident(struct untracked_cache *uc) { strbuf_reset(&uc->ident); @@ -4093,12 +4106,49 @@ void add_untracked_cache(struct index_state *istate) new_untracked_cache(istate, -1); } else { if (!ident_in_untracked(istate->untracked)) { + if (istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + starts_with(istate->fsmonitor_last_update, + "builtin:") && + !istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + !istate->split_index && + fsm_settings__get_mode(istate->repo) == + FSMONITOR_MODE_IPC && + legacy_ident_in_untracked(istate->untracked)) { + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-preserved", 1); + return; + } free_untracked_cache(istate->untracked); new_untracked_cache(istate, -1); } } } +int untracked_cache_adopt_legacy(struct index_state *istate) +{ + if (!istate->untracked || + !legacy_ident_in_untracked(istate->untracked)) + return 0; + set_untracked_ident(istate->untracked); + untracked_cache_recompute_fsmonitor_valid_recursive( + istate->untracked); + istate->cache_changed |= UNTRACKED_CHANGED; + return 1; +} + +void untracked_cache_discard_legacy(struct index_state *istate) +{ + if (!istate->untracked || + !legacy_ident_in_untracked(istate->untracked)) + return; + free_untracked_cache(istate->untracked); + new_untracked_cache(istate, -1); + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-discarded", 1); +} + void remove_untracked_cache(struct index_state *istate) { if (istate->untracked) { @@ -4162,7 +4212,9 @@ static struct untracked_cache_dir *validate_untracked_cache(struct dir_struct *d if (dir->internal.exclude_list_group[EXC_CMDL].nr) return NULL; - if (!ident_in_untracked(dir->untracked)) { + if (!ident_in_untracked(dir->untracked) && + !(istate->fsmonitor_legacy_untracked_fallback && + legacy_ident_in_untracked(dir->untracked))) { warning(_("untracked cache is disabled on this system or location")); return NULL; } diff --git a/dir.h b/dir.h index d674df9a493a18..c13d0db2866eaf 100644 --- a/dir.h +++ b/dir.h @@ -652,6 +652,8 @@ struct untracked_cache *read_untracked_extension(const void *data, unsigned long void write_untracked_extension(struct strbuf *out, struct untracked_cache *untracked); void add_untracked_cache(struct index_state *istate); void remove_untracked_cache(struct index_state *istate); +int untracked_cache_adopt_legacy(struct index_state *istate); +void untracked_cache_discard_legacy(struct index_state *istate); /* * Connect a worktree to a git directory by creating (or overwriting) a diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index f6eb03cfd9442f..38f3843bbbb9bb 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -16,6 +16,15 @@ #include "strbuf.h" #include "trace2.h" +#ifdef __APPLE__ +#include +#include +#endif + +#ifdef __linux__ +#include +#endif + int fsmonitor_ipc__get_worktree_identity(struct repository *r, struct strbuf *identity) { @@ -79,7 +88,8 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) } int fsmonitor_ipc__send_query(const char *since_token UNUSED, - struct strbuf *answer UNUSED) + struct strbuf *answer UNUSED, + int *legacy_worktree_authenticated UNUSED) { return -1; } @@ -251,12 +261,297 @@ static int server_supports_bound_queries(void) return ret; } -static int wait_for_daemon_exit(void) +#if defined(__APPLE__) || defined(__linux__) +static int legacy_peer_credentials( + struct ipc_client_connection *connection, pid_t *pid) +{ +#ifdef __APPLE__ + uid_t uid; + gid_t gid; + socklen_t size = sizeof(*pid); + + if (getpeereid(connection->fd, &uid, &gid) || + uid != geteuid() || + getsockopt(connection->fd, SOL_LOCAL, LOCAL_PEERPID, + pid, &size) || size != sizeof(*pid)) + return 0; +#else + struct ucred peer; + socklen_t size = sizeof(peer); + + if (getsockopt(connection->fd, SOL_SOCKET, SO_PEERCRED, + &peer, &size) || size != sizeof(peer) || + peer.uid != geteuid()) + return 0; + *pid = peer.pid; +#endif + return *pid > 0; +} + +static int legacy_peer_start_identity(pid_t pid, struct strbuf *identity) +{ +#ifdef __APPLE__ + struct proc_bsdinfo info; + + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, + &info, sizeof(info)) != sizeof(info) || + info.pbi_pid != (uint32_t)pid || + info.pbi_uid != geteuid()) + return 0; + strbuf_addf(identity, "%"PRIu64".%"PRIu64, + info.pbi_start_tvsec, info.pbi_start_tvusec); +#else + struct strbuf path = STRBUF_INIT; + struct strbuf stat = STRBUF_INIT; + const char *value, *end; + int valid = 0; + + strbuf_addf(&path, "/proc/%"PRIuMAX"/stat", (uintmax_t)pid); + if (strbuf_read_file(&stat, path.buf, 4096) < 0 || + !(value = strrchr(stat.buf, ')')) || + value[1] != ' ') + goto done; + value += 2; + for (int field = 3; field < 22; field++) { + value = strchr(value, ' '); + if (!value) + goto done; + while (*value == ' ') + value++; + } + end = strchr(value, ' '); + if (!end || end == value) + goto done; + for (const char *p = value; p < end; p++) + if (!isdigit(*p)) + goto done; + strbuf_add(identity, value, end - value); + valid = 1; +done: + strbuf_release(&path); + strbuf_release(&stat); + return valid; +#endif + return 1; +} + +#ifdef __APPLE__ +static int legacy_peer_watches_worktree( + pid_t pid, const char *worktree, const struct stat *root) +{ + struct proc_fdinfo *fds = NULL; + int size, bytes, matches = 0; + + size = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0); + if (size <= 0 || size > 1024 * 1024 - + 16 * (int)sizeof(*fds)) + return 0; + size += 16 * sizeof(*fds); + fds = xmalloc(size); + bytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, size); + if (bytes < 0 || bytes % sizeof(*fds)) + goto done; + for (int i = 0; i < bytes / (int)sizeof(*fds); i++) { + struct vnode_fdinfowithpath vnode; + const struct vinfo_stat *stat; + + if (fds[i].proc_fdtype != PROX_FDTYPE_VNODE || + proc_pidfdinfo(pid, fds[i].proc_fd, + PROC_PIDFDVNODEPATHINFO, + &vnode, sizeof(vnode)) != sizeof(vnode)) + continue; + stat = &vnode.pvip.vip_vi.vi_stat; + if ((uintmax_t)stat->vst_dev == (uintmax_t)root->st_dev && + (uintmax_t)stat->vst_ino == (uintmax_t)root->st_ino && + !strcmp(vnode.pvip.vip_path, worktree)) { + matches = 1; + break; + } + } +done: + free(fds); + return matches; +} +#else +static int legacy_peer_watches_worktree( + pid_t pid, const char *worktree UNUSED, const struct stat *root) +{ + struct strbuf directory = STRBUF_INIT; + struct strbuf path = STRBUF_INIT; + struct strbuf target = STRBUF_INIT; + struct strbuf line = STRBUF_INIT; + uintmax_t device = ((uintmax_t)major(root->st_dev) << 20) | + (uintmax_t)minor(root->st_dev); + DIR *fds = NULL; + struct dirent *entry; + int matches = 0; + + strbuf_addf(&directory, "/proc/%"PRIuMAX"/fd", (uintmax_t)pid); + fds = opendir(directory.buf); + if (!fds) + goto done; + while ((entry = readdir(fds)) != NULL) { + FILE *info; + + if (!strcmp(entry->d_name, ".") || + !strcmp(entry->d_name, "..")) + continue; + strbuf_reset(&path); + strbuf_addf(&path, "%s/%s", directory.buf, entry->d_name); + strbuf_reset(&target); + if (strbuf_readlink(&target, path.buf, 32) < 0 || + strcmp(target.buf, "anon_inode:inotify")) + continue; + strbuf_reset(&path); + strbuf_addf(&path, "/proc/%"PRIuMAX"/fdinfo/%s", + (uintmax_t)pid, entry->d_name); + info = fopen(path.buf, "r"); + if (!info) + continue; + while (!strbuf_getline_lf(&line, info)) { + uintmax_t inode, source_device; + unsigned int watch; + + if (!starts_with(line.buf, "inotify wd:1 ")) + continue; + if (sscanf(line.buf, + "inotify wd:%x ino:%"SCNxMAX" sdev:%"SCNxMAX, + &watch, &inode, &source_device) == 3 && + watch == 1 && inode == (uintmax_t)root->st_ino && + source_device == device) + matches = 1; + break; + } + fclose(info); + if (matches) + break; + } +done: + if (fds) + closedir(fds); + strbuf_release(&directory); + strbuf_release(&path); + strbuf_release(&target); + strbuf_release(&line); + return matches; +} +#endif + +static int legacy_identity_cache_matches( + const char *path, const struct strbuf *expected) +{ + struct strbuf actual = STRBUF_INIT; + struct stat st; + int fd, matches = 0; + + fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) + return 0; + if (!fstat(fd, &st) && S_ISREG(st.st_mode) && + st.st_uid == geteuid() && !(st.st_mode & 022) && + st.st_size >= 0 && (uintmax_t)st.st_size == expected->len && + strbuf_read(&actual, fd, expected->len) == (ssize_t)expected->len) + matches = !strbuf_cmp(&actual, expected); + close(fd); + strbuf_release(&actual); + return matches; +} + +static void cache_legacy_peer_identity( + const char *path, const struct strbuf *identity) +{ + struct lock_file lock = LOCK_INIT; + int fd = hold_lock_file_for_update(&lock, path, LOCK_NO_DEREF); + + if (fd < 0) + return; + if (fchmod(fd, 0600) || + write_in_full(fd, identity->buf, identity->len) != + (ssize_t)identity->len || + commit_lock_file(&lock)) + rollback_lock_file(&lock); +} + +static int try_send_attested_legacy_query( + const char *token, const struct strbuf *identity, + struct strbuf *answer) +{ + struct ipc_client_connect_options options = + IPC_CLIENT_CONNECT_OPTIONS_INIT; + struct ipc_client_connection *connection = NULL; + struct strbuf worktree = STRBUF_INIT; + struct strbuf path = STRBUF_INIT; + struct strbuf expected = STRBUF_INIT; + struct strbuf peer_start = STRBUF_INIT; + struct stat root, socket; + pid_t pid; + int cached, ret = -1; + + if (!token || !starts_with(token, "builtin:") || + !repo_get_work_tree(the_repository) || + !strbuf_realpath(&worktree, + repo_get_work_tree(the_repository), 0) || + stat(worktree.buf, &root) || !S_ISDIR(root.st_mode)) + goto done; + options.wait_if_busy = 1; + if (ipc_client_try_connect( + fsmonitor_ipc__get_path(the_repository), + &options, &connection) != IPC_STATE__LISTENING || + !legacy_peer_credentials(connection, &pid) || + !legacy_peer_start_identity(pid, &peer_start) || + lstat(fsmonitor_ipc__get_path(the_repository), &socket) || + !S_ISSOCK(socket.st_mode)) + goto done; + strbuf_addf(&path, "%s.legacy-identity", + fsmonitor_ipc__get_path(the_repository)); + strbuf_addf(&expected, + "v1\n%s\n%"PRIuMAX"\n%"PRIuMAX"\n%s\n%"PRIuMAX"\n%"PRIuMAX"\n", + identity->buf, (uintmax_t)geteuid(), (uintmax_t)pid, + peer_start.buf, + (uintmax_t)socket.st_dev, (uintmax_t)socket.st_ino); + cached = legacy_identity_cache_matches(path.buf, &expected); + if (!cached && + !legacy_peer_watches_worktree(pid, worktree.buf, &root)) + goto done; + if (!cached) + cache_legacy_peer_identity(path.buf, &expected); + trace2_data_intmax("fsm_client", NULL, + cached ? "query/legacy-peer-cached" : + "query/legacy-peer-authenticated", 1); + ret = ipc_client_send_command_to_connection( + connection, token, strlen(token), answer); +done: + ipc_client_close_connection(connection); + strbuf_release(&worktree); + strbuf_release(&path); + strbuf_release(&expected); + strbuf_release(&peer_start); + return ret; +} +#else +static int try_send_attested_legacy_query( + const char *token UNUSED, const struct strbuf *identity UNUSED, + struct strbuf *answer UNUSED) +{ + return -1; +} +#endif + +static int wait_for_daemon_exit(const struct stat *original_socket) { uintmax_t elapsed_ms = 0; uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; while (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (original_socket) { + struct stat current_socket; + + if (!lstat(fsmonitor_ipc__get_path(the_repository), + ¤t_socket) && + (current_socket.st_dev != original_socket->st_dev || + current_socket.st_ino != original_socket->st_ino)) + return 1; + } if (elapsed_ms >= timeout_ms) return -1; sleep_millisec(50); @@ -273,6 +568,7 @@ static int restart_incompatible_daemon(void) uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; long lock_timeout_ms = timeout_ms > LONG_MAX ? LONG_MAX : (long)timeout_ms; + unsigned int restart_attempts = 0; int have_lock = 0; int ret = -1; @@ -291,35 +587,47 @@ static int restart_incompatible_daemon(void) } have_lock = 1; - /* Another client may have replaced the daemon while we waited. */ - if (server_supports_bound_queries()) - goto success; - trace2_data_intmax("fsm_client", NULL, "query/incompatible-daemon", 1); - if (try_send_command("quit", &answer, NULL)) { - /* - * The connection state describes the failed attempt, not - * necessarily the state after the failure. Re-read it before - * deciding whether there is still a daemon to replace. - */ - if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { - if (server_supports_bound_queries()) - ret = 0; - goto done; + while (restart_attempts++ < 32) { + struct stat socket_stat; + const struct stat *original_socket = NULL; + int wait_result; + + /* Another client may have replaced the daemon while we waited. */ + if (server_supports_bound_queries()) + goto success; + if (!lstat(fsmonitor_ipc__get_path(the_repository), + &socket_stat)) + original_socket = &socket_stat; + if (try_send_command("quit", &answer, NULL)) { + /* + * The failed connection may already have been replaced. + * Re-read its state before abandoning the upgrade. + */ + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { + if (server_supports_bound_queries()) + ret = 0; + goto done; + } } - } - if (wait_for_daemon_exit()) - goto done; + wait_result = wait_for_daemon_exit(original_socket); + if (wait_result < 0) + goto done; + if (wait_result > 0) { + trace2_data_intmax("fsm_client", NULL, + "query/restart-raced", 1); + continue; + } - /* - * A concurrent client may already have started a replacement. - * The retried bound query will verify its capability if needed. - */ - if (fsmonitor_ipc__get_state() != IPC_STATE__LISTENING && - spawn_daemon()) - goto done; + /* The retried bound query still verifies any raced replacement. */ + if (fsmonitor_ipc__get_state() != IPC_STATE__LISTENING && + spawn_daemon()) + goto done; + goto success; + } + goto done; success: ret = 0; @@ -333,7 +641,8 @@ static int restart_incompatible_daemon(void) } int fsmonitor_ipc__send_query(const char *since_token, - struct strbuf *answer) + struct strbuf *answer, + int *legacy_worktree_authenticated) { struct strbuf command = STRBUF_INIT; struct strbuf identity = STRBUF_INIT; @@ -345,6 +654,8 @@ int fsmonitor_ipc__send_query(const char *since_token, = IPC_CLIENT_CONNECT_OPTIONS_INIT; const char *tok = since_token ? since_token : ""; + if (legacy_worktree_authenticated) + *legacy_worktree_authenticated = 0; trace2_region_enter("fsm_client", "query", NULL); if (fsmonitor_ipc__get_worktree_identity(the_repository, &identity)) { trace2_data_intmax("fsm_client", NULL, @@ -377,6 +688,13 @@ int fsmonitor_ipc__send_query(const char *since_token, "query/response-length", answer->len); if (!ret && is_trivial_response(answer) && !server_supports_bound_queries()) { + if (!try_send_attested_legacy_query( + tok, &identity, answer)) { + if (legacy_worktree_authenticated) + *legacy_worktree_authenticated = 1; + ret = 0; + goto done; + } /* * A daemon predating bound queries treats query-v1 as * garbage and returns a valid trivial response. Never diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index 006ee0750cf134..daddca5b67fc9b 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -44,7 +44,8 @@ enum ipc_active_state fsmonitor_ipc__get_state(void); * Returns -1 on error; 0 on success. */ int fsmonitor_ipc__send_query(const char *since_token, - struct strbuf *answer); + struct strbuf *answer, + int *legacy_worktree_authenticated); /* * Connect to a `git-fsmonitor--daemon` process via simple-ipc and diff --git a/fsmonitor.c b/fsmonitor.c index 76bb7e051176a0..795109d1169c3e 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -848,6 +848,7 @@ enum fsmonitor_query_outcome query_builtin_fsmonitor( const char *test_sequence = getenv("GIT_TEST_FSMONITOR_QUERY_SEQUENCE"); struct strbuf raw = STRBUF_INIT; + int legacy_authenticated = 0; /* * Tests may script clean, delta, trivial, and error responses with @@ -883,8 +884,12 @@ enum fsmonitor_query_outcome query_builtin_fsmonitor( return result->outcome; } - if (!fsmonitor_ipc__send_query(since_token, &raw)) + if (!fsmonitor_ipc__send_query( + since_token, &raw, &legacy_authenticated)) { fsmonitor_parse_builtin_response(&raw, result); + result->legacy_worktree_authenticated = + legacy_authenticated; + } strbuf_release(&raw); return result->outcome; } @@ -906,6 +911,55 @@ static int apply_fsmonitor_paths(struct index_state *istate, return count; } +static void adopt_legacy_untracked_cache( + struct index_state *istate, + const struct fsmonitor_query_result *result, + int semantic_baseline_needed) +{ + if (fstat_is_reliable() && !istate->split_index && + istate->repo->config_values_private_.trust_ctime && + istate->repo->config_values_private_.check_stat && + result->outcome == FSMONITOR_QUERY_TRIVIAL && + istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + !strcmp(istate->fsmonitor_last_update, "builtin:fake") && + !istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + istate->untracked && istate->untracked->root) { + /* + * A client without daemon support records builtin:fake. Its + * UNTR tree is still useful with ordinary directory timestamp + * validation, but it cannot certify fsmonitor acceleration. + */ + istate->fsmonitor_legacy_untracked_fallback = 1; + istate->untracked->use_fsmonitor = 0; + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-stat-fallback", 1); + return; + } + if (!semantic_baseline_needed || + !result->legacy_worktree_authenticated || + result->outcome != FSMONITOR_QUERY_DELTA || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + !istate->untracked || !istate->untracked->root) { + untracked_cache_discard_legacy(istate); + return; + } + if (!untracked_cache_adopt_legacy(istate)) + return; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_untracked_valid = 1; + istate->fsmonitor_legacy_untracked_adopted = 1; + istate->untracked->use_fsmonitor = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/legacy-adopted", 1); +} + static void invalidate_all_fsmonitor(struct index_state *istate) { unsigned int i; @@ -933,8 +987,14 @@ static void invalidate_all_fsmonitor_for_baseline( struct index_state *istate) { unsigned int i; + int preserve_untracked = istate->fsmonitor_legacy_untracked_adopted && + istate->fsmonitor_untracked_valid; invalidate_all_fsmonitor(istate); + if (preserve_untracked) { + istate->fsmonitor_untracked_valid = 1; + istate->untracked->use_fsmonitor = 1; + } for (i = 0; i < istate->cache_nr; i++) istate->cache[i]->ce_flags &= ~CE_UPTODATE; } @@ -957,6 +1017,7 @@ static void invalidate_all_fsmonitor_strong(struct index_state *istate) void fsmonitor_invalidate_semantics(struct index_state *istate) { + istate->fsmonitor_legacy_untracked_adopted = 0; clean_status_invalidate_current_proof(istate); git_attr_invalidate_all(); invalidate_all_fsmonitor_strong(istate); @@ -979,6 +1040,12 @@ static void invalidate_fsmonitor_for_bootstrap( } if (physical_history_unavailable) { + if (istate->fsmonitor_legacy_untracked_fallback) { + invalidate_all_fsmonitor_for_baseline(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/legacy-stat-fallback", 1); + return; + } clean_status_refresh_worktree_manifest(istate); fsmonitor_invalidate_semantics(istate); untracked_cache_invalidate_all(istate); @@ -1053,6 +1120,8 @@ void refresh_fsmonitor(struct index_state *istate) istate->fsmonitor_last_update ? istate->fsmonitor_last_update : "builtin:fake", &result); + adopt_legacy_untracked_cache( + istate, &result, semantic_baseline_needed); if (result.outcome != FSMONITOR_QUERY_ERROR) { query_success = 1; strbuf_addbuf(&last_update_token, &result.token); @@ -1288,7 +1357,13 @@ void refresh_fsmonitor(struct index_state *istate) istate->fsmonitor_pending_token_from_provider = query_success && (fsm_mode == FSMONITOR_MODE_IPC || !is_trivial); - istate->fsmonitor_untracked_valid = 0; + if (istate->fsmonitor_legacy_untracked_adopted) { + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update_pending); + } else { + istate->fsmonitor_untracked_valid = 0; + } } else { /* * The applied delta carries an existing proof forward: diff --git a/fsmonitor.h b/fsmonitor.h index 136f4769c36fc2..ef178d61a225d9 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -26,6 +26,7 @@ struct fsmonitor_query_result { enum fsmonitor_query_outcome outcome; struct strbuf token; struct strbuf paths; + unsigned int legacy_worktree_authenticated : 1; }; #define FSMONITOR_QUERY_RESULT_INIT { \ diff --git a/read-cache-ll.h b/read-cache-ll.h index 81faab1478ddca..2051bbd974e442 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -193,6 +193,8 @@ struct index_state { fsmonitor_untracked_valid : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, + fsmonitor_legacy_untracked_adopted : 1, + fsmonitor_legacy_untracked_fallback : 1, fsmonitor_pending_token_from_provider : 1, preload_untracked_complete : 1, preload_bulk_provider_pending : 1, diff --git a/read-cache.c b/read-cache.c index 0a8f726ddbd427..91575f07b5da69 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3300,7 +3300,8 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, if (write_extensions & WRITE_FSMONITOR_EXTENSION && istate->untracked && istate->fsmonitor_last_update && - istate->fsmonitor_untracked_valid) { + istate->fsmonitor_untracked_valid && + !istate->fsmonitor_legacy_untracked_fallback) { strbuf_reset(&sb); write_fsmonitor_untracked_extension(&sb, istate); @@ -3314,6 +3315,7 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, } } if (write_extensions & WRITE_FSCF_EXTENSION && + !istate->fsmonitor_legacy_untracked_fallback && clean_status_should_write_fsmonitor_config(istate)) { strbuf_reset(&sb); clean_status_write_fsmonitor_config(&sb, istate); diff --git a/t/helper/test-fsmonitor-client.c b/t/helper/test-fsmonitor-client.c index dc1dff23fb8ed5..b5e428a0730a61 100644 --- a/t/helper/test-fsmonitor-client.c +++ b/t/helper/test-fsmonitor-client.c @@ -53,7 +53,7 @@ static int do_send_query(const char *token) if (!token || !*token) token = get_token_from_index(); - ret = fsmonitor_ipc__send_query(token, &answer); + ret = fsmonitor_ipc__send_query(token, &answer, NULL); if (ret < 0) die("could not query fsmonitor--daemon"); @@ -109,7 +109,7 @@ static void *hammer_thread_proc(void *_hammer_thread_data) for (k = 0; k < data->nr_requests; k++) { strbuf_reset(&answer); - ret = fsmonitor_ipc__send_query(data->token, &answer); + ret = fsmonitor_ipc__send_query(data->token, &answer, NULL); if (ret < 0) data->sum_errors++; else diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 4edbd1f87386a7..304e020206b1bb 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1562,6 +1562,37 @@ test_expect_success 'bound query replaces a legacy daemon' ' ) ' +test_expect_success 'bound daemon also serves legacy token queries' ' + test_when_finished "stop_daemon_delete_repo legacy-client-query" && + test_create_repo legacy-client-query && + ( + cd legacy-client-query && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TRACE2_EVENT="$PWD/.git/daemon.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test-tool dump-fsmonitor >.git/fsmonitor && + token=$(sed -n "s/^fsmonitor last update //p" \ + .git/fsmonitor) && + test -n "$token" && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc send --name="$ipc_path" \ + --token="$token" >.git/legacy-response && + test_grep "^builtin:" .git/legacy-response && + ! test_trace2_data fsmonitor query/worktree-mismatch 1 \ + <.git/daemon.trace && + GIT_TRACE2_EVENT="$PWD/.git/legacy-client.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/legacy-client.trace + ) +' + test_expect_success MACOS 'daemon token reset closes a skipHash index' ' test_when_finished \ "stop_daemon_delete_repo daemon-token-reset" && diff --git a/wt-status.c b/wt-status.c index b31f5d14163840..018dcc36efb705 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1059,11 +1059,12 @@ static int wt_status_begin_attr_snapshot(struct wt_status *s) } if (s->attr_source_snapshot) git_attr_source_snapshot_begin(s->attr_source_snapshot); - if ((ret > 0 && - (!hook_provider || - (ret & CLEAN_STATUS_ATTR_CONTENT_CHANGED))) || - (clean_status_fsmonitor_strong_mismatch(s->repo->index) && - !hook_provider)) { + if (!s->repo->index->fsmonitor_legacy_untracked_fallback && + ((ret > 0 && + (!hook_provider || + (ret & CLEAN_STATUS_ATTR_CONTENT_CHANGED))) || + (clean_status_fsmonitor_strong_mismatch(s->repo->index) && + !hook_provider))) { /* * Hook providers have no closing query with which to adopt * missing semantic history, so absence alone must preserve @@ -1371,6 +1372,7 @@ static int wt_status_can_use_bulk_provider( struct wt_status *s, unsigned int refresh_flags) { return !s->show_ignored_mode && !s->pathspec.nr && + !s->repo->index->fsmonitor_legacy_untracked_fallback && !clean_status_filter_scope_needs_validation(s->repo->index) && (refresh_flags & REFRESH_DEFER_BULK_DIRTY) && preload_index_bulk_can_close_provider(s->repo->index); @@ -1385,6 +1387,7 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( int ret; if (!fstat_is_reliable() || istate->split_index || + istate->fsmonitor_legacy_untracked_fallback || s->show_ignored_mode || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || istate->sparse_index != INDEX_EXPANDED || @@ -1582,20 +1585,23 @@ static int wt_status_close_ordinary_fsmonitor_token( struct index_state *istate = s->repo->index; struct clean_status_proof_epoch *scan_epoch = NULL; int reliable_stat = fstat_is_reliable(); + int validate_epoch = reliable_stat && + !istate->fsmonitor_legacy_untracked_fallback; /* * A pending token must close a refresh begun after its epoch was * captured. A refresh performed before entering token closure cannot * be validated by capturing its inputs afterward. */ - if (reliable_stat) { + if (validate_epoch) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, closure->use_bulk_provider, &closure->refresh_result); if (!scan_epoch) return 0; - } else if (!refreshed_before_closure) { + } else if (!refreshed_before_closure || + istate->fsmonitor_legacy_untracked_fallback) { closure->refresh_result |= refresh_index( istate, closure->refresh_flags, &s->pathspec, NULL, NULL); @@ -1616,7 +1622,7 @@ static int wt_status_close_ordinary_fsmonitor_token( while (closure->queries < FSMONITOR_TOKEN_MAX_QUERIES) { enum fsmonitor_token_result result; - if (reliable_stat && + if (validate_epoch && !clean_status_proof_epoch_start_token_matches( istate, scan_epoch)) break; @@ -1625,7 +1631,7 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, wt_status_untracked_cache_valid(closure)); if (result == FSMONITOR_TOKEN_CLEAN) { - if (reliable_stat && + if (validate_epoch && !clean_status_proof_epoch_matches( istate, scan_epoch)) { wt_status_reset_attr_snapshot_if_changed(s); @@ -1635,7 +1641,7 @@ static int wt_status_close_ordinary_fsmonitor_token( !closure->require_untracked) { if (preload_index_bulk_result_accept(istate) < 0) break; - if (reliable_stat) + if (validate_epoch) clean_status_mark_fsmonitor_config_valid( istate, istate->fsmonitor_last_update_pending); @@ -1661,7 +1667,7 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); if (wt_status_refresh_invalidated_manifest(s)) break; - if (reliable_stat) { + if (validate_epoch) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, closure->use_bulk_provider, @@ -1878,9 +1884,14 @@ static int wt_status_close_fsmonitor_token( closure.use_bulk_provider = wt_status_can_use_bulk_provider(s, refresh_flags); closure.untracked_ready = !istate->untracked || - !istate->untracked->root; + !istate->untracked->root || + (istate->fsmonitor_legacy_untracked_adopted && + istate->fsmonitor_untracked_valid && + istate->untracked->root->valid_recursive); closure.untracked_proof_complete = - !require_untracked || !istate->untracked; + !require_untracked || !istate->untracked || + (istate->fsmonitor_legacy_untracked_adopted && + closure.untracked_ready); if (require_untracked && !closure.can_prime && !closure.untracked_ready) BUG("cannot close required untracked scan"); From 8b4c582a740c7c00779bca35bf22c55f3cabc7d6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:25:59 -0500 Subject: [PATCH 248/403] t7527: guard invalidated external fsmonitor history --- t/t7527-builtin-fsmonitor.sh | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 304e020206b1bb..eea3e10d132656 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2192,6 +2192,48 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ + 'dirty stash cannot resurrect an invalidated external checkpoint' ' + test_when_finished "rm -rf stash-checkpoint-history" && + test_create_repo stash-checkpoint-history && + ( + cd stash-checkpoint-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --porcelain=v2 >.git/checkpoint && + test_must_be_empty .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git stash push >.git/stash && + test_grep "Saved working directory" .git/stash && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/actual && + test_grep "nothing to commit, working tree clean" .git/actual && + test_trace2_data fsmonitor history/external-proof-invalidated 1 \ + <.git/status.trace && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/status.trace && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <.git/status.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'mixed reset to a same-tree commit preserves closed history' ' test_when_finished "rm -rf reset-mixed-same-tree" && From 33ec97b12b655cf3c6dbd60c8a978de41d716291 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 5 Aug 2026 21:28:42 -0700 Subject: [PATCH 249/403] t7529: open the resume FIFO before publishing readiness The APFS bulk-preload race tests pause status until the test driver writes a byte to a resume FIFO. The child currently publishes its ready file before it opens the FIFO. If it is descheduled between those operations, the parent can observe readiness, write and close its descriptor, and discard the byte before a reader exists. Status then blocks forever in strbuf_read_file(), leaving a macOS CI job apparently hung. Open the resume FIFO first and read from that descriptor after publishing readiness. The parent opens the FIFO read/write before starting status, so the child open cannot block. Readiness now proves a reader is attached, and the resume byte cannot be lost. Signed-off-by: Taylor Blau --- preload-index-bulk.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/preload-index-bulk.c b/preload-index-bulk.c index 92ce36e8fe4850..6dd2d1e552ec56 100644 --- a/preload-index-bulk.c +++ b/preload-index-bulk.c @@ -45,6 +45,7 @@ int preload_bulk_test_barrier(struct preload_bulk_scan *scan, const char *path) { struct strbuf buf = STRBUF_INIT; + int fd; int result; if (!scan->test_barrier_path || @@ -53,9 +54,12 @@ int preload_bulk_test_barrier(struct preload_bulk_scan *scan, if (!scan->test_barrier_ready || !scan->test_barrier_resume) return -1; + fd = open(scan->test_barrier_resume, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; write_file(scan->test_barrier_ready, "ready"); - result = strbuf_read_file(&buf, scan->test_barrier_resume, 1) > 0 ? - 0 : -1; + result = strbuf_read(&buf, fd, 1) > 0 ? 0 : -1; + close(fd); strbuf_release(&buf); return result; } From e75df74b58178f2dec5850a34b7bc1b8f9fbe5e9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:43:46 -0500 Subject: [PATCH 250/403] t7527: disable split index for legacy daemon queries --- t/t7527-builtin-fsmonitor.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index eea3e10d132656..f8096ebc086426 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1567,6 +1567,7 @@ test_expect_success 'bound daemon also serves legacy token queries' ' test_create_repo legacy-client-query && ( cd legacy-client-query && + sane_unset GIT_TEST_SPLIT_INDEX && test_commit base tracked && git config core.preloadIndex false && git config core.untrackedCache true && From 4c3cb85feb1c99f379d42a1a58d6a4bf020ee3ed Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 10:44:40 -0500 Subject: [PATCH 251/403] t7527: allow a clean read-tree reset to skip refresh --- t/t7527-builtin-fsmonitor.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index f8096ebc086426..0ace97706f7d7d 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2441,8 +2441,6 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "nothing to commit, working tree clean" .git/actual && test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace && - test_trace2_data index refresh/sum_lstat 1 \ - <.git/status.trace && ! test_trace2_data status semantic_verify/prepared 1 \ <.git/status.trace ) From a26cb1928443dec384809d321df6b099d829ccb7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:11:23 -0500 Subject: [PATCH 252/403] t7527: cover scoped fsmonitor history reuse Exercise pathspec-scoped status with selected untracked files and dirt outside the selected directory. Ensure a repeat query retains semantic history without rescanning metadata, rewriting the index, or creating a root-wide clean proof. --- t/t7527-builtin-fsmonitor.sh | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 0ace97706f7d7d..8b61e773d2eb2e 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2235,6 +2235,48 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ + 'pathspec status preserves global history without hiding outside dirt' ' + test_when_finished "rm -rf pathspec-checkpoint-history" && + test_create_repo pathspec-checkpoint-history && + ( + cd pathspec-checkpoint-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + test_write_lines changed >tracked && + test_write_lines selected >scoped/new && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status --porcelain=v2 -- scoped >.git/first && + test_grep "^? scoped/new$" .git/first && + ! test_grep "tracked\|outside-new" .git/first && + test_path_is_missing .git/index.csts && + cp .git/index .git/before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 -- scoped >.git/second && + test_cmp .git/first .git/second && + test_cmp .git/before .git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/status.trace && + test_path_is_missing .git/index.csts && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/root && + test_grep "^1 \.M .* tracked$" .git/root + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'mixed reset to a same-tree commit preserves closed history' ' test_when_finished "rm -rf reset-mixed-same-tree" && From 2b09ca70f8556b7b55792c8f5d2c4344c5a0e2e5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:13:44 -0500 Subject: [PATCH 253/403] t7527: tolerate unpersisted fsmonitor-valid bits A clean path observed by status can remain uppercase in a subsequent ls-files invocation when external history avoids rewriting the main index. A late directory event can produce the same representation. Accept either fsmonitor marker while continuing to verify case-alias events and the final modified-path results. This removes a macOS CI failure without forcing an otherwise unnecessary index rewrite. --- t/t7527-builtin-fsmonitor.sh | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 8b61e773d2eb2e..af73c4939f9c9e 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1362,30 +1362,14 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep ! -q "fsmonitor_refresh_callback.*FILE-4-A.*pos" "$PWD/file_case_wrong-try2.log" && test_grep ! -q "fsmonitor_refresh_callback.*file-4-a.*pos" "$PWD/file_case_wrong-try2.log" && - # A late directory event can arrive without repeating the file - # events checked above. Such an event invalidates its entire cone, - # so those entries remain "H" until the next quiet refresh. + # A late directory event can invalidate the whole cone. External + # history can also retain refreshed fsmonitor bits without writing + # them back into the index, so either marker is valid here. git -C file_case_wrong ls-files -f >"$PWD/file_case_wrong-lsf2.out" && - if test_grep -E -q \ - "fsmonitor_refresh_callback .dir1(/dir2(/dir3)?)?/?. .*pos " \ - "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null - then - expected_3=H - else - expected_3=h - fi && - test_grep -q "$expected_3 dir1/dir2/dir3/file-3-a" \ + test_grep -E -q "^[Hh] dir1/dir2/dir3/file-3-a$" \ "$PWD/file_case_wrong-lsf2.out" && - if test_grep -E -q \ - "fsmonitor_refresh_callback .dir1(/dir2(/dir4)?)?/?. .*pos " \ - "$PWD/file_case_wrong-try2.log" >/dev/null 2>&1 4>/dev/null - then - expected_4=H - else - expected_4=h - fi && - test_grep -q "$expected_4 dir1/dir2/dir4/FILE-4-A" \ + test_grep -E -q "^[Hh] dir1/dir2/dir4/FILE-4-A$" \ "$PWD/file_case_wrong-lsf2.out" && From 95ddb66b5b13ec9e42fc278d102ebd4b0a48cba5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:59 -0500 Subject: [PATCH 254/403] t7527: cover closed scoped untracked-cache reuse Exercise repeated tracked-directory queries with root and nested working directories after builtin fsmonitor proves the selected untracked-cache subtree is closed. Require selected files to remain visible, outside files to stay hidden, and the index to stay untouched. Create and remove an untracked child after the initial root-wide cache population. Verify each scoped query reports the correct result, visits exactly one path, opens no directory, and leaves the subsequent ordinary root status clean. Include tracked root and scoped ignore files and reject a subsequent root-wide ignore invalidation. Spell the adjacent scoped-history assertion as the lint-approved negated test_grep invocation. --- t/t7527-builtin-fsmonitor.sh | 114 ++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index af73c4939f9c9e..29343deb8db68e 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2242,7 +2242,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ git status --porcelain=v2 -- scoped >.git/first && test_grep "^? scoped/new$" .git/first && - ! test_grep "tracked\|outside-new" .git/first && + test_grep ! "tracked\|outside-new" .git/first && test_path_is_missing .git/index.csts && cp .git/index .git/before && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ @@ -2261,6 +2261,118 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-directory pathspec reads a closed untracked-cache subtree' ' + test_when_finished "rm -rf pathspec-cached-subtree" && + test_create_repo pathspec-cached-subtree && + ( + cd pathspec-cached-subtree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines selected >scoped/new && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/root && + test_grep "^? scoped/new$" .git/root && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/root-repeat && + cp .git/index .git/before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/scoped.trace" \ + git status --porcelain=v2 -- scoped >.git/scoped && + test_grep "^? scoped/new$" .git/scoped && + test_grep ! "outside-new" .git/scoped && + test_cmp .git/before .git/index && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/scoped.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/scoped.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested.trace" \ + git -C scoped status --porcelain=v2 -- . >.git/nested && + test_grep "^? new$" .git/nested && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/nested.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-directory pathspec repairs changed untracked children' ' + test_when_finished "rm -rf pathspec-repaired-subtree" && + test_create_repo pathspec-repaired-subtree && + ( + cd pathspec-repaired-subtree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped outside && + test_commit selected scoped/tracked && + test_commit unrelated outside/tracked && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "ignored-dir/" >scoped/.gitignore && + git add .gitignore scoped/.gitignore && + git commit -qm "add tracked ignore files" && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + test_write_lines created >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/created.trace" \ + git status --porcelain=v2 -- scoped >.git/created && + test_grep "^? scoped/new$" .git/created && + test_trace2_data status untracked/pathspec-refreshed 1 \ + <.git/created.trace && + test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + <.git/created.trace && + test_trace2_data read_directory paths-visited 1 \ + <.git/created.trace && + test_trace2_data read_directory opendir 0 \ + <.git/created.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/created.trace && + + rm scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/removed.trace" \ + git status --porcelain=v2 -- scoped >.git/removed && + test_must_be_empty .git/removed && + test_trace2_data status untracked/pathspec-refreshed 1 \ + <.git/removed.trace && + test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + <.git/removed.trace && + test_trace2_data read_directory paths-visited 1 \ + <.git/removed.trace && + test_trace2_data read_directory opendir 0 \ + <.git/removed.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/removed.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/root-after-remove.trace" \ + git status --porcelain=v2 >.git/root-after-remove && + test_must_be_empty .git/root-after-remove && + test_grep ! "\"key\":\"gitignore-invalidation\",\"value\":\"[1-9]" \ + .git/root-after-remove.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'mixed reset to a same-tree commit preserves closed history' ' test_when_finished "rm -rf reset-mixed-same-tree" && From 7f6a3199a16d639cc0408f7d8a178c56e27b9826 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 7 Aug 2026 01:00:24 -0500 Subject: [PATCH 255/403] diff: honor --no-optional-locks when refreshing the index `git diff` hides a stat-only mismatch when the working-tree contents still match the index, then refreshes and writes the index after producing its result. This write is opportunistic: failure to take the lock is already ignored. 27344d6a6c (git: add --no-optional-locks option, 2017-09-27) made background callers able to suppress optional lock-taking work and called out this refresh as a possible future user. But refresh_index_quietly() never consulted use_optional_locks(), so `git --no-optional-locks diff` still took the index lock and rewrote the index for a stat-only match. Return before taking the lock when optional locks are disabled. The diff result has already been computed at this point, so the only effect is to leave the refreshed stat data unpersisted, matching the documented tradeoff of the option. Add a regression that checks the index mtime stays put under --no-optional-locks while an ordinary `git diff` still writes the refresh. --- builtin/diff.c | 4 ++++ t/t7508-status.sh | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/builtin/diff.c b/builtin/diff.c index 18b1083e984a35..c597935957c74e 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -13,6 +13,7 @@ #include "lockfile.h" #include "color.h" #include "commit.h" +#include "environment.h" #include "gettext.h" #include "tag.h" #include "diff.h" @@ -239,6 +240,9 @@ static void refresh_index_quietly(void) struct lock_file lock_file = LOCK_INIT; int fd; + if (!use_optional_locks()) + return; + fd = repo_hold_locked_index(the_repository, &lock_file, 0); if (fd < 0) return; diff --git a/t/t7508-status.sh b/t/t7508-status.sh index 8059c64940f165..beb84cbf3d657c 100755 --- a/t/t7508-status.sh +++ b/t/t7508-status.sh @@ -1681,6 +1681,23 @@ test_expect_success '--no-optional-locks prevents index update' ' ! test_is_magic_mtime .git/index ' +test_expect_success '--no-optional-locks prevents diff index update' ' + test_when_finished "rm -rf optional-locks-diff" && + test_create_repo optional-locks-diff && + ( + cd optional-locks-diff && + test_commit base tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + git --no-optional-locks diff -- tracked >actual && + test_must_be_empty actual && + test_is_magic_mtime .git/index +1 && + git diff -- tracked >actual && + test_must_be_empty actual && + ! test_is_magic_mtime .git/index +1 + ) +' + test_expect_success 'racy timestamps will be fixed for clean worktree' ' echo content >racy-dirty && echo content >racy-racy && From 12d352edce261a695e59aa9b262ac9b937e758d0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:30:24 -0500 Subject: [PATCH 256/403] describe: honor optional locks for dirty-worktree checks A dirty-worktree check refreshes cached stat information before comparing the index with HEAD. Persisting that refresh is opportunistic, but both describe --dirty implementations currently rewrite the index even when optional locks are disabled. Keep the in-process refresh for accurate dirty detection, but avoid taking the index lock when it is optional. For --broken, retain child-process isolation and use the non-refreshing diff path instead of invoking update-index. Cover clean stat mismatches, actual modifications, broken submodules, and the ordinary mode that still persists refreshed stat information. --- builtin/describe.c | 36 ++++++++++++++++++++++------------ t/t6120-describe.sh | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/builtin/describe.c b/builtin/describe.c index b39df0937ecd14..8e216206bcc19f 100644 --- a/builtin/describe.c +++ b/builtin/describe.c @@ -741,14 +741,23 @@ int cmd_describe(int argc, if (broken) { struct child_process cp = CHILD_PROCESS_INIT; - strvec_pushv(&cp.args, update_index_args); - cp.git_cmd = 1; - cp.no_stdin = 1; - cp.no_stdout = 1; - run_command(&cp); - - child_process_init(&cp); - strvec_pushv(&cp.args, diff_index_args); + if (use_optional_locks()) { + strvec_pushv(&cp.args, update_index_args); + cp.git_cmd = 1; + cp.no_stdin = 1; + cp.no_stdout = 1; + run_command(&cp); + + child_process_init(&cp); + strvec_pushv(&cp.args, diff_index_args); + } else { + strvec_pushl(&cp.args, "-c", + "diff.autoRefreshIndex=true", + "diff", "--quiet", + "--no-ext-diff", "--no-textconv", + "--ignore-submodules=untracked", + "HEAD", "--", NULL); + } cp.git_cmd = 1; cp.no_stdin = 1; cp.no_stdout = 1; @@ -784,10 +793,13 @@ int cmd_describe(int argc, repo_read_index(the_repository); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED, NULL, NULL, NULL); - fd = repo_hold_locked_index(the_repository, - &index_lock, 0); - if (0 <= fd) - repo_update_index_if_able(the_repository, &index_lock); + if (use_optional_locks()) { + fd = repo_hold_locked_index(the_repository, + &index_lock, 0); + if (0 <= fd) + repo_update_index_if_able(the_repository, + &index_lock); + } repo_init_revisions(the_repository, &revs, prefix); diff --git a/t/t6120-describe.sh b/t/t6120-describe.sh index 7a7c46658a3a81..77a14b73a638f8 100755 --- a/t/t6120-describe.sh +++ b/t/t6120-describe.sh @@ -392,6 +392,19 @@ test_expect_success 'setup and absorb a submodule' ' test_cmp expect out ' +test_expect_success 'describe --broken ignores diff submodule presentation settings' ' + test_when_finished "git -C sub1 checkout -- initial.t && rm -f sub1/untracked" && + test_config diff.ignoreSubmodules all && + test_write_lines untracked >sub1/untracked && + git --no-optional-locks describe --dirty --broken >out && + test_grep ! ".*-dirty$" out && + test_write_lines changed >sub1/initial.t && + test_set_magic_mtime .git/index && + git --no-optional-locks describe --dirty --broken >out && + test_grep ".*-dirty$" out && + test_is_magic_mtime .git/index +' + test_expect_success 'describe chokes on severely broken submodules' ' mv .git/modules/sub1/ .git/modules/sub_moved && test_must_fail git describe --dirty @@ -402,6 +415,13 @@ test_expect_success 'describe ignoring a broken submodule' ' test_grep broken out ' +test_expect_success 'describe --broken honors --no-optional-locks' ' + test_set_magic_mtime .git/index && + git --no-optional-locks describe --broken >out && + test_grep broken out && + test_is_magic_mtime .git/index +' + test_expect_success 'describe with --work-tree ignoring a broken submodule' ' ( cd "$TEST_DIRECTORY" && @@ -791,6 +811,34 @@ test_expect_success 'describe --broken --dirty with a file with changed stat' ' ) ' +for broken in '' '--broken' +do + test_expect_success "describe --dirty $broken honors --no-optional-locks" ' + test_when_finished "rm -fr describe-optional-locks" && + git init describe-optional-locks && + ( + cd describe-optional-locks && + test_commit --annotate base tracked && + git config diff.autoRefreshIndex false && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + git --no-optional-locks describe --dirty $broken >actual && + test_grep "^base$" actual && + test_is_magic_mtime .git/index +1 && + test_write_lines changed >tracked && + git --no-optional-locks describe --dirty $broken >actual && + test_grep "^base-dirty$" actual && + test_is_magic_mtime .git/index +1 && + git checkout -- tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + git describe --dirty $broken >actual && + test_grep "^base$" actual && + ! test_is_magic_mtime .git/index +1 + ) + ' +done + test_expect_success '--always with no refs falls back to commit hash' ' git rev-parse HEAD >expect && git describe --no-abbrev --always --match=no-such-tag >actual && From 13c8406b3097bb920ce77b8fceb1bb1be006617c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:24 -0500 Subject: [PATCH 257/403] stash: avoid rewriting the index when nothing can be saved A stash push currently refreshes and writes the index before checking whether the requested paths contain any changes. Even a no-op stash therefore replaces the physical index, invalidates its clean-status proof, and can make the next status scan the entire worktree. Take the existing index lock and perform the refresh in memory, then check for changes while the lock is held. Roll the lock back when there is nothing to stash, including stat-only mismatches; publish the refreshed index before continuing only when a real stash will be created. Preserve locked-index and unmerged-index failures, cover ordinary and optional-lock no-op sequences, and assert that fsmonitor history remains valid without an index write. --- builtin/stash.c | 22 ++++++++++------ t/t3903-stash.sh | 49 ++++++++++++++++++++++++++++++++++++ t/t7527-builtin-fsmonitor.sh | 4 +++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 4fd7ec0c6258ad..6458ca7f9d91f8 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1677,6 +1677,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q { int ret = 0; int preserve_clean_history = !ps->nr && !include_untracked; + struct lock_file index_lock = LOCK_INIT; struct stash_info info = STASH_INFO_INIT; struct strbuf patch = STRBUF_INIT; struct strbuf stash_msg_buf = STRBUF_INIT; @@ -1705,11 +1706,9 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q } /* - * A clean stash push returns after its initial stat refresh. Keep - * that rewrite bound only for whole-worktree forms; paths and - * untracked discovery can change the index or its status inputs. - * If changes are found below, invalidate before the real stash - * machinery mutates the index or worktree. + * Keep whole-worktree history bound while inspecting the worktree. + * If changes are found, invalidate it before stash machinery + * mutates the index or worktree. */ if (preserve_clean_history) clean_status_set_config_digest(the_repository, @@ -1733,17 +1732,25 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q free(ps_matched); } - if (repo_refresh_and_write_index(the_repository, REFRESH_QUIET, 0, 0, - NULL, NULL, NULL)) { + if (repo_hold_locked_index(the_repository, &index_lock, + LOCK_REPORT_ON_ERROR) < 0 || + refresh_index(the_repository->index, REFRESH_QUIET, + NULL, NULL, NULL)) { ret = error(_("could not write index")); goto done; } if (!check_changes(ps, include_untracked, &untracked_files)) { + rollback_lock_file(&index_lock); if (!quiet) printf_ln(_("No local changes to save")); goto done; } + if (write_locked_index(the_repository->index, &index_lock, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) { + ret = error(_("could not write index")); + goto done; + } if (preserve_clean_history) clean_status_invalidate_current_proof(the_repository->index); @@ -1910,6 +1917,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q } done: + rollback_lock_file(&index_lock); strbuf_release(&patch); strbuf_release(&out); free_stash_info(&info); diff --git a/t/t3903-stash.sh b/t/t3903-stash.sh index da27a6599a6a79..8ac681e41df64a 100755 --- a/t/t3903-stash.sh +++ b/t/t3903-stash.sh @@ -1290,6 +1290,33 @@ test_expect_success 'push : show no changes when there are none' ' test_cmp expect actual ' +test_expect_success 'clean stash push does not rewrite an unchanged index' ' + test_when_finished "rm -rf clean-stash-index" && + test_create_repo clean-stash-index && + ( + cd clean-stash-index && + test_commit base tracked && + test_set_magic_mtime .git/index +1 && + git stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + git --no-optional-locks stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + test_set_magic_mtime tracked && + git --no-optional-locks stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + git stash push >actual && + test_grep "No local changes to save" actual && + test_is_magic_mtime .git/index +1 && + test_write_lines changed >tracked && + git stash push >actual && + test_grep "Saved working directory" actual && + ! test_is_magic_mtime .git/index +1 + ) +' + test_expect_success 'push: not in the repository errors out' ' >untracked && test_must_fail git stash push untracked && @@ -1697,6 +1724,28 @@ test_expect_success 'stash push reports a locked index' ' ) ' +test_expect_success 'stash push rolls back its lock for an unmerged index' ' + test_when_finished "rm -rf stash-unmerged-lock" && + test_create_repo stash-unmerged-lock && + ( + cd stash-unmerged-lock && + test_commit base tracked && + git checkout -b side && + test_write_lines side >tracked && + git commit -am side && + git checkout - && + test_write_lines main >tracked && + git commit -am main && + test_must_fail git merge side && + test_must_fail git stash push >actual 2>err && + test_grep "needs merge" actual && + test_grep "could not write index" err && + test_path_is_missing .git/index.lock && + git ls-files --unmerged >stages && + test_line_count = 3 stages + ) +' + test_expect_success 'stash apply reports a locked index' ' test_when_finished "rm -rf repo" && git init repo && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 29343deb8db68e..c68f14443f3cdb 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2132,9 +2132,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty .git/prime && test_grep FSCF .git/index && + cp .git/index .git/index.before && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ git stash push >.git/stash && test_grep "No local changes to save" .git/stash && + test_cmp .git/index.before .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/stash.trace && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status >.git/actual && From 614c40f88f155eeb0b1bc5f1efe212b5f71a3253 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:31 -0500 Subject: [PATCH 258/403] status: reuse clean proofs across equivalent query shapes A clean-status sidecar certifies that the complete worktree has no tracked or visible untracked changes. That fact does not depend on status formatting, the current subdirectory, literal pathspecs, branch headers, stash headers, or whether untracked entries would be displayed. Replace separate exact, normal, and scoped consumption predicates with one conservative eligibility check, then use the existing live status printer for every supported clean query. Keep proof issuance restricted to the existing exact and normal cases, and continue rejecting ignored output, verbose output, submodule summaries, sparse checkouts, unborn HEADs, and changed proof inputs. Record the supported command sequences with output-oracle comparisons and Trace2 assertions that no index read, refresh, preload, or directory traversal occurs. Exercise live branch, stash, merge, rebase, configuration, prefix, and pathspec changes alongside fail-closed controls. --- builtin/commit.c | 47 ++--- t/t7530-status-clean-sidecar.sh | 309 +++++++++++++++++++++++++++++++- 2 files changed, 334 insertions(+), 22 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index b27ac2e201180c..fc0e043ac109f8 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -36,6 +36,7 @@ #include "refs.h" #include "repository.h" #include "string-list.h" +#include "submodule.h" #include "rerere.h" #include "unpack-trees.h" #include "column.h" @@ -1606,12 +1607,11 @@ static int git_status_config(const char *k, const char *v, /* * A clean-proof hit certifies the tracked and untracked lists, but it - * deliberately does not cache human-readable status output. Refresh the - * cheap state which the long printer derives from refs and administrative - * files before printing those empty lists. + * deliberately does not cache status output. Refresh the cheap state which + * the selected printer derives from refs and administrative files before + * printing those empty lists. */ -static int print_normal_clean_sidecar(struct wt_status *s, - const char *prefix) +static int print_clean_sidecar(struct wt_status *s, const char *prefix) { struct object_id oid; @@ -1621,7 +1621,8 @@ static int print_normal_clean_sidecar(struct wt_status *s, oidcpy(&s->oid_commit, &oid); s->ignore_submodule_arg = ignore_submodule_arg; s->status_format = status_format; - s->verbose = verbose; + /* A globally clean proof guarantees that both verbose diffs are empty. */ + s->verbose = 0; FREE_AND_NULL(s->branch); s->branch = refs_resolve_refdup(get_main_ref_store(s->repo), "HEAD", 0, NULL, NULL); @@ -1651,7 +1652,7 @@ struct repository *repo UNUSED) !strcmp(argv[1], "--porcelain=v2") && (!prefix || !*prefix); int exact_clean_query; int normal_clean_query; - int scoped_clean_query; + int reusable_clean_query; int normal_has_head; struct object_id oid; static struct option builtin_status_options[] = { @@ -1727,6 +1728,7 @@ struct repository *repo UNUSED) handle_untracked_files_arg(&s); handle_ignored_arg(&s); + s.ignore_submodule_arg = ignore_submodule_arg; if (s.show_ignored_mode == SHOW_MATCHING_IGNORED && s.show_untracked_files == SHOW_NO_UNTRACKED_FILES) @@ -1735,10 +1737,12 @@ struct repository *repo UNUSED) parse_pathspec(&s.pathspec, 0, PATHSPEC_PREFER_FULL, prefix, argv); - s.allow_clean_status_shortcuts = - default_status_command && !s.pathspec.nr; - normal_has_head = default_status_command && - !repo_get_oid(the_repository, s.reference, &oid); + if (s.ignore_submodule_arg) { + struct diff_options diffopt = { 0 }; + + handle_ignore_submodules_arg(&diffopt, s.ignore_submodule_arg); + } + normal_has_head = !repo_get_oid(the_repository, s.reference, &oid); exact_clean_query = exact_clean_command && status_format == STATUS_FORMAT_PORCELAIN_V2 && !s.pathspec.nr && !s.show_branch && !s.show_stash && @@ -1751,19 +1755,21 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; - scoped_clean_query = s.pathspec.nr && - status_format == STATUS_FORMAT_NONE && - !s.show_branch && !s.show_stash && !s.show_ignored_mode && - !s.null_termination && !s.verbose && !s.submodule_summary && - s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && - !repo_config_values(the_repository)->apply_sparse_checkout && - !repo_get_oid(the_repository, s.reference, &oid); + reusable_clean_query = normal_has_head && + !s.show_ignored_mode && !s.submodule_summary && + /* A clean merge still prints a staged-changes header with -vv. */ + !(verbose > 1 && + file_exists(git_path_merge_head(the_repository))) && + !repo_config_values(the_repository)->apply_sparse_checkout; + s.allow_clean_status_shortcuts = normal_has_head && + !s.submodule_summary && + !repo_config_values(the_repository)->apply_sparse_checkout; clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; - if ((exact_clean_query || normal_clean_query || scoped_clean_query) && + if (reusable_clean_query && clean_status_try_sidecar(the_repository, &clean_digest)) { if (exact_clean_query || - print_normal_clean_sidecar(&s, prefix)) { + print_clean_sidecar(&s, prefix)) { wt_status_collect_free_buffers(&s); return 0; } @@ -1803,7 +1809,6 @@ struct repository *repo UNUSED) if (!s.is_initial) oidcpy(&s.oid_commit, &oid); - s.ignore_submodule_arg = ignore_submodule_arg; s.status_format = status_format; s.verbose = verbose; if (no_renames != -1) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 9a59a18ec4f3e6..eddcc3e8d08f51 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -70,6 +70,63 @@ issue_sidecar () { test_path_is_file "$repo/.git/index.csts" } +assert_clean_sidecar_result () { + sidecar_result=$1 && + sidecar_repo=$2 && + sidecar_cwd=$3 && + sidecar_label=$4 && + shift 4 && + cp "$sidecar_repo/.git/index" "$sidecar_label.index" && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -C "$sidecar_cwd" \ + status "$@" >"$sidecar_label.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/$sidecar_label.trace" \ + git -C "$sidecar_cwd" status "$@" \ + >"$sidecar_label.actual" && + test_cmp_bin "$sidecar_label.expect" "$sidecar_label.actual" && + test_cmp_bin "$sidecar_label.index" "$sidecar_repo/.git/index" || + return 1 + + if test "$sidecar_result" = hit + then + test_trace2_data status clean-proof/hit 1 \ + <"$sidecar_label.trace" && + test_grep ! "\"label\":\"do_read_index\"" \ + "$sidecar_label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + "$sidecar_label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"preload" \ + "$sidecar_label.trace" && + test_grep ! "\"label\":\"read_directory\"" \ + "$sidecar_label.trace" + else + test_grep ! "\"key\":\"clean-proof/hit\"" \ + "$sidecar_label.trace" + fi +} + +assert_clean_sidecar_hit () { + assert_clean_sidecar_result hit "$@" +} + +assert_clean_sidecar_fallback () { + assert_clean_sidecar_result fallback "$@" +} + +assert_tracked_clean_fallback () { + tracked_trace=$3.trace && + assert_clean_sidecar_fallback "$@" && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <"$tracked_trace" && + test_trace2_data status index/cache-tree-match 1 \ + <"$tracked_trace" && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + "$tracked_trace" && + test_grep ! "\"category\":\"index\",\"label\":\"preload" \ + "$tracked_trace" +} + assert_fallback_matches_oracle () { repo=$1 && sidecar_trace=$2 && @@ -228,6 +285,252 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'a clean sidecar serves every index-independent status shape' ' + shapes=sidecar-query-shapes && + test_when_finished "stop_daemon $shapes" && + setup_repo "$shapes" && + mkdir "$shapes/scoped" && + test_commit -C "$shapes" scoped scoped/tracked && + test_write_lines "*.ignored" >"$shapes/.gitignore" && + git -C "$shapes" add .gitignore && + git -C "$shapes" commit -qm ignores && + git -C "$shapes" branch sidecar-upstream && + git -C "$shapes" branch --set-upstream-to=sidecar-upstream && + git -C "$shapes" commit --allow-empty -qm ahead && + test_write_lines stashed >"$shapes/tracked" && + git -C "$shapes" stash push -qm sidecar-stash && + test-tool -C "$shapes" chmtime -120 \ + tracked scoped/tracked .gitignore && + git -C "$shapes" update-index --refresh && + test_write_lines ignored >"$shapes/root.ignored" && + test_write_lines ignored >"$shapes/scoped/nested.ignored" && + git -C "$shapes" config core.untrackedCache true && + issue_sidecar "$shapes" && + + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-default && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-long --long && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose --verbose && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-twice -vv && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-long --verbose --long && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-short --verbose --short && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-v2 --verbose --porcelain=v2 && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-null --verbose -z && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-branch --verbose --branch && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-stash --verbose --show-stash && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-verbose-scoped --verbose -- scoped && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" \ + sidecar-verbose-nested --verbose && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-short --short && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-porcelain --porcelain && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-porcelain-v1 --porcelain=v1 && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-porcelain-v2 --porcelain=v2 && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-null -z && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-short-branch --short --branch && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-v1-branch-null \ + --porcelain=v1 --branch -z && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-v2-branch \ + --porcelain=v2 --branch && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-stash --show-stash && + test_grep "Your stash currently has 1 entry" sidecar-stash.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-v2-stash \ + --porcelain=v2 --show-stash && + test_grep "^# stash 1$" sidecar-v2-stash.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-daemon \ + --porcelain=v2 -z --branch --show-stash \ + --no-ahead-behind --untracked-files=normal \ + --ignore-submodules=all && + for ignore_mode in all dirty untracked none + do + assert_clean_sidecar_hit "$shapes" "$shapes" \ + "sidecar-ignore-$ignore_mode" \ + "--ignore-submodules=$ignore_mode" || return 1 + done && + test_must_fail git -C "$shapes" status \ + --ignore-submodules=bogus >sidecar-invalid-ignore.out \ + 2>sidecar-invalid-ignore.err && + test_must_be_empty sidecar-invalid-ignore.out && + test_grep "bad --ignore-submodules argument: bogus" \ + sidecar-invalid-ignore.err && + test_must_fail git -C "$shapes" status \ + --ignore-submodules=bogus -- ":(bogus)tracked" \ + >sidecar-invalid-order.out 2>sidecar-invalid-order.err && + test_must_be_empty sidecar-invalid-order.out && + test_grep "Invalid pathspec magic.*bogus" \ + sidecar-invalid-order.err && + test_grep ! "bad --ignore-submodules argument" \ + sidecar-invalid-order.err && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-untracked-no -uno && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-untracked-all -uall && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-no-renames --no-renames && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-find-renames --find-renames=50% && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-scoped -- scoped && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-scoped-slash -- scoped/ && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-scoped-file -- scoped/tracked && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-multiple -- tracked scoped && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-scoped-v2 \ + --porcelain=v2 -- scoped && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" sidecar-nested && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" sidecar-nested-v2 \ + --porcelain=v2 -- tracked && + assert_clean_sidecar_hit "$shapes" "$shapes/scoped" sidecar-nested-root \ + --porcelain=v1 -- ":(top)tracked" && + + stash_oid=$(git -C "$shapes" rev-parse refs/stash) && + git -C "$shapes" stash drop -q && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-stash-dropped --show-stash && + test_grep ! "Your stash currently has" \ + sidecar-stash-dropped.actual && + git -C "$shapes" stash store -m restored "$stash_oid" && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-stash-restored \ + --porcelain=v2 --show-stash && + test_grep "^# stash 1$" sidecar-stash-restored.actual && + + current_ref=$(git -C "$shapes" symbolic-ref HEAD) && + git -C "$shapes" branch sidecar-live HEAD && + git -C "$shapes" symbolic-ref HEAD refs/heads/sidecar-live && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-branch-moved \ + --porcelain=v2 --branch && + test_grep "^# branch.head sidecar-live$" \ + sidecar-branch-moved.actual && + git -C "$shapes" symbolic-ref HEAD "$current_ref" && + + git -C "$shapes" rev-parse HEAD >"$shapes/.git/MERGE_HEAD" && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-merge --long && + test_grep "All conflicts fixed but you are still merging" \ + sidecar-merge.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-merge-verbose --verbose && + assert_clean_sidecar_fallback "$shapes" "$shapes" \ + sidecar-merge-verbose-twice -vv && + test_grep "Changes to be committed:" \ + sidecar-merge-verbose-twice.actual && + rm "$shapes/.git/MERGE_HEAD" && + mkdir "$shapes/.git/rebase-merge" && + git -C "$shapes" symbolic-ref HEAD \ + >"$shapes/.git/rebase-merge/head-name" && + git -C "$shapes" rev-parse HEAD \ + >"$shapes/.git/rebase-merge/onto" && + assert_clean_sidecar_hit "$shapes" "$shapes" sidecar-rebase --long && + test_grep "You are currently rebasing" sidecar-rebase.actual && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-rebase-verbose --verbose && + assert_clean_sidecar_hit "$shapes" "$shapes" \ + sidecar-rebase-verbose-twice -vv && + rm -rf "$shapes/.git/rebase-merge" +' + +test_expect_success DURABLE_FSMONITOR \ + 'a clean sidecar respects configured short and branch output' ' + test_when_finished "stop_daemon sidecar-configured-shapes" && + setup_repo sidecar-configured-shapes && + git -C sidecar-configured-shapes config status.short true && + git -C sidecar-configured-shapes config status.branch true && + issue_sidecar sidecar-configured-shapes && + assert_clean_sidecar_hit sidecar-configured-shapes \ + sidecar-configured-shapes sidecar-configured-short && + test_grep "^## " sidecar-configured-short.actual && + assert_clean_sidecar_hit sidecar-configured-shapes \ + sidecar-configured-shapes sidecar-configured-long \ + --no-short --no-branch && + assert_clean_sidecar_hit sidecar-configured-shapes \ + sidecar-configured-shapes sidecar-configured-v2 \ + --porcelain=v2 --branch +' + +test_expect_success DURABLE_FSMONITOR \ + 'a clean sidecar never answers unsupported or dirty status shapes' ' + test_when_finished "stop_daemon sidecar-unsafe-shapes" && + setup_repo sidecar-unsafe-shapes && + mkdir sidecar-unsafe-shapes/scoped && + test_commit -C sidecar-unsafe-shapes scoped scoped/tracked && + test_write_lines "*.ignored" >sidecar-unsafe-shapes/.gitignore && + git -C sidecar-unsafe-shapes add .gitignore && + git -C sidecar-unsafe-shapes commit -qm ignores && + test-tool -C sidecar-unsafe-shapes chmtime -120 \ + tracked scoped/tracked .gitignore && + git -C sidecar-unsafe-shapes update-index --refresh && + test_write_lines ignored >sidecar-unsafe-shapes/root.ignored && + test_write_lines ignored \ + >sidecar-unsafe-shapes/scoped/nested.ignored && + git -C sidecar-unsafe-shapes config core.untrackedCache true && + issue_sidecar sidecar-unsafe-shapes && + + assert_tracked_clean_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-ignored --ignored && + test_grep "root.ignored" sidecar-ignored.actual && + test_grep "\"label\":\"read_directory\"" sidecar-ignored.trace && + assert_tracked_clean_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-ignored-matching \ + --ignored=matching && + assert_tracked_clean_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-ignored-scoped \ + --ignored -- scoped && + test_grep "scoped/nested.ignored" sidecar-ignored-scoped.actual && + assert_clean_sidecar_hit sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-verbose-clean --verbose && + + git -C sidecar-unsafe-shapes config core.sparseCheckout true && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-sparse --long && + git -C sidecar-unsafe-shapes config --unset core.sparseCheckout && + current_ref=$(git -C sidecar-unsafe-shapes symbolic-ref HEAD) && + git -C sidecar-unsafe-shapes symbolic-ref \ + HEAD refs/heads/sidecar-unborn && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-unborn \ + --porcelain=v2 --branch && + test_grep "^# branch.oid (initial)$" sidecar-unborn.actual && + git -C sidecar-unsafe-shapes symbolic-ref HEAD "$current_ref" && + + test_write_lines changed >sidecar-unsafe-shapes/tracked && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-dirty-verbose --verbose && + test_grep "tracked" sidecar-dirty-verbose.actual && + test_grep "\"category\":\"diff\"" \ + sidecar-dirty-verbose.trace && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-dirty --porcelain=v2 && + test_grep "^1 \.M .* tracked$" sidecar-dirty.actual && + assert_clean_sidecar_fallback sidecar-unsafe-shapes \ + sidecar-unsafe-shapes sidecar-dirty-outside \ + --porcelain=v2 -- scoped && + test_must_be_empty sidecar-dirty-outside.actual +' + +test_expect_success DURABLE_FSMONITOR \ + 'submodule summaries reject an otherwise valid clean sidecar' ' + test_when_finished "stop_daemon sidecar-submodule-summary" && + setup_repo sidecar-submodule-summary && + git -C sidecar-submodule-summary \ + config status.submoduleSummary true && + issue_sidecar sidecar-submodule-summary && + assert_clean_sidecar_fallback sidecar-submodule-summary \ + sidecar-submodule-summary sidecar-summary --long +' + test_expect_success DURABLE_FSMONITOR \ 'dirty exact status checkpoints history without certifying cleanliness' ' test_when_finished "stop_daemon external-dirty-exact" && @@ -671,8 +974,12 @@ test_expect_success DURABLE_FSMONITOR \ test_must_be_empty actual && test_path_is_missing sidecar-shape/.git/index.csts && - bulk_status -C sidecar-shape status --porcelain=v2 --branch >actual && + test_env GIT_TRACE2_EVENT="$PWD/shape-branch.trace" \ + bulk_status -C sidecar-shape \ + status --porcelain=v2 --branch >actual && test_grep "^# branch.oid " actual && + test_grep ! "\"key\":\"clean-proof/sidecar\"" \ + shape-branch.trace && test_path_is_missing sidecar-shape/.git/index.csts && echo changed >sidecar-shape/tracked && From 3d7c419293fe69afbbad3e4cf6d379cd94d8eb40 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:37 -0500 Subject: [PATCH 259/403] status: preserve semantic history across scoped and index changes An otherwise clean worktree containing untracked files cannot use the whole-worktree clean sidecar. Status still has a closed fsmonitor token and individually certified tracked entries, but previously reused that state only for unqualified top-level status. Allow the existing tracked-clean and cache-tree shortcuts for safely supported query shapes, including pathspecs, machine-readable formats, branch and stash headers, and nested working directories. Preserve the provider, semantic, token, expanded-index, and tracked-entry checks. Staging, unstaging, or switching branches also invalidated the semantic proof whenever the logical index changed. Preserve it when the affected entries cannot change attribute or ignore rules. Verify new directory ancestors with anchored, no-follow attribute probes, and reject filters, sparse indexes, resolve-undo state, and changed semantic sources. For ordinary branch switches, transfer semantic history only after each changed entry passes those same checks. Keep modified worktree entries fsmonitor-invalid and clean sidecars bound to the new logical index. Cover scoped queries, staging, unstaging, newly indexed directories, branch switches, and attribute changes with provider regressions. --- attr-fingerprint.c | 38 +- attr-fingerprint.h | 1 + builtin/checkout.c | 13 +- builtin/commit.c | 37 +- builtin/fsmonitor--daemon.c | 10 +- builtin/reset.c | 16 +- clean-status-history-store.c | 74 ++ clean-status-history-store.h | 3 + clean-status-history.c | 494 ++++++++- clean-status-internal.h | 3 + clean-status-sidecar-issue.c | 5 +- clean-status.c | 263 +++++ clean-status.h | 14 + compat/fsmonitor/fsm-listen-darwin.c | 20 + dir.c | 118 ++- dir.h | 3 +- fsmonitor-ipc.c | 87 +- fsmonitor-ipc.h | 2 + fsmonitor.c | 8 +- read-cache-ll.h | 1 + read-cache.c | 17 +- t/helper/test-simple-ipc.c | 22 +- t/t7519-status-fsmonitor.sh | 410 +++++++- t/t7527-builtin-fsmonitor.sh | 1443 +++++++++++++++++++++++++- t/t7530-status-clean-sidecar.sh | 45 + t/unit-tests/u-attr-fingerprint.c | 118 +++ t/unit-tests/u-clean-status-config.c | 2 + unpack-trees.c | 130 ++- unpack-trees.h | 3 +- wt-status.c | 360 ++++++- wt-status.h | 1 + 31 files changed, 3669 insertions(+), 92 deletions(-) diff --git a/attr-fingerprint.c b/attr-fingerprint.c index d0152d6fe23963..f614464f553331 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -34,6 +34,7 @@ static int open_attr_source(const char *path) static int hash_source(struct git_hash_ctx *content_ctx, struct git_hash_ctx *namespace_ctx, + struct git_hash_ctx *portable_namespace_ctx, const struct attr_fingerprint_source *source, int *present, struct attr_source_snapshot_entry *snapshot) @@ -49,14 +50,20 @@ static int hash_source(struct git_hash_ctx *content_ctx, int fd = -1, ret = -1; char extra; - hash_optional_cstring(content_ctx, source->path); hash_optional_cstring(namespace_ctx, source->path); put_be32(&state, source->enabled); - hash_length_delimited(content_ctx, &state, sizeof(state)); hash_length_delimited(namespace_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, sizeof(state)); *present = 0; - if (!source->enabled || !source->path) + if (!source->enabled || !source->path) { + hash_optional_cstring(content_ctx, NULL); + hash_length_delimited(content_ctx, &state, sizeof(state)); + state = 0; + hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, + sizeof(state)); return 0; + } absolute = absolute_pathdup(source->path); strbuf_addstr(&normalized, absolute); @@ -64,12 +71,18 @@ static int hash_source(struct git_hash_ctx *content_ctx, path_namespace_capture(normalized.buf, &before)) goto done; *present = path_namespace_target_present(before); + hash_optional_cstring(content_ctx, + *present ? source->path : NULL); + put_be32(&state, source->enabled); + hash_length_delimited(content_ctx, &state, sizeof(state)); if (!*present) { if (path_namespace_capture(normalized.buf, &after) || !path_namespace_equal(before, after)) goto done; state = 0; hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, + sizeof(state)); path_namespace_hash(namespace_ctx, before); ret = 0; goto done; @@ -93,9 +106,15 @@ static int hash_source(struct git_hash_ctx *content_ctx, goto done; state = 1; hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, + sizeof(state)); + hash_optional_cstring(portable_namespace_ctx, source->path); path_namespace_hash(namespace_ctx, before); + path_namespace_hash(portable_namespace_ctx, before); path_namespace_hash_stat(namespace_ctx, &opened_after); + path_namespace_hash_stat(portable_namespace_ctx, &opened_after); hash_length_delimited(content_ctx, buf, size); + hash_length_delimited(portable_namespace_ctx, buf, size); if (snapshot) { snapshot->path = xstrdup(source->path); snapshot->buf = buf; @@ -119,7 +138,7 @@ static int fingerprint_sources( const struct git_hash_algo *algo, struct attr_fingerprint *result, struct attr_source_snapshot *snapshot) { - struct git_hash_ctx content_ctx, namespace_ctx; + struct git_hash_ctx content_ctx, namespace_ctx, portable_namespace_ctx; uint32_t count; if (snapshot && nr != ARRAY_SIZE(snapshot->sources)) @@ -127,26 +146,33 @@ static int fingerprint_sources( memset(result, 0, sizeof(*result)); git_hash_init(&content_ctx, algo); git_hash_init(&namespace_ctx, algo); + git_hash_init(&portable_namespace_ctx, algo); hash_optional_cstring(&content_ctx, "attribute-source-content-v1"); hash_optional_cstring(&namespace_ctx, "attribute-source-namespace-v1"); + hash_optional_cstring(&portable_namespace_ctx, + "attribute-source-portable-namespace-v1"); if (nr > UINT32_MAX) return -1; put_be32(&count, nr); hash_length_delimited(&content_ctx, &count, sizeof(count)); hash_length_delimited(&namespace_ctx, &count, sizeof(count)); + hash_length_delimited(&portable_namespace_ctx, &count, sizeof(count)); for (size_t i = 0; i < nr; i++) { int present; struct attr_source_snapshot_entry *entry = snapshot ? &snapshot->sources[i] : NULL; - if (hash_source(&content_ctx, &namespace_ctx, &sources[i], - &present, entry)) + if (hash_source(&content_ctx, &namespace_ctx, + &portable_namespace_ctx, &sources[i], &present, + entry)) return -1; result->sources_present |= present; } git_hash_final(result->content_hash, &content_ctx); git_hash_final(result->namespace_hash, &namespace_ctx); + git_hash_final(result->portable_namespace_hash, + &portable_namespace_ctx); return 0; } diff --git a/attr-fingerprint.h b/attr-fingerprint.h index 518a5b31b4e485..69cd5bf79adc28 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -13,6 +13,7 @@ struct attr_fingerprint_source { struct attr_fingerprint { unsigned char content_hash[GIT_MAX_RAWSZ]; unsigned char namespace_hash[GIT_MAX_RAWSZ]; + unsigned char portable_namespace_hash[GIT_MAX_RAWSZ]; unsigned int sources_present : 1; }; diff --git a/builtin/checkout.c b/builtin/checkout.c index c18b8ce85f2a51..57e2ffb1ae76b2 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -239,7 +239,10 @@ static int update_some(const struct object_id *oid, struct strbuf *base, } } - if (checkout_context && checkout_context->index_changed) + if (checkout_context && checkout_context->index_changed && + !clean_status_index_entry_is_semantically_safe( + the_repository->index, + pos >= 0 ? the_repository->index->cache[pos] : NULL, ce)) *checkout_context->index_changed = 1; add_index_entry(the_repository->index, ce, ADD_CACHE_OK_TO_ADD | ADD_CACHE_OK_TO_REPLACE); @@ -452,7 +455,9 @@ static void mark_ce_for_checkout_no_overlay(struct cache_entry *ce, * tree-ish, which means we should remove it * from the index and the working tree. */ - if (index_changed) + if (index_changed && + !clean_status_index_entry_is_semantically_safe( + the_repository->index, ce, NULL)) *index_changed = 1; ce->ce_flags |= CE_REMOVE | CE_WT_REMOVE; } @@ -904,7 +909,8 @@ static int merge_working_tree(const struct checkout_opts *opts, * target tree matches the index. Let unpack_trees() transfer the * proof only after it proves that the rebuilt index is identical. */ - if (opts->discard_changes) + if (opts->discard_changes || + (!opts->merge && !opts->new_orphan_branch)) clean_status_set_config_digest(the_repository, &opts->clean_digest); if (repo_read_index_preload(the_repository, NULL, 0) < 0) { @@ -952,6 +958,7 @@ static int merge_working_tree(const struct checkout_opts *opts, /* 2-way merge to the new branch */ init_topts(&topts, opts->show_progress, opts->overwrite_ignore, quiet); + topts.preserve_semantic_history = 1; init_checkout_metadata(&topts.meta, new_branch_info->refname, new_branch_info->commit ? &new_branch_info->commit->object.oid : diff --git a/builtin/commit.c b/builtin/commit.c index fc0e043ac109f8..2b27964c888642 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -15,6 +15,7 @@ #include "cache-tree.h" #include "clean-status.h" #include "clean-status-index.h" +#include "clean-status-sidecar.h" #include "color.h" #include "dir.h" #include "editor.h" @@ -1636,6 +1637,23 @@ static int print_clean_sidecar(struct wt_status *s, const char *prefix) return 1; } +static int clean_status_sidecar_has_stale_index(struct repository *repo) +{ + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct clean_status_index_snapshot index = { .fd = -1 }; + int stale = 0; + + if (!clean_status_sidecar_load( + repo->index_file, repo->hash_algo, &record)) + stale = !!clean_status_sidecar_pin_source( + repo->index_file, &record.sidecar, + repo->hash_algo, &index); + clean_status_index_snapshot_release(&index); + clean_status_sidecar_record_release(&record); + return stale; +} + int cmd_status(int argc, const char **argv, const char *prefix, @@ -1654,6 +1672,7 @@ struct repository *repo UNUSED) int normal_clean_query; int reusable_clean_query; int normal_has_head; + int stale_clean_sidecar = 0; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1774,6 +1793,9 @@ struct repository *repo UNUSED) return 0; } } + if (normal_clean_query && use_optional_locks()) + stale_clean_sidecar = + clean_status_sidecar_has_stale_index(the_repository); if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) { @@ -1786,8 +1808,9 @@ struct repository *repo UNUSED) clean_status_capture_external_history_source( the_repository->index); if (normal_clean_query && use_optional_locks() && - clean_status_external_history_was_restored( - the_repository->index)) + (stale_clean_sidecar || + clean_status_external_history_was_restored( + the_repository->index))) s.certify_clean_status = 1; wt_status_start_untracked_cache_preload(&s); wt_status_refresh_index( @@ -1828,9 +1851,9 @@ struct repository *repo UNUSED) the_repository->index); int external_saved = 0; int preserve_entry_changes = - !external_restored && - (the_repository->index->cache_changed & - CE_ENTRY_CHANGED); + (!external_restored && + (the_repository->index->cache_changed & CE_ENTRY_CHANGED)) || + the_repository->index->fsmonitor_untracked_must_persist; /* * Publish resumable history before the physical clean proof. @@ -1861,7 +1884,9 @@ struct repository *repo UNUSED) fd = -1; } } else if (!preserve_entry_changes && - normal_clean_query && external_restored && + normal_clean_query && + (external_restored || + (stale_clean_sidecar && external_saved)) && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 1)) { fd = -1; diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 65780205798554..6adef864a65a7c 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -415,6 +415,10 @@ static struct fsmonitor_token_data *fsmonitor_new_token_data(void) struct tm tm; time_t secs; +#ifdef __APPLE__ + strbuf_addstr(&token->token_id, + FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX); +#endif gettimeofday(&tv, NULL); secs = tv.tv_sec; gmtime_r(&secs, &tm); @@ -742,7 +746,11 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, if (!strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { static const char capabilities[] = - FSMONITOR_IPC_QUERY_VERSION "\n"; + FSMONITOR_IPC_QUERY_VERSION "\n" +#ifdef __APPLE__ + FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" +#endif + ; return reply(reply_data, capabilities, sizeof(capabilities) - 1); diff --git a/builtin/reset.c b/builtin/reset.c index dfa0b3624c792a..539be66cd64936 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -161,9 +161,17 @@ static void update_index_from_diff(struct diff_queue_struct *q, int pos; struct diff_filespec *one = q->queue[i]->one; int is_in_reset_tree = one->mode && !is_null_oid(&one->oid); + struct cache_entry *old; struct cache_entry *ce; + pos = index_name_pos(the_repository->index, one->path, + strlen(one->path)); + old = pos >= 0 ? the_repository->index->cache[pos] : NULL; if (!is_in_reset_tree && !intent_to_add) { + if (!clean_status_index_entry_is_semantically_safe( + the_repository->index, old, NULL)) + clean_status_invalidate_current_proof( + the_repository->index); remove_file_from_index(the_repository->index, one->path); continue; } @@ -179,7 +187,6 @@ static void update_index_from_diff(struct diff_queue_struct *q, * if this entry is outside the sparse cone - this is necessary * to properly construct the reset sparse directory. */ - pos = index_name_pos(the_repository->index, one->path, strlen(one->path)); if ((pos >= 0 && ce_skip_worktree(the_repository->index->cache[pos])) || (pos < 0 && !path_in_sparse_checkout(one->path, the_repository->index))) ce->ce_flags |= CE_SKIP_WORKTREE; @@ -191,6 +198,10 @@ static void update_index_from_diff(struct diff_queue_struct *q, ce->ce_flags |= CE_INTENT_TO_ADD; set_object_name_for_intent_to_add_entry(ce); } + if (!clean_status_index_entry_is_semantically_safe( + the_repository->index, old, ce)) + clean_status_invalidate_current_proof( + the_repository->index); add_index_entry(the_repository->index, ce, ADD_CACHE_OK_TO_ADD | ADD_CACHE_OK_TO_REPLACE); } @@ -499,6 +510,9 @@ int cmd_reset(int argc, !unborn) { preserve_mixed_history = reset_type == MIXED; clean_status_set_config_digest(the_repository, &clean_digest); + } else if (reset_type == MIXED && pathspec.nr && + !intent_to_add && !unborn) { + clean_status_set_config_digest(the_repository, &clean_digest); } if (repo_read_index(the_repository) < 0) diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 06e6a7219a92b9..ead169a017b6ed 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -1,6 +1,7 @@ #include "git-compat-util.h" #ifdef __APPLE__ +#include #include #endif @@ -66,6 +67,18 @@ static char *history_store_path(const char *index_path, return xstrfmt("%s.csh1.%s", index_path, hex); } +char *clean_status_history_store_witness_path( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + char hex[GIT_MAX_HEXSZ + 1]; + + proof_namespace_hash(proof_namespace, algo, hash); + hash_to_hex_algop_r(hex, hash, algo); + return xstrfmt("%s.cswi.%s", index_path, hex); +} + struct history_store_file { char *path; timestamp_t mtime; @@ -157,6 +170,21 @@ static int prune_history_store(const char *index_path, if (lstat(files[i].path, &st) || !S_ISREG(st.st_mode) || unlink(files[i].path)) goto done; + { + char *witness = xstrdup(files[i].path); + size_t pathlen = strlen(witness); + char *marker = pathlen >= algo->hexsz + 6 ? + witness + pathlen - algo->hexsz - 6 : NULL; + + if (marker && !memcmp(marker, ".csh1.", 6)) + memcpy(marker, ".cswi.", 6); + else + marker = NULL; + if (marker && !lstat(witness, &st) && + S_ISREG(st.st_mode)) + unlink(witness); + free(witness); + } remove_nr--; } ret = remove_nr ? -1 : 0; @@ -437,6 +465,49 @@ static int local_apfs_id(int fd MAYBE_UNUSED, #endif } +static void install_history_witness( + const char *index_path, const char *proof_namespace, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo, int encoded_matches) +{ +#ifdef __APPLE__ + struct clean_status_filesystem_id fsid; + struct clean_status_index_snapshot existing = { .fd = -1 }; + char *witness = NULL, *temporary = NULL; + + if (!snapshot || snapshot->fd < 0 || + local_apfs_id(snapshot->fd, &fsid)) + return; + witness = clean_status_history_store_witness_path( + index_path, proof_namespace, algo); + if (encoded_matches && + !clean_status_index_snapshot_open(&existing, witness, algo) && + existing.version == snapshot->version && + existing.cache_nr == snapshot->cache_nr && + oideq(&existing.checksum, &snapshot->checksum)) { + clean_status_index_snapshot_release(&existing); + free(witness); + return; + } + clean_status_index_snapshot_release(&existing); + temporary = xstrfmt("%s.tmp.%"PRIuMAX, witness, + (uintmax_t)getpid()); + if (!fclonefileat(snapshot->fd, AT_FDCWD, temporary, 0) && + clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo)) + rename(temporary, witness); + unlink(temporary); + free(temporary); + free(witness); +#else + (void)index_path; + (void)proof_namespace; + (void)snapshot; + (void)algo; + (void)encoded_matches; +#endif +} + int clean_status_history_checkpoint_source_matches( const char *index_path, const struct clean_status_history_checkpoint *checkpoint, @@ -515,6 +586,9 @@ int clean_status_history_store_install( !clean_status_index_snapshot_still_matches_path( snapshot, index_path, algo)) goto done; + if (aliased.source_alias_valid) + install_history_witness(index_path, proof_namespace, + snapshot, algo, encoded_matches); if (encoded_matches) { ret = 0; goto done; diff --git a/clean-status-history-store.h b/clean-status-history-store.h index 82c7a267efb5bc..2e275c87a62afc 100644 --- a/clean-status-history-store.h +++ b/clean-status-history-store.h @@ -52,6 +52,9 @@ int clean_status_history_store_install( const struct clean_status_history_checkpoint *checkpoint, const struct clean_status_index_snapshot *snapshot, const struct git_hash_algo *algo); +char *clean_status_history_store_witness_path( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo); int clean_status_history_checkpoint_source_matches( const char *index_path, const struct clean_status_history_checkpoint *checkpoint, diff --git a/clean-status-history.c b/clean-status-history.c index d2f487d46296e9..347f6c6644e5a1 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -247,6 +247,8 @@ void clean_status_advance_fsmonitor_config_token( if (!next_token || !current_proof_is_writable(istate)) return; + if (strcmp(istate->fsmonitor_last_update, next_token)) + clean_status_clear_authenticated_new_directories(istate); FREE_AND_NULL(state->config_revalidated_token); state->config_revalidated_token = xstrdup(next_token); trace2_data_intmax("fsmonitor", istate->repo, @@ -329,7 +331,8 @@ static int external_history_namespace(struct index_state *istate, char *out) istate->repo->hash_algo->rawsz); hash_length_delimited(&ctx, state->current_semantic_hash, istate->repo->hash_algo->rawsz); - hash_length_delimited(&ctx, state->current_attr_namespace_hash, + hash_length_delimited(&ctx, + state->current_attr_portable_namespace_hash, istate->repo->hash_algo->rawsz); hash_length_delimited(&ctx, worktree, strlen(worktree)); hash_length_delimited(&ctx, gitdir, strlen(gitdir)); @@ -572,6 +575,393 @@ static int external_token_is_replayable(const char *token) return replayable; } +#ifdef __APPLE__ +static int external_semantic_delta_is_safe( + const struct strbuf *paths, struct index_state *old_index, + struct index_state *new_index) +{ + const char *path = paths->buf; + const char *end = paths->buf + paths->len; + + while (path < end) { + size_t len = strlen(path); + const char *base = find_last_dir_sep(path); + + base = base ? base + 1 : path; + if (!len || !fspathcmp(base, ".gitattributes") || + !fspathcmp(base, ".gitignore")) + return 0; + if (path[len - 1] == '/') { + int old_pos = index_name_pos(old_index, path, len); + int new_pos = index_name_pos(new_index, path, len); + const struct cache_entry *entry; + + old_pos = old_pos < 0 ? -old_pos - 1 : old_pos; + new_pos = new_pos < 0 ? -new_pos - 1 : new_pos; + if ((unsigned int)old_pos < old_index->cache_nr && + starts_with(old_index->cache[old_pos]->name, path)) + return 0; + if ((unsigned int)new_pos >= new_index->cache_nr || + !starts_with(new_index->cache[new_pos]->name, path)) { + path += len + 1; + continue; + } + entry = new_index->cache[new_pos]; + if (!clean_status_index_entry_is_semantically_safe( + old_index, NULL, entry)) + return 0; + } + path += len + 1; + } + return path == end; +} + +static void invalidate_external_checkpoint_entry(size_t pos, void *data) +{ + struct index_state *istate = data; + + if (pos < istate->cache_nr) + istate->cache[pos]->ce_flags &= ~CE_FSMONITOR_VALID; +} + +static int external_checkpoint_path_was_replayed( + const char *name, const struct strbuf *paths) +{ + const char *path = paths->buf; + const char *end = paths->buf + paths->len; + + while (path < end) { + size_t len = strlen(path); + + if (!fspathcmp(name, path) || + (path[len - 1] == '/' && !fspathncmp(name, path, len))) + return 1; + path += len + 1; + } + return 0; +} + +static void restore_external_tracked_history( + struct index_state *istate, struct index_state *witness, + const struct clean_status_history_checkpoint *checkpoint, + const struct strbuf *paths, const struct fsmonitor_clean_proof *proof) +{ + struct index_state parsed = INDEX_STATE_INIT(istate->repo); + unsigned int old_pos = 0, new_pos = 0, restored = 0, i; + const unsigned int unsafe_flags = CE_VALID | CE_SKIP_WORKTREE | + CE_INTENT_TO_ADD | CE_CONTENT_CHECK_REQUIRED | CE_STAGEMASK; + + if (!checkpoint->fsmonitor_len || !istate->fsmonitor_dirty) + return; + parsed.cache_nr = witness->cache_nr; + if (read_fsmonitor_extension(&parsed, checkpoint->fsmonitor, + checkpoint->fsmonitor_len) || + !parsed.fsmonitor_token_valid || !parsed.fsmonitor_dirty || + !parsed.fsmonitor_last_update || + strlen(parsed.fsmonitor_last_update) != proof->token_len || + memcmp(parsed.fsmonitor_last_update, proof->token, + proof->token_len)) + goto done; + for (i = 0; i < witness->cache_nr; i++) + if (!S_ISGITLINK(witness->cache[i]->ce_mode)) + witness->cache[i]->ce_flags |= CE_FSMONITOR_VALID; + ewah_each_bit(parsed.fsmonitor_dirty, + invalidate_external_checkpoint_entry, witness); + for (i = 0; i < istate->cache_nr; i++) + if (!S_ISGITLINK(istate->cache[i]->ce_mode)) + istate->cache[i]->ce_flags |= CE_FSMONITOR_VALID; + ewah_each_bit(istate->fsmonitor_dirty, + invalidate_external_checkpoint_entry, istate); + while (old_pos < witness->cache_nr && new_pos < istate->cache_nr) { + const struct cache_entry *old_entry = witness->cache[old_pos]; + struct cache_entry *new_entry = istate->cache[new_pos]; + int cmp = strcmp(old_entry->name, new_entry->name); + + if (cmp < 0) { + old_pos++; + continue; + } + if (cmp > 0) { + new_pos++; + continue; + } + old_pos++; + new_pos++; + if ((new_entry->ce_flags & CE_FSMONITOR_VALID) || + !(old_entry->ce_flags & CE_FSMONITOR_VALID) || + ((old_entry->ce_flags | new_entry->ce_flags) & unsafe_flags) || + (!S_ISREG(new_entry->ce_mode) && + !S_ISLNK(new_entry->ce_mode)) || + old_entry->ce_mode != new_entry->ce_mode || + !oideq(&old_entry->oid, &new_entry->oid) || + memcmp(&old_entry->ce_stat_data, &new_entry->ce_stat_data, + sizeof(old_entry->ce_stat_data)) || + is_racy_timestamp(istate, new_entry) || + external_checkpoint_path_was_replayed( + new_entry->name, paths)) + continue; + new_entry->ce_flags |= CE_FSMONITOR_VALID; + restored++; + } + if (restored) { + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; + fill_fsmonitor_bitmap(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-tracked-restored", restored); + } + +done: + if (parsed.fsmonitor_dirty) + ewah_free(parsed.fsmonitor_dirty); + parsed.fsmonitor_dirty = NULL; + parsed.cache_nr = 0; + release_index(&parsed); +} + +static int external_index_has_other_tracked_sibling( + struct index_state *istate, const char *name, size_t parent_len) +{ + const unsigned int unsafe_flags = + CE_STAGEMASK | CE_SKIP_WORKTREE | CE_INTENT_TO_ADD; + int pos = index_name_pos(istate, name, parent_len); + + if (pos < 0) + pos = -pos - 1; + for (; (unsigned int)pos < istate->cache_nr; pos++) { + const struct cache_entry *entry = istate->cache[pos]; + + if (ce_namelen(entry) <= parent_len || + memcmp(entry->name, name, parent_len)) + break; + if (!strcmp(entry->name, name)) + continue; + if ((entry->ce_flags & unsafe_flags) || + (!S_ISREG(entry->ce_mode) && !S_ISLNK(entry->ce_mode))) + continue; + return 1; + } + return 0; +} + +static int external_untracked_membership_needs_root_invalidation( + struct index_state *istate, struct index_state *witness, + const char *name) +{ + const char *slash = find_last_dir_sep(name); + size_t parent_len; + + if (!slash || istate->sparse_index || witness->sparse_index) + return 1; + parent_len = slash - name + 1; + return !external_index_has_other_tracked_sibling( + witness, name, parent_len) || + !external_index_has_other_tracked_sibling( + istate, name, parent_len); +} + +static void restore_external_untracked_history( + struct index_state *istate, struct index_state *witness, + const struct clean_status_history_checkpoint *checkpoint, + const struct strbuf *paths, const struct fsmonitor_clean_proof *proof) +{ + struct index_state parsed = INDEX_STATE_INIT(istate->repo); + const char *path = paths->buf; + const char *end = paths->buf + paths->len; + unsigned int old_pos = 0, new_pos = 0; + unsigned int targeted_membership = 0, rooted_membership = 0; + + if (istate->fsmonitor_untracked_valid || + !checkpoint->untracked_cache_len || + !checkpoint->fsmonitor_untracked_len) + return; + parsed.untracked = read_untracked_extension( + checkpoint->untracked_cache, + checkpoint->untracked_cache_len); + if (!parsed.untracked || + read_fsmonitor_untracked_extension( + &parsed, checkpoint->fsmonitor_untracked, + checkpoint->fsmonitor_untracked_len) || + parsed.fsmonitor_untracked_extension_invalid || + !parsed.fsmonitor_untracked_token || + strlen(parsed.fsmonitor_untracked_token) != proof->token_len || + memcmp(parsed.fsmonitor_untracked_token, + proof->token, proof->token_len)) + goto done; + free_untracked_cache(istate->untracked); + istate->untracked = parsed.untracked; + parsed.untracked = NULL; + istate->untracked->use_fsmonitor = 1; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_untracked_extension_seen = 1; + istate->fsmonitor_untracked_extension_invalid = 0; + istate->fsmonitor_untracked_valid = 1; + while (old_pos < witness->cache_nr || new_pos < istate->cache_nr) { + const struct cache_entry *old_entry = + old_pos < witness->cache_nr ? + witness->cache[old_pos] : NULL; + const struct cache_entry *new_entry = + new_pos < istate->cache_nr ? + istate->cache[new_pos] : NULL; + int cmp = !old_entry ? 1 : !new_entry ? -1 : + strcmp(old_entry->name, new_entry->name); + + if (cmp < 0) { + int rooted = + external_untracked_membership_needs_root_invalidation( + istate, witness, old_entry->name); + + untracked_cache_invalidate_path( + istate, old_entry->name, rooted); + rooted ? rooted_membership++ : targeted_membership++; + old_pos++; + } else if (cmp > 0) { + int rooted = + external_untracked_membership_needs_root_invalidation( + istate, witness, new_entry->name); + + untracked_cache_invalidate_path( + istate, new_entry->name, rooted); + rooted ? rooted_membership++ : targeted_membership++; + new_pos++; + } else { + old_pos++; + new_pos++; + } + } + while (path < end) { + size_t len = strlen(path); + + untracked_cache_invalidate_trimmed_path(istate, path, 0); + path += len + 1; + } + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-untracked-restored", 1); + if (targeted_membership) + trace2_data_intmax("fsmonitor", istate->repo, + "history/untracked-membership-targeted", + targeted_membership); + if (rooted_membership) + trace2_data_intmax("fsmonitor", istate->repo, + "history/untracked-membership-rooted", + rooted_membership); + +done: + release_index(&parsed); +} +#endif + +static int restore_external_semantic_history( + struct index_state *istate, + const struct clean_status_history_checkpoint *checkpoint, + const char *proof_namespace, + const struct clean_status_index_snapshot *snapshot) +{ +#ifdef __APPLE__ + struct index_state witness = INDEX_STATE_INIT(istate->repo); + struct fsmonitor_query_result old = FSMONITOR_QUERY_RESULT_INIT; + struct fsmonitor_query_result current = FSMONITOR_QUERY_RESULT_INIT; + struct fsmonitor_clean_proof proof; + struct clean_status_identity before_identity, after_identity; + struct stat before, after; + unsigned char witness_hash[GIT_MAX_RAWSZ]; + char *path = NULL; + int fd = -1, transferred = 0; + + if (!checkpoint->source_alias_valid || + !has_usable_on_index_builtin_token(istate) || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) + goto done; + path = clean_status_history_store_witness_path( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo); + fd = open_nofollow(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || + before.st_nlink != 1 || before.st_uid != geteuid() || + clean_status_identity_from_stat(&before_identity, &before) || + lstat(path, &after) || before.st_dev != after.st_dev || + before.st_ino != after.st_ino) + goto done; + do_read_index(&witness, path, 1); + if (fstat(fd, &after) || after.st_nlink != 1 || + after.st_uid != geteuid() || + clean_status_identity_from_stat(&after_identity, &after) || + !clean_status_identity_equal(&before_identity, &after_identity) || + before.st_size != after.st_size || + lstat(path, &after) || before.st_dev != after.st_dev || + before.st_ino != after.st_ino || + witness.version != checkpoint->source_version || + witness.cache_nr != checkpoint->source_cache_nr || + !oideq(&witness.oid, &checkpoint->source_checksum) || + clean_status_index_logical_digest(&witness, witness_hash) || + memcmp(witness_hash, checkpoint->index_hash, + istate->repo->hash_algo->rawsz) || + fsmonitor_clean_proof_parse( + &proof, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len, + istate->repo->hash_algo)) + goto done; + clean_status_release(&witness); + clean_status_attach_config(&witness); + clean_status_read_fsmonitor_config( + &witness, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len); + free(witness.fsmonitor_last_update); + witness.fsmonitor_last_update = + xmemdupz(proof.token, proof.token_len); + witness.fsmonitor_token_valid = 1; + clean_status_prepare_fsmonitor_config(&witness); + if (!current_proof_is_writable(&witness) || + query_builtin_fsmonitor(witness.fsmonitor_last_update, &old) != + FSMONITOR_QUERY_DELTA || + query_builtin_fsmonitor(istate->fsmonitor_last_update, ¤t) != + FSMONITOR_QUERY_DELTA || + strcmp(old.token.buf, current.token.buf) || + !external_semantic_delta_is_safe(&old.paths, &witness, istate) || + !clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + if (strcmp(witness.fsmonitor_last_update, + istate->fsmonitor_last_update)) { + clean_status_advance_fsmonitor_config_token( + &witness, istate->fsmonitor_last_update); + free(witness.fsmonitor_last_update); + witness.fsmonitor_last_update = + xstrdup(istate->fsmonitor_last_update); + } + transferred = + clean_status_transfer_current_proof_if_semantically_same_index( + istate, &witness); + if (transferred) { + clean_status_set_authenticated_new_directories( + istate, &witness, &old.paths); + restore_external_tracked_history( + istate, &witness, checkpoint, &old.paths, &proof); + restore_external_untracked_history( + istate, &witness, checkpoint, &old.paths, &proof); + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-semantic-restored", 1); + } + +done: + if (fd >= 0) + close(fd); + free(path); + fsmonitor_query_result_release(&old); + fsmonitor_query_result_release(¤t); + release_index(&witness); + return transferred; +#else + (void)istate; + (void)checkpoint; + (void)proof_namespace; + (void)snapshot; + return 0; +#endif +} + int clean_status_restore_external_history(struct index_state *istate) { struct clean_status_history_store_record record = @@ -585,6 +975,7 @@ int clean_status_restore_external_history(struct index_state *istate) int restored = 0; if (!clean_status_external_history_enabled(istate) || !state || + state->disk_config_invalid || !state->config_enforced || !state->current_config_valid || !state->current_semantic_valid || !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || @@ -629,10 +1020,14 @@ int clean_status_restore_external_history(struct index_state *istate) memcpy(state->source_logical_hash, index_hash, istate->repo->hash_algo->rawsz); state->source_logical_hash_valid = 1; - if (!record_loaded || - memcmp(index_hash, record.checkpoint.index_hash, - istate->repo->hash_algo->rawsz)) + if (!record_loaded) + goto done; + if (memcmp(index_hash, record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz)) { + restored = restore_external_semantic_history( + istate, &record.checkpoint, proof_namespace, &snapshot); goto done; + } parsed.cache_nr = istate->cache_nr; if (read_fsmonitor_extension( &parsed, record.checkpoint.fsmonitor, @@ -830,3 +1225,94 @@ int clean_status_transfer_current_proof_if_same_index( return transferred; } + +int clean_status_transfer_current_proof_if_semantically_same_index( + struct index_state *dst, const struct index_state *src) +{ + const unsigned int semantic_flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + struct strbuf proof = STRBUF_INIT; + unsigned int src_pos = 0, dst_pos = 0; + int transferred; + + if (!current_proof_is_writable(src) || + src->repo != dst->repo || src->split_index || dst->split_index || + src->sparse_index || dst->sparse_index || + (src->cache_changed & RESOLVE_UNDO_CHANGED) || + src->resolve_undo || + !src->fsmonitor_last_update || !dst->fsmonitor_last_update || + strcmp(src->fsmonitor_last_update, dst->fsmonitor_last_update)) + return 0; + + while (src_pos < src->cache_nr || dst_pos < dst->cache_nr) { + const struct cache_entry *old = src_pos < src->cache_nr ? + src->cache[src_pos] : NULL; + const struct cache_entry *new_entry = dst_pos < dst->cache_nr ? + dst->cache[dst_pos] : NULL; + int cmp; + + if (!old) + cmp = 1; + else if (!new_entry) + cmp = -1; + else + cmp = strcmp(old->name, new_entry->name); + if (cmp < 0) { + if (!clean_status_index_entry_is_semantically_safe( + src, old, NULL)) + return 0; + src_pos++; + } else if (cmp > 0) { + if (!clean_status_index_entry_is_semantically_safe( + src, NULL, new_entry)) + return 0; + dst_pos++; + } else { + if ((old->ce_mode != new_entry->ce_mode || + !oideq(&old->oid, &new_entry->oid) || + ((old->ce_flags ^ new_entry->ce_flags) & semantic_flags)) && + !clean_status_index_entry_is_semantically_safe( + src, old, new_entry)) + return 0; + src_pos++; + dst_pos++; + } + } + + if (current_proof_is_writable(dst)) { + const struct clean_status_state *src_state = src->clean_status; + const struct clean_status_state *dst_state = dst->clean_status; + size_t rawsz = dst->repo->hash_algo->rawsz; + + if (memcmp(src_state->current_config_hash, + dst_state->current_config_hash, rawsz) || + memcmp(src_state->current_semantic_hash, + dst_state->current_semantic_hash, rawsz) || + memcmp(src_state->current_attr_hash, + dst_state->current_attr_hash, rawsz) || + src_state->manifest.current_flags != + dst_state->manifest.current_flags || + src_state->manifest.current.len != + dst_state->manifest.current.len || + memcmp(src_state->manifest.current.buf, + dst_state->manifest.current.buf, + src_state->manifest.current.len)) + return 0; + trace2_data_intmax("fsmonitor", dst->repo, + "history/semantic-transferred", 1); + return 1; + } + + clean_status_write_fsmonitor_config(&proof, src); + dst->fsmonitor_token_valid = src->fsmonitor_token_valid; + clean_status_read_fsmonitor_config(dst, proof.buf, proof.len); + clean_status_attach_config(dst); + clean_status_prepare_fsmonitor_config(dst); + transferred = current_proof_is_writable(dst); + if (transferred) + trace2_data_intmax("fsmonitor", dst->repo, + "history/semantic-transferred", 1); + strbuf_release(&proof); + + return transferred; +} diff --git a/clean-status-internal.h b/clean-status-internal.h index f37fdcad4ca79e..f19b03f89947b2 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -11,8 +11,10 @@ struct clean_status_state { struct clean_status_identity source_index_identity; struct clean_status_manifest_state manifest; struct strbuf disk_config_raw; + struct strbuf authenticated_new_directories; char *disk_config_token; char *config_revalidated_token; + char *authenticated_new_directories_token; int source_index_fd; unsigned char current_config_hash[GIT_MAX_RAWSZ]; unsigned char disk_config_hash[GIT_MAX_RAWSZ]; @@ -20,6 +22,7 @@ struct clean_status_state { unsigned char disk_semantic_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned char current_attr_portable_namespace_hash[GIT_MAX_RAWSZ]; unsigned char disk_attr_hash[GIT_MAX_RAWSZ]; unsigned char source_logical_hash[GIT_MAX_RAWSZ]; unsigned current_config_valid : 1; diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index 06cab58d59699e..a13898d2bba190 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -139,8 +139,9 @@ int clean_status_issue_sidecar( goto done; } if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || - !istate->cache_tree || istate->cache_tree->entry_count < 0 || - !oideq(&head_tree, &istate->cache_tree->oid)) { + ((!istate->cache_tree || istate->cache_tree->entry_count < 0) ? + !status->index_tree_verified : + !oideq(&head_tree, &istate->cache_tree->oid))) { trace_miss(repo, "issue-head-cache-tree"); goto done; } diff --git a/clean-status.c b/clean-status.c index 1597f76e64881e..47d49c2d78df1d 100644 --- a/clean-status.c +++ b/clean-status.c @@ -1,11 +1,15 @@ #include "git-compat-util.h" #include "attr-fingerprint.h" +#include "attr-manifest.h" #include "clean-status.h" #include "clean-status-internal.h" +#include "dir.h" #include "fsmonitor-clean-proof.h" #include "progress.h" #include "read-cache-ll.h" #include "repository.h" +#include "semantic-verify-internal.h" +#include "worktree-attr-source.h" #include "thread-utils.h" #include "trace2.h" @@ -80,6 +84,8 @@ struct clean_status_state *clean_status_get_state(struct index_state *istate) istate->clean_status->source_index_fd = -1; clean_status_manifest_init(&istate->clean_status->manifest); strbuf_init(&istate->clean_status->disk_config_raw, 0); + strbuf_init(&istate->clean_status->authenticated_new_directories, + 0); } return istate->clean_status; } @@ -125,6 +131,9 @@ void clean_status_attach_config(struct index_state *istate) istate->repo->hash_algo->rawsz); memcpy(state->current_attr_namespace_hash, attrs.namespace_hash, istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_portable_namespace_hash, + attrs.portable_namespace_hash, + istate->repo->hash_algo->rawsz); state->current_attr_valid = 1; state->current_attr_sources_present = attrs.sources_present; } @@ -166,12 +175,261 @@ void clean_status_invalidate_current_proof(struct index_state *istate) { if (!istate->clean_status) return; + clean_status_clear_authenticated_new_directories(istate); istate->clean_status->config_revalidated = 0; istate->clean_status->initial_coherent = 0; istate->clean_status->filter_scope_valid = 0; istate->clean_status->semantic_baseline_pending = 0; } +static int path_has_no_new_attribute_sources( + const struct index_state *istate, const char *name, + int allow_removed_parent) +{ + const struct clean_status_manifest_state *manifest = + &istate->clean_status->manifest; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + struct strbuf candidate = STRBUF_INIT; + const char *slash = name; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable = 0; + size_t position = 0; + int safe = 0; + + if (!strchr(name, '/')) + return 1; + if (semantic_verify_root_init(istate->repo, &root)) + goto done; + path = semantic_verify_path_new(root); + while ((slash = strchr(slash, '/')) != NULL) { + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + const struct cache_entry *source = NULL; + const char *basename; + unsigned int low = 0, high = istate->cache_nr; + int parent_fd, found, matched = 0, missing_parent = 0; + + strbuf_reset(&candidate); + strbuf_add(&candidate, name, slash - name + 1); + strbuf_addstr(&candidate, ".gitattributes"); + if (semantic_verify_resolve_parent(path, candidate.buf, + position, &parent_fd, + &basename)) { + if (!allow_removed_parent || errno != ENOENT) + goto done; + missing_parent = 1; + found = 0; + } else if (worktree_attr_source_read(path, candidate.buf, + position, + istate->repo->hash_algo, + hash, &found)) { + goto done; + } + position++; + while (low < high) { + unsigned int middle = low + (high - low) / 2; + const struct cache_entry *ce = istate->cache[middle]; + int cmp = strcmp(ce->name, candidate.buf); + + if (!cmp) { + source = ce; + break; + } + if (cmp < 0) + low = middle + 1; + else + high = middle; + } + if (!source && !found && !missing_parent) { + slash++; + continue; + } + if (!manifest->current_valid || manifest->current_invalidated || + (manifest->current_flags & + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) != + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) + goto done; + if (attr_manifest_cursor_init(&cursor, manifest->current.buf, + manifest->current.len, + istate->repo->hash_algo)) + goto done; + if (missing_parent) { + if (source) + goto done; + while (attr_manifest_cursor_next(&cursor, &entry) > 0) + if (entry.path_len == candidate.len && + !memcmp(entry.path, candidate.buf, candidate.len)) + goto done; + slash++; + continue; + } + if (!source || !S_ISREG(source->ce_mode) || ce_stage(source) || + ce_skip_worktree(source) || ce_intent_to_add(source) || + (source->ce_flags & CE_VALID)) + goto done; + while (attr_manifest_cursor_next(&cursor, &entry) > 0) { + if (entry.path_len != candidate.len || + memcmp(entry.path, candidate.buf, candidate.len)) + continue; + matched = found ? + entry.source == ATTR_MANIFEST_WORKTREE && + !memcmp(entry.hash, hash, + istate->repo->hash_algo->rawsz) : + entry.source == ATTR_MANIFEST_INDEX && + !memcmp(entry.hash, source->oid.hash, + istate->repo->hash_algo->rawsz); + break; + } + if (!matched) + goto done; + slash++; + } + semantic_verify_path_free(path, &namespace_unstable, NULL); + path = NULL; + safe = !namespace_unstable && semantic_verify_root_stable(root); + +done: + if (path) + semantic_verify_path_free(path, &namespace_unstable, NULL); + semantic_verify_root_clear(root); + strbuf_release(&candidate); + return safe; +} + +int clean_status_index_entry_is_semantically_safe( + const struct index_state *istate, + const struct cache_entry *old, + const struct cache_entry *new_entry) +{ + const struct clean_status_state *state = istate->clean_status; + const struct cache_entry *entry = old ? old : new_entry; + const char *base; + + if (!state || !state->config_revalidated || + !clean_status_revalidated_token_matches(istate) || + state->filter_configured || istate->split_index || + istate->sparse_index || !entry) + return 0; + if ((old && (!S_ISREG(old->ce_mode) && !S_ISLNK(old->ce_mode))) || + (new_entry && (!S_ISREG(new_entry->ce_mode) && + !S_ISLNK(new_entry->ce_mode)))) + return 0; + if ((old && (ce_stage(old) || ce_skip_worktree(old) || + ce_intent_to_add(old) || (old->ce_flags & CE_VALID))) || + (new_entry && (ce_stage(new_entry) || + ce_skip_worktree(new_entry) || + ce_intent_to_add(new_entry) || + (new_entry->ce_flags & CE_VALID)))) + return 0; + base = strrchr(entry->name, '/'); + base = base ? base + 1 : entry->name; + if (!fspathcmp(base, ".gitattributes") || + !fspathcmp(base, ".gitignore")) + return 0; + if (!old || !new_entry) + return path_has_no_new_attribute_sources(istate, entry->name, + old && !new_entry); + return ce_namelen(old) == ce_namelen(new_entry) && + !memcmp(old->name, new_entry->name, ce_namelen(old)) && + old->ce_mode == new_entry->ce_mode; +} + +void clean_status_clear_authenticated_new_directories( + struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state) + return; + strbuf_reset(&state->authenticated_new_directories); + FREE_AND_NULL(state->authenticated_new_directories_token); +} + +static unsigned int clean_status_directory_lower_bound( + const struct index_state *istate, const char *name) +{ + unsigned int low = 0, high = istate->cache_nr; + + while (low < high) { + unsigned int middle = low + (high - low) / 2; + + if (strcmp(istate->cache[middle]->name, name) < 0) + low = middle + 1; + else + high = middle; + } + return low; +} + +void clean_status_set_authenticated_new_directories( + struct index_state *istate, const struct index_state *old_index, + const struct strbuf *paths) +{ + struct clean_status_state *state = istate->clean_status; + const char *path = paths->buf, *end = paths->buf + paths->len; + + clean_status_clear_authenticated_new_directories(istate); + if (!state || !state->manifest.current_valid || + state->manifest.current_invalidated || + (state->manifest.current_flags & + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) != + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX) || + !clean_status_revalidated_token_matches(istate)) + return; + while (path < end) { + size_t len = strlen(path); + unsigned int old_pos, new_pos; + + if (!len || path[len - 1] != '/') + goto next; + old_pos = clean_status_directory_lower_bound(old_index, path); + new_pos = clean_status_directory_lower_bound(istate, path); + if ((old_pos < old_index->cache_nr && + starts_with(old_index->cache[old_pos]->name, path)) || + new_pos >= istate->cache_nr || + !starts_with(istate->cache[new_pos]->name, path) || + !clean_status_index_entry_is_semantically_safe( + old_index, NULL, istate->cache[new_pos])) + goto next; + strbuf_add(&state->authenticated_new_directories, path, + len + 1); +next: + path += len + 1; + } + if (state->authenticated_new_directories.len) + state->authenticated_new_directories_token = + xstrdup(istate->fsmonitor_last_update); +} + +int clean_status_directory_event_is_semantically_safe( + const struct index_state *istate, const char *name) +{ + const struct clean_status_state *state = istate->clean_status; + const char *path, *end; + + if (!state || !state->authenticated_new_directories_token || + !clean_status_revalidated_token_matches(istate) || + strcmp(state->authenticated_new_directories_token, + istate->fsmonitor_last_update)) + return 0; + path = state->authenticated_new_directories.buf; + end = path + state->authenticated_new_directories.len; + while (path < end) { + if (!strcmp(path, name)) { + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/authenticated-new-directory", 1); + return 1; + } + path += strlen(path) + 1; + } + return 0; +} + int clean_status_capture_attr_snapshot( struct index_state *istate, struct attr_source_snapshot **snapshot) @@ -206,6 +464,9 @@ int clean_status_capture_attr_snapshot( istate->repo->hash_algo->rawsz); memcpy(state->current_attr_namespace_hash, attrs->namespace_hash, istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_portable_namespace_hash, + attrs->portable_namespace_hash, + istate->repo->hash_algo->rawsz); state->current_attr_valid = 1; state->current_attr_sources_present = attrs->sources_present; } else { @@ -313,7 +574,9 @@ void clean_status_release(struct index_state *istate) close(istate->clean_status->source_index_fd); clean_status_manifest_release(&istate->clean_status->manifest); strbuf_release(&istate->clean_status->disk_config_raw); + strbuf_release(&istate->clean_status->authenticated_new_directories); free(istate->clean_status->disk_config_token); free(istate->clean_status->config_revalidated_token); + free(istate->clean_status->authenticated_new_directories_token); FREE_AND_NULL(istate->clean_status); } diff --git a/clean-status.h b/clean-status.h index 0988e9ba318f32..47600588643244 100644 --- a/clean-status.h +++ b/clean-status.h @@ -4,6 +4,7 @@ #include "clean-status-config.h" struct index_state; +struct cache_entry; struct attr_source_snapshot; struct clean_status_progress; struct clean_status_proof_epoch; @@ -104,6 +105,17 @@ int clean_status_read_fsmonitor_config(struct index_state *istate, void clean_status_prepare_fsmonitor_config(struct index_state *istate); int clean_status_probe_fsmonitor_config(struct index_state *istate); void clean_status_invalidate_current_proof(struct index_state *istate); +int clean_status_index_entry_is_semantically_safe( + const struct index_state *istate, + const struct cache_entry *old, + const struct cache_entry *new_entry); +void clean_status_set_authenticated_new_directories( + struct index_state *istate, const struct index_state *old_index, + const struct strbuf *paths); +void clean_status_clear_authenticated_new_directories( + struct index_state *istate); +int clean_status_directory_event_is_semantically_safe( + const struct index_state *istate, const char *name); void clean_status_advance_fsmonitor_config_token( struct index_state *istate, const char *next_token); int clean_status_should_write_fsmonitor_config( @@ -120,6 +132,8 @@ void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src); int clean_status_transfer_current_proof_if_same_index( struct index_state *dst, const struct index_state *src); +int clean_status_transfer_current_proof_if_semantically_same_index( + struct index_state *dst, const struct index_state *src); void clean_status_release(struct index_state *istate); diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index ffd8392262261b..41e47c4ac17d77 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -144,6 +144,20 @@ static int ef_is_hardlink(const FSEventStreamEventFlags ef) kFSEventStreamEventFlagItemIsLastHardlink); } +static int ef_ignore_dir_metadata(const FSEventStreamEventFlags ef) +{ + static const FSEventStreamEventFlags required = + kFSEventStreamEventFlagItemIsDir | + kFSEventStreamEventFlagItemInodeMetaMod; + static const FSEventStreamEventFlags allowed = + kFSEventStreamEventFlagItemIsDir | + kFSEventStreamEventFlagItemInodeMetaMod | + kFSEventStreamEventFlagItemCreated | + kFSEventStreamEventFlagItemXattrMod; + + return (ef & required) == required && !(ef & ~allowed); +} + /* * If an `xattr` change is the only reason we received this event, * then silently ignore it. Git doesn't care about xattr's. We @@ -353,6 +367,12 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, if (trace_pass_fl(&trace_fsmonitor)) log_flags_set(path_k, event_flags[k]); + if (ef_ignore_dir_metadata(event_flags[k])) { + trace_printf_key(&trace_fsmonitor, + "ignore-dir-metadata: '%s', flags=0x%x", + path_k, event_flags[k]); + break; + } /* * Because of the implicit "binning" (the diff --git a/dir.c b/dir.c index 27f13a51569848..927dda6e5b2e9c 100644 --- a/dir.c +++ b/dir.c @@ -108,6 +108,7 @@ struct untracked_cache_preload { struct index_state *istate; struct untracked_cache *uc; struct untracked_cache_dir *root; + const struct pathspec *pathspec; struct untracked_cache_preload_task *tasks; struct object_id *exclude_index_oids; struct untracked_cache_preload_data *data; @@ -135,16 +136,77 @@ static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat *st); static void *preload_untracked_cache_thread(void *data); +static const struct pathspec *untracked_cache_preload_pathspec( + const struct pathspec *pathspec) +{ + int i, positive = 0; + + if (!pathspec || !pathspec->nr || + (pathspec->magic & (PATHSPEC_ATTR | PATHSPEC_ICASE))) + return NULL; + + for (i = 0; i < pathspec->nr; i++) { + const struct pathspec_item *item = &pathspec->items[i]; + + if (item->magic & PATHSPEC_EXCLUDE) + continue; + if (!item->nowildcard_len || strstr(item->match, "//") || + starts_with(item->match, "./") || + strstr(item->match, "/./") || + strstr(item->match, "/../")) + return NULL; + positive = 1; + } + return positive ? pathspec : NULL; +} + +static int untracked_cache_preload_pathspec_matches( + const struct pathspec *pathspec, + const char *path, + size_t pathlen) +{ + int i; + + if (!pathspec || !pathlen) + return 1; + + for (i = 0; i < pathspec->nr; i++) { + const struct pathspec_item *item = &pathspec->items[i]; + size_t len = item->nowildcard_len; + int wildcard = len != item->len; + + if (item->magic & PATHSPEC_EXCLUDE) + continue; + if (!wildcard) + while (len && item->match[len - 1] == '/') + len--; + if (!len) + return 1; + if (strncmp(path, item->match, pathlen < len ? pathlen : len)) + continue; + if (pathlen == len || + (pathlen < len && item->match[pathlen] == '/') || + (pathlen > len && (wildcard || path[len] == '/'))) + return 1; + } + return 0; +} + static void collect_untracked_cache_preload_tasks( struct untracked_cache_dir *ucd, struct strbuf *path, struct untracked_cache_preload_task **tasks, size_t *nr, size_t *alloc, - int fsmonitor_excludes_only) + int fsmonitor_excludes_only, + const struct pathspec *pathspec) { size_t i; + if (!untracked_cache_preload_pathspec_matches( + pathspec, path->buf, path->len)) + return; + if (!fsmonitor_excludes_only || !is_null_oid(&ucd->exclude_oid)) { ALLOC_GROW(*tasks, *nr + 1, *alloc); @@ -165,7 +227,8 @@ static void collect_untracked_cache_preload_tasks( strbuf_addch(path, '/'); strbuf_addstr(path, child->name); collect_untracked_cache_preload_tasks(child, path, tasks, nr, - alloc, fsmonitor_excludes_only); + alloc, fsmonitor_excludes_only, + pathspec); strbuf_setlen(path, old_len); } } @@ -342,7 +405,7 @@ static void preload_fsmonitor_excludes_from_index( static struct untracked_cache_preload *untracked_cache_preload_start_1( struct index_state *istate, unsigned int dir_flags, int automatic, - int fsmonitor_excludes_only) + int fsmonitor_excludes_only, const struct pathspec *pathspec) { struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; @@ -362,13 +425,15 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( preload->istate = istate; preload->uc = uc; preload->root = uc->root; + preload->pathspec = fsmonitor_excludes_only ? + untracked_cache_preload_pathspec(pathspec) : NULL; preload->index_timestamp = istate->timestamp; preload->exclude_per_dir = xstrdup_or_null(uc->exclude_per_dir); preload->dir_flags = dir_flags; preload->fsmonitor_excludes_only = fsmonitor_excludes_only; collect_untracked_cache_preload_tasks( uc->root, &path, &preload->tasks, &preload->nr, &alloc, - fsmonitor_excludes_only); + fsmonitor_excludes_only, preload->pathspec); strbuf_release(&path); if (fsmonitor_excludes_only) { CALLOC_ARRAY(preload->exclude_index_oids, preload->nr); @@ -433,10 +498,11 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( struct untracked_cache_preload * untracked_cache_preload_start_fsmonitor_excludes( - struct index_state *istate, unsigned int dir_flags) + struct index_state *istate, unsigned int dir_flags, + const struct pathspec *pathspec) { return untracked_cache_preload_start_1( - istate, dir_flags, 0, 1); + istate, dir_flags, 0, 1, pathspec); } struct untracked_cache_preload *untracked_cache_preload_start_ordinary( @@ -447,7 +513,7 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( if (!uc || uc->dir_flags != dir_flags || !untracked_cache_auto_preload_worthwhile(uc)) return NULL; - return untracked_cache_preload_start_1(istate, dir_flags, 1, 0); + return untracked_cache_preload_start_1(istate, dir_flags, 1, 0, NULL); } static void *preload_untracked_cache_thread(void *_data) @@ -795,6 +861,26 @@ static int update_preloaded_exclude_index_uptodate( return marked; } +static void invalidate_scoped_preloaded_exclude( + struct untracked_cache_preload *preload, + const struct untracked_cache_preload_task *task) +{ + struct strbuf path = STRBUF_INIT; + + if (!preload->exclude_per_dir) { + preload->root->valid = 0; + preload->root->valid_recursive = 0; + return; + } + if (strcmp(task->path, ".")) { + strbuf_addstr(&path, task->path); + strbuf_addch(&path, '/'); + } + strbuf_addstr(&path, preload->exclude_per_dir); + untracked_cache_invalidate_path(preload->istate, path.buf, 1); + strbuf_release(&path); +} + int untracked_cache_preload_finish(struct untracked_cache_preload *preload, struct index_state *istate, unsigned int dir_flags, @@ -828,6 +914,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, oideq(&task->exclude_oid, &task->ucd->exclude_oid) && task->exclude_matches; + int exclude_invalidated = !exclude_matches; int exclude_revalidated; if (!exclude_matches) @@ -840,8 +927,12 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, update_preloaded_exclude_index_uptodate( preload, task, i, &normalized, &invalidated, &exclude_revalidated); - if (exclude_matches && exclude_revalidated == 0) + if (exclude_matches && exclude_revalidated == 0) { invalidate_gitignore(uc, task->ucd); + exclude_invalidated = 1; + } + if (preload->pathspec && exclude_invalidated) + invalidate_scoped_preloaded_exclude(preload, task); } if (normalized) istate->cache_changed |= UNTRACKED_CHANGED; @@ -864,6 +955,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, trace2_data_intmax( "dir", istate->repo, "preload_untracked_cache/valid", + preload->pathspec ? preload->root->valid_recursive : compute_untracked_cache_fsmonitor_valid_recursive( preload->root)); if (index_invalidated) @@ -3507,7 +3599,7 @@ static int refresh_cached_fsmonitor_files( struct strbuf path = STRBUF_INIT; const char *event, *end; size_t base_len, refreshed = 0; - int valid; + int valid, untracked_changed = 0; if (!uc || !untracked->valid || !untracked->fsmonitor_dirty || !uc->fsmonitor_dirty_paths.len) @@ -3523,6 +3615,7 @@ static int refresh_cached_fsmonitor_files( enum path_treatment state; const char *name; size_t i; + int was_untracked = 0; if (strncmp(event, path.buf, base_len)) goto next; @@ -3538,6 +3631,7 @@ static int refresh_cached_fsmonitor_files( untracked->untracked + i + 1, untracked->untracked_nr - i - 1); untracked->untracked_nr--; + was_untracked = 1; break; } @@ -3552,6 +3646,8 @@ static int refresh_cached_fsmonitor_files( } if (state == path_untracked) add_untracked(untracked, name); + if (was_untracked != (state == path_untracked)) + untracked_changed = 1; refreshed++; next: @@ -3562,6 +3658,10 @@ static int refresh_cached_fsmonitor_files( if (!refreshed || !untracked->valid) return 0; + if (untracked_changed) { + istate->cache_changed |= UNTRACKED_CHANGED; + istate->fsmonitor_untracked_must_persist = 1; + } untracked->fsmonitor_dirty = 0; untracked->has_untracked = !!untracked->untracked_nr; valid = untracked->valid; diff --git a/dir.h b/dir.h index c13d0db2866eaf..bdef37a0c60cf3 100644 --- a/dir.h +++ b/dir.h @@ -639,7 +639,8 @@ void untracked_cache_add_to_index(struct index_state *, const char *); struct untracked_cache_preload; struct untracked_cache_preload * untracked_cache_preload_start_fsmonitor_excludes( - struct index_state *, unsigned int dir_flags); + struct index_state *, unsigned int dir_flags, + const struct pathspec *pathspec); struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *, unsigned int dir_flags); int untracked_cache_preload_finish(struct untracked_cache_preload *, diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 38f3843bbbb9bb..3a03658853308c 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -261,6 +261,38 @@ static int server_supports_bound_queries(void) return ret; } +static int server_supports_required_capabilities(void) +{ +#ifdef __APPLE__ + struct strbuf answer = STRBUF_INIT; + int ret; + + ret = !try_send_command(FSMONITOR_IPC_CAPABILITY_COMMAND, + &answer, NULL) && + has_capability(&answer, FSMONITOR_IPC_QUERY_VERSION) && + has_capability(&answer, + FSMONITOR_IPC_DIR_METADATA_CAPABILITY); + strbuf_release(&answer); + return ret; +#else + return server_supports_bound_queries(); +#endif +} + +#ifdef __APPLE__ +static int query_identifies_filtered_daemon(const char *token, + const struct strbuf *answer) +{ + static const char prefix[] = + "builtin:" FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX; + const char *end = memchr(answer->buf, '\0', answer->len); + + return starts_with(token, prefix) && end && + (size_t)(end - answer->buf) >= sizeof(prefix) - 1 && + !memcmp(answer->buf, prefix, sizeof(prefix) - 1); +} +#endif + #if defined(__APPLE__) || defined(__linux__) static int legacy_peer_credentials( struct ipc_client_connection *connection, pid_t *pid) @@ -581,7 +613,7 @@ static int restart_incompatible_daemon(void) if (hold_lock_file_for_update_timeout(&restart_lock, lock_path.buf, LOCK_NO_DEREF, lock_timeout_ms) < 0) { - if (server_supports_bound_queries()) + if (server_supports_required_capabilities()) ret = 0; goto done; } @@ -595,7 +627,7 @@ static int restart_incompatible_daemon(void) int wait_result; /* Another client may have replaced the daemon while we waited. */ - if (server_supports_bound_queries()) + if (server_supports_required_capabilities()) goto success; if (!lstat(fsmonitor_ipc__get_path(the_repository), &socket_stat)) @@ -606,7 +638,7 @@ static int restart_incompatible_daemon(void) * Re-read its state before abandoning the upgrade. */ if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { - if (server_supports_bound_queries()) + if (server_supports_required_capabilities()) ret = 0; goto done; } @@ -640,6 +672,39 @@ static int restart_incompatible_daemon(void) return ret; } +#ifdef __APPLE__ +static int spawn_daemon_serialized(void) +{ + struct strbuf lock_path = STRBUF_INIT; + struct lock_file restart_lock = LOCK_INIT; + uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; + long lock_timeout_ms = timeout_ms > LONG_MAX ? + LONG_MAX : (long)timeout_ms; + int have_lock = 0; + int ret = -1; + + strbuf_addf(&lock_path, "%s.restart", + fsmonitor_ipc__get_path(the_repository)); + if (hold_lock_file_for_update_timeout(&restart_lock, lock_path.buf, + LOCK_NO_DEREF, + lock_timeout_ms) < 0) { + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) + ret = 0; + goto done; + } + have_lock = 1; + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING || + !spawn_daemon()) + ret = 0; + +done: + if (have_lock) + rollback_lock_file(&restart_lock); + strbuf_release(&lock_path); + return ret; +} +#endif + int fsmonitor_ipc__send_query(const char *since_token, struct strbuf *answer, int *legacy_worktree_authenticated) @@ -686,6 +751,18 @@ int fsmonitor_ipc__send_query(const char *since_token, trace2_data_intmax("fsm_client", NULL, "query/response-length", answer->len); +#ifdef __APPLE__ + if (!ret && !query_identifies_filtered_daemon(tok, answer) && + !server_supports_required_capabilities()) { + strbuf_reset(answer); + ret = -1; + if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS || + restart_incompatible_daemon()) + goto done; + options.wait_if_not_found = 1; + goto try_again; + } +#endif if (!ret && is_trivial_response(answer) && !server_supports_bound_queries()) { if (!try_send_attested_legacy_query( @@ -716,7 +793,11 @@ int fsmonitor_ipc__send_query(const char *since_token, if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS) goto done; +#ifdef __APPLE__ + if (spawn_daemon_serialized()) +#else if (spawn_daemon()) +#endif goto done; /* diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index daddca5b67fc9b..eff0798464b362 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -8,6 +8,8 @@ struct repository; #define FSMONITOR_IPC_QUERY_VERSION "query-v1" #define FSMONITOR_IPC_QUERY_PREFIX FSMONITOR_IPC_QUERY_VERSION " " #define FSMONITOR_IPC_CAPABILITY_COMMAND "get-capabilities" +#define FSMONITOR_IPC_DIR_METADATA_CAPABILITY "dir-metadata-filter-v1" +#define FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX "dirmeta-v1." #define FSMONITOR_IPC_WORKTREE_ID_HEX 64 /* Hash the canonical worktree root and its stable filesystem identity. */ diff --git a/fsmonitor.c b/fsmonitor.c index 795109d1169c3e..f08e15c3b6dcdc 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -647,7 +647,8 @@ static size_t handle_path_with_trailing_slash( nr_in_cone++; } - if (nr_in_cone) { + if (nr_in_cone && + !clean_status_directory_event_is_semantically_safe(istate, name)) { /* * A matched directory event may stand in for a nested * attribute-file change. @@ -665,6 +666,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) int len = strlen(name); int pos; int attributes_may_have_changed; + int directory_is_semantically_safe; size_t nr_in_cone; trace_printf_key(&trace_fsmonitor, @@ -687,12 +689,14 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) pos = index_name_pos(istate, name, len); attributes_may_have_changed = fsmonitor_invalidate_attributes_path(istate, name); + directory_is_semantically_safe = name[len - 1] == '/' && + clean_status_directory_event_is_semantically_safe(istate, name); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); else nr_in_cone = handle_path_without_trailing_slash(istate, name, pos); - if (pos < 0 && nr_in_cone) + if (pos < 0 && nr_in_cone && !directory_is_semantically_safe) attributes_may_have_changed = 1; /* diff --git a/read-cache-ll.h b/read-cache-ll.h index 2051bbd974e442..8d637a558cb717 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -191,6 +191,7 @@ struct index_state { fsmonitor_token_valid : 1, fsmonitor_extension_seen : 1, fsmonitor_untracked_valid : 1, + fsmonitor_untracked_must_persist : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, fsmonitor_legacy_untracked_adopted : 1, diff --git a/read-cache.c b/read-cache.c index 91575f07b5da69..26425fea31837b 100644 --- a/read-cache.c +++ b/read-cache.c @@ -777,7 +777,7 @@ void set_object_name_for_intent_to_add_entry(struct cache_entry *ce) int add_to_index(struct index_state *istate, const char *path, struct stat *st, int flags) { - int namelen, was_same, logical_same; + int namelen, was_same, logical_same, semantic_same; int cache_nr = istate->cache_nr; mode_t st_mode = st->st_mode; struct cache_entry *ce, *alias = NULL; @@ -864,9 +864,11 @@ int add_to_index(struct index_state *istate, const char *path, struct stat *st, oideq(&alias->oid, &ce->oid) && ce->ce_mode == alias->ce_mode); logical_same = same_persistent_add_entry(alias, ce); + semantic_same = clean_status_index_entry_is_semantically_safe( + istate, alias, ce); if (!pretend && (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && - !logical_same) + !logical_same && !semantic_same) clean_status_invalidate_current_proof(istate); if (pretend) @@ -877,7 +879,7 @@ int add_to_index(struct index_state *istate, const char *path, struct stat *st, return error(_("unable to add '%s' to index"), path); } if ((flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && - cache_nr != istate->cache_nr) + cache_nr != istate->cache_nr && !semantic_same) clean_status_invalidate_current_proof(istate); } if (verbose && !was_same) @@ -4287,7 +4289,14 @@ static void update_callback(struct diff_queue_struct *q, if (data->flags & ADD_CACHE_IGNORE_REMOVAL) break; if (!(data->flags & ADD_CACHE_PRETEND)) { - if (data->flags & ADD_CACHE_TRACK_CLEAN_HISTORY) + int pos = index_name_pos(data->index, path, + strlen(path)); + + if ((data->flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && + !clean_status_index_entry_is_semantically_safe( + data->index, + pos >= 0 ? data->index->cache[pos] : NULL, + NULL)) clean_status_invalidate_current_proof( data->index); remove_file_from_index(data->index, path); diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index 3be92e4fbd01ca..a37b7481f69e91 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -161,6 +161,7 @@ static int app__sendbytes_command(const char *received, size_t received_len, static int my_app_data = 42; static int fsmonitor_legacy; static int fsmonitor_capability_superset; +static int fsmonitor_pre_dir_metadata; static ipc_server_application_cb test_app_cb; @@ -170,7 +171,12 @@ static int app__fsmonitor_capability_superset( struct ipc_server_reply_data *reply_data) { static const char capability_command[] = "get-capabilities"; - static const char capabilities[] = "query-v1\nquery-v2\n"; + static const char capabilities[] = "query-v1\nquery-v2\n" +#ifdef __APPLE__ + "dir-metadata-filter-v1\n" +#endif + ; + static const char pre_dir_metadata_capabilities[] = "query-v1\n"; static const char query_prefix[] = "query-v1 "; static const char token[] = "builtin:test-capable:0"; const char *query; @@ -178,9 +184,14 @@ static int app__fsmonitor_capability_superset( int ret; if (command_len == sizeof(capability_command) - 1 && - !memcmp(command, capability_command, command_len)) + !memcmp(command, capability_command, command_len)) { + if (fsmonitor_pre_dir_metadata) + return reply_cb(reply_data, + pre_dir_metadata_capabilities, + sizeof(pre_dir_metadata_capabilities) - 1); return reply_cb(reply_data, capabilities, sizeof(capabilities) - 1); + } query = memchr(command, '\n', command_len); query_len = query ? command_len - (query + 1 - command) : 0; @@ -232,7 +243,7 @@ static int test_app_cb(void *application_data, return SIMPLE_IPC_QUIT; } - if (fsmonitor_capability_superset) + if (fsmonitor_capability_superset || fsmonitor_pre_dir_metadata) return app__fsmonitor_capability_superset( command, command_len, reply_cb, reply_data); @@ -359,6 +370,8 @@ static int daemon__start_server(void) strvec_push(&cp.args, "--fsmonitor-legacy"); if (fsmonitor_capability_superset) strvec_push(&cp.args, "--fsmonitor-capability-superset"); + if (fsmonitor_pre_dir_metadata) + strvec_push(&cp.args, "--fsmonitor-pre-dir-metadata"); cp.no_stdin = 1; cp.no_stdout = 1; @@ -656,6 +669,9 @@ int cmd__simple_ipc(int argc, const char **argv) OPT_BOOL(0, "fsmonitor-capability-superset", &fsmonitor_capability_superset, N_("advertise multiple fsmonitor query versions")), + OPT_BOOL(0, "fsmonitor-pre-dir-metadata", + &fsmonitor_pre_dir_metadata, + N_("emulate a daemon without directory metadata filtering")), /* * The "byte" string here is not marked for translation and diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index e6eb39f5c93b6b..483782ef174c7a 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -621,9 +621,9 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TRACE2_EVENT="$PWD/.git/exact.trace" \ git status --porcelain=v2 >.git/exact && test_must_be_empty .git/exact && - ! test_trace2_data status fsmonitor/tracked-clean 1 \ + test_trace2_data status fsmonitor/tracked-clean 1 \ <.git/exact.trace && - test_grep \ + test_grep ! \ "\"category\":\"index\",\"label\":\"refresh\"" \ .git/exact.trace && @@ -1606,6 +1606,412 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success MACOS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'plumbing diffs restore clean history lost by a foreign index writer' ' + test_when_finished "rm -rf plumbing-diff-history" && + test_create_repo plumbing-diff-history && + ( + cd plumbing-diff-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for prime in first second third + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime || return 1 + done && + test_must_be_empty .git/prime && + test_path_is_file .git/index.csts && + find .git -maxdepth 1 -type f -name "index.csh1.*" \ + >.git/checkpoints && + test_line_count = 1 .git/checkpoints && + + rm .git/index && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + read-tree HEAD && + test_grep ! FSMN .git/index && + cp .git/index .git/index.before && + + for diff_case in files index cached describe + do + case "$diff_case" in + files) set -- diff-files ;; + index) set -- diff-index HEAD -- ;; + cached) set -- diff-index --cached HEAD -- ;; + describe) set -- describe --dirty --tags ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$diff_case.trace" \ + git "$@" >".git/$diff_case.actual" && + if test "$diff_case" = describe + then + test_grep "^base$" ".git/$diff_case.actual" && + test_trace2_data index refresh/sum_lstat 0 \ + <".git/$diff_case.trace" + else + test_must_be_empty ".git/$diff_case.actual" + fi && + test_cmp_bin .git/index.before .git/index && + test_trace2_data fsmonitor history/external-restored 1 \ + <".git/$diff_case.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <".git/$diff_case.trace" && + test_grep ! "\"label\":\"do_write_index\"" \ + ".git/$diff_case.trace" || return 1 + done && + + test_write_lines changed >tracked && + for diff_case in files index describe + do + case "$diff_case" in + files) set -- diff-files -p ;; + index) set -- diff-index -p HEAD -- ;; + describe) set -- describe --dirty --tags ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/$diff_case-dirty.trace" \ + git "$@" >".git/$diff_case-dirty.actual" && + if test "$diff_case" = describe + then + test_grep "^base-dirty$" \ + ".git/$diff_case-dirty.actual" + else + test_grep "^+changed$" \ + ".git/$diff_case-dirty.actual" + fi && + test_trace2_data fsmonitor history/external-restored 1 \ + <".git/$diff_case-dirty.trace" && + test_cmp_bin .git/index.before .git/index || return 1 + done + ) +' + +test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'foreign index writers preserve unchanged worktree semantics' ' + test_when_finished "rm -rf foreign-semantic-history" && + test_when_finished \ + "git -C foreign-semantic-history fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo foreign-semantic-history && + ( + cd foreign-semantic-history && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir existing && + test_commit base existing/tracked && + test_commit retained existing/retained && + test-tool chmtime -120 existing/tracked existing/retained && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git update-index --fsmonitor && + for prime in first second third + do + git status --porcelain=v2 >.git/prime || return 1 + done && + find .git -maxdepth 1 -type f -name "index.cswi.*" \ + >.git/witnesses && + test_line_count = 1 .git/witnesses && + cat >.git/foreign-writer.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $name = $ARGV[0]; + my $rawsz = $name eq "sha256" ? 32 : 20; + for my $extension ("FSUC", "FSCF") { + my $offset = index($index, $extension); + next if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, + $name eq "sha256" ? sha256($payload) : sha1($payload); + EOF + + test_write_lines changed >existing/tracked && + git update-index --no-fsmonitor-valid existing/retained && + git update-index --add existing/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + test_grep ! FSCF .git/index && + git -c core.fsmonitor=false --no-optional-locks \ + status --porcelain=v2 >.git/existing.expect && + test_grep "^1 M\. .* existing/tracked$" .git/existing.expect && + GIT_TRACE2_EVENT="$PWD/.git/existing.trace" \ + git status --porcelain=v2 >.git/existing && + test_grep "^1 M\. .* existing/tracked$" .git/existing && + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 <.git/existing.trace && + test_trace2_data fsmonitor \ + history/external-untracked-restored 1 <.git/existing.trace && + test_trace2_data fsmonitor \ + history/external-tracked-restored 1 <.git/existing.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/existing.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/existing.trace && + + mkdir newdir && + test_write_lines new >newdir/tracked && + git update-index --add newdir/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + test_grep ! FSCF .git/index && + GIT_TRACE2_EVENT="$PWD/.git/newdir.trace" \ + git status --porcelain=v2 >.git/newdir && + test_grep "^1 A\. .* newdir/tracked$" .git/newdir && + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 <.git/newdir.trace && + test_trace2_data fsmonitor \ + history/external-untracked-restored 1 <.git/newdir.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/newdir.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/newdir.trace && + + mkdir existing/retired-directory && + test_write_lines transient >existing/retired-directory/file && + rm existing/retired-directory/file && + rmdir existing/retired-directory && + test_write_lines changed-again >existing/tracked && + git update-index --add existing/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + GIT_TRACE2_EVENT="$PWD/.git/retired.trace" \ + git status --porcelain=v2 >.git/retired && + test_grep "^1 M\. .* existing/tracked$" .git/retired && + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 <.git/retired.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/retired.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/retired.trace && + + test_write_lines "* text" >existing/.gitattributes && + git update-index --add existing/.gitattributes && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git status --porcelain=v2 >.git/attributes && + test_grep "^1 A\. .* existing/.gitattributes$" \ + .git/attributes && + ! test_trace2_data fsmonitor \ + history/external-semantic-restored <.git/attributes.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/attributes.trace && + + mkdir guarded && + test_write_lines "* text" >guarded/.gitattributes && + test_write_lines guarded >guarded/tracked && + git update-index --add guarded/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + GIT_TRACE2_EVENT="$PWD/.git/guarded.trace" \ + git status --porcelain=v2 >.git/guarded && + test_grep "^1 A\. .* guarded/tracked$" .git/guarded && + ! test_trace2_data fsmonitor \ + history/external-semantic-restored <.git/guarded.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/guarded.trace + ) +' + +test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'branch switches preserve existing authenticated index proofs' ' + test_when_finished "rm -rf switch-authenticated-history" && + test_when_finished \ + "git -C switch-authenticated-history fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo switch-authenticated-history && + ( + cd switch-authenticated-history && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p api/existing && + test_write_lines "*.txt text" >api/.gitattributes && + test_write_lines base >api/existing/tracked && + for sibling in $(test_seq 1 24) + do + mkdir "api/sibling-$sibling" && + test_write_lines retained \ + >"api/sibling-$sibling/tracked" || return 1 + done && + git add api && + git commit -m base && + initial_branch=$(git symbolic-ref --short HEAD) && + git switch -c replace-only && + test_write_lines replacement >api/existing/tracked && + git add api/existing/tracked && + git commit -m replacement && + git switch "$initial_branch" && + git switch -c alternate && + mkdir api/new-directory && + mkdir api/new-directory/__pycache__ && + test_write_lines existing >api/existing/added.txt && + test_write_lines new >api/new-directory/added.txt && + test_write_lines ignored \ + >api/new-directory/__pycache__/hidden.pyc && + git config core.excludesFile "$PWD/.git/test-excludes" && + test_write_lines "__pycache__/" >.git/test-excludes && + git add api/existing/added.txt api/new-directory/added.txt && + git commit -m alternate && + git switch "$initial_branch" && + test-tool chmtime -120 api/.gitattributes api/existing/tracked \ + api/sibling-*/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config index.recordEndOfIndexEntries false && + git config core.untrackedCache true && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git update-index --fsmonitor && + for prime in first second third + do + git status --porcelain=v2 >.git/prime || return 1 + done && + test_must_be_empty .git/prime && + find .git -maxdepth 1 -type f -name "index.cswi.*" \ + >.git/witnesses && + test_line_count = 1 .git/witnesses && + for branch in replace-only "$initial_branch" \ + alternate "$initial_branch" + do + GIT_TRACE2_EVENT="$PWD/.git/switch-$branch.trace" \ + GIT_TRACE2_EVENT_NESTING=10 \ + git switch "$branch" && + test_trace2_data fsmonitor history/semantic-transferred 1 \ + <".git/switch-$branch.trace" && + if test "$branch" = alternate + then + test_trace2_data fsmonitor \ + history/untracked-paired-new-directory-deferred 1 \ + <".git/switch-$branch.trace" && + test_grep ! FSUC .git/index + else + test_trace2_data fsmonitor \ + history/untracked-paired-transfer 1 \ + <".git/switch-$branch.trace" && + test_grep FSUC .git/index + fi && + git -c core.fsmonitor=false --no-optional-locks \ + status --porcelain=v2 >.git/expect && + GIT_TRACE2_EVENT="$PWD/.git/status-$branch.trace" \ + GIT_TRACE2_EVENT_NESTING=10 \ + GIT_TRACE2_PERF="$PWD/.git/status-$branch.perf" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <".git/status-$branch.trace" && + ! test_trace2_data fsmonitor config/invalid-extension 1 \ + <".git/status-$branch.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <".git/status-$branch.trace" && + ! test_trace2_data index preload/bulk_useful \ + <".git/status-$branch.trace" && + if test "$branch" = replace-only + then + test_trace2_data fsmonitor \ + checkout/untracked-replacement-targeted 1 \ + <".git/switch-$branch.trace" && + visited_dirs=$(sed -n \ + "s/.*directories-visited:\\([0-9][0-9]*\\).*/\\1/p" \ + ".git/status-$branch.perf") && + test "$visited_dirs" -lt 12 + elif test "$branch" = alternate + then + test_trace2_data fsmonitor \ + history/external-untracked-restored 1 \ + <".git/status-$branch.trace" && + visited_dirs=$(sed -n \ + "s/.*directories-visited:\\([0-9][0-9]*\\).*/\\1/p" \ + ".git/status-$branch.perf") && + test "$visited_dirs" -lt 12 + fi || return 1 + done && + + cat >.git/duplicate-fscf.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $name = $ARGV[0]; + my $rawsz = $name eq "sha256" ? 32 : 20; + my $payload = substr($index, 0, -$rawsz); + my $offset = index($payload, "FSCF"); + die "index has no FSCF extension\n" if $offset < 0; + my $size = unpack("N", substr($payload, $offset + 4, 4)); + $payload .= substr($payload, $offset, 8 + $size); + print $payload, + $name eq "sha256" ? sha256($payload) : sha1($payload); + EOF + perl .git/duplicate-fscf.pl "$(test_oid algo)" \ + <.git/index >.git/index.duplicate && + mv .git/index.duplicate .git/index && + GIT_TRACE2_EVENT="$PWD/.git/duplicate.trace" \ + git status --porcelain=v2 >.git/duplicate && + test_cmp .git/expect .git/duplicate && + test_trace2_data fsmonitor config/invalid-extension 1 \ + <.git/duplicate.trace && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <.git/duplicate.trace && + ! test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/duplicate.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'branch switches preserve unchanged worktree semantics' ' + test_when_finished "rm -rf switch-semantic-history" && + test_create_repo switch-semantic-history && + ( + cd switch-semantic-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines changed >tracked && + git add tracked && + git commit -m changed && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/switch.trace" \ + git switch --detach HEAD^ && + test_trace2_data fsmonitor history/semantic-transferred 1 \ + <.git/switch.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace + ) +' + test_expect_success PTHREADS,UNTRACKED_CACHE,SHA1 'load cache-tree and untracked-cache extensions in parallel' ' test_create_repo parallel-extensions && ( diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index c68f14443f3cdb..e01015e043eb91 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1022,16 +1022,18 @@ stop_git () { } stop_watchdog () { - while kill -0 $watchdog_pid + while test -n "$watchdog_pid" && + kill -0 "$watchdog_pid" 2>/dev/null do - kill $watchdog_pid + kill "$watchdog_pid" 2>/dev/null sleep 1 done + watchdog_pid= } test_expect_success !MINGW "submodule implicitly starts daemon by pull" ' test_atexit "stop_watchdog" && - test_when_finished "set +m; stop_git; rm -rf cloned super sub" && + test_when_finished "stop_watchdog; set +m; stop_git; rm -rf cloned super sub" && create_super super && create_sub sub && @@ -1436,6 +1438,96 @@ test_expect_success MACOS,HARDLINKS 'hardlink events invalidate all tracked path ) ' +test_expect_success MACOS,UNTRACKED_CACHE \ + 'directory timestamp events preserve clean fsmonitor proofs' ' + test_when_finished \ + "git -C directory-metadata fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo directory-metadata && + ( + cd directory-metadata && + mkdir -p api/nested other && + test_write_lines api >api/nested/tracked && + test_write_lines other >other/tracked && + git add api/nested/tracked other/tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + start_daemon --tf "$PWD/.git/daemon.trace" && + git status --porcelain=2 >.git/warm-one && + git status --porcelain=2 >.git/warm-two && + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + for mode in root nested xattr + do + case "$mode" in + root) directory=api ;; + nested) directory=api/nested ;; + xattr) + directory=api && + xattr -w com.git.fsmonitor.test ignored "$directory" + ;; + esac && + if test -f .git/index.csts + then + expect_sidecar_hit=t + else + expect_sidecar_hit= + fi && + touch "$directory" && + GIT_TRACE2_EVENT="$PWD/.git/touch.trace" \ + git status --porcelain=v2 -- "$directory" \ + >.git/touch && + test_must_be_empty .git/touch && + if test -n "$expect_sidecar_hit" + then + test_trace2_data status clean-proof/hit 1 \ + <.git/touch.trace && + test_grep ! "\"label\":\"do_read_index\"" \ + .git/touch.trace + fi && + test_grep ! "\"key\":\"semantic/attributes-cone\"" \ + .git/touch.trace && + test_grep ! "\"key\":\"semantic/manifest-scan-count\"" \ + .git/touch.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/touch.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/touch.trace && + test_grep ! "\"key\":\"preload/bulk_" \ + .git/touch.trace && + rm .git/touch.trace || return 1 + done && + test_grep "ignore-dir-metadata:.*api" .git/daemon.trace && + + mkdir api/created && + test_write_lines child >api/created/child && + git status --porcelain=v2 -- api >.git/created && + test_grep "^? api/created/$" .git/created && + rm api/created/child && + rmdir api/created && + git status --porcelain=v2 -- api >.git/removed && + test_must_be_empty .git/removed && + + test_write_lines "* text" >api/.gitattributes && + git status --porcelain=v2 -- api >.git/attributes && + test_grep "^? api/.gitattributes$" .git/attributes && + rm api/.gitattributes && + git status --porcelain=v2 -- api >.git/attributes-removed && + test_must_be_empty .git/attributes-removed && + + chmod 750 api && + git status --porcelain=v2 -- api >.git/chmod && + test_grep "fsevent:.*api.*ItemChangeOwner" \ + .git/daemon.trace && + chmod 755 api && + mv api/nested api/renamed && + git status --porcelain=v2 -- api >.git/renamed && + test_grep "^1 \\.D .*api/nested/tracked$" .git/renamed && + test_grep "^? api/renamed/$" .git/renamed + ) +' + test_expect_success MACOS 'implicit daemon reuses the invoking Git executable' ' test_create_repo same-executable-spawn && mkdir fake-exec-path && @@ -1546,6 +1638,48 @@ test_expect_success 'bound query replaces a legacy daemon' ' ) ' +test_expect_success MACOS 'bound query upgrades stale directory event daemon' ' + test_when_finished \ + "stop_daemon_delete_repo directory-daemon-upgrade" && + test_create_repo directory-daemon-upgrade && + ( + cd directory-daemon-upgrade && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >.git/before && + test_must_be_empty .git/before && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 \ + --fsmonitor-pre-dir-metadata && + + GIT_TRACE2_EVENT="$PWD/.git/upgrade.trace" \ + git status --porcelain=v2 >.git/upgrade && + test_must_be_empty .git/upgrade && + test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/upgrade.trace && + test_grep \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/upgrade.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "fsmonitor last update builtin:dirmeta-v1\\." \ + .git/fsmonitor && + + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git status --porcelain=v2 >.git/repeat && + test_cmp .git/upgrade .git/repeat && + ! test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/repeat.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/repeat.trace + ) +' + test_expect_success 'bound daemon also serves legacy token queries' ' test_when_finished "stop_daemon_delete_repo legacy-client-query" && test_create_repo legacy-client-query && @@ -1857,7 +1991,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'source-tree checkout drops history after an index change' ' + 'source-tree checkout preserves history after a nonsemantic index change' ' test_when_finished "rm -rf checkout-source-changed" && test_create_repo checkout-source-changed && ( @@ -1882,7 +2016,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && test_grep "^1 M\." .git/actual && - test_trace2_data fsmonitor config/coherent 0 \ + test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace ) ' @@ -2016,7 +2150,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'ordinary add drops history after a logical index change' ' + 'ordinary add preserves history after a nonsemantic index change' ' test_when_finished "rm -rf add-ordinary-changed" && test_create_repo add-ordinary-changed && ( @@ -2040,11 +2174,256 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && test_grep "^1 M\." .git/actual && - test_trace2_data fsmonitor config/coherent 0 \ + test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary staged paths preserve closed semantic history' ' + test_when_finished "rm -rf staged-semantic-history" && + test_create_repo staged-semantic-history && + ( + cd staged-semantic-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/staged.trace" \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 M\\..* tracked$" .git/staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/staged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/staged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/unstaged.trace" \ + git status --porcelain=v2 >.git/unstaged && + test_grep "^1 \\.M.* tracked$" .git/unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/unstaged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/unstaged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git reset HEAD -- tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git status --porcelain=v2 >.git/reset && + test_grep "^1 \\.M.* tracked$" .git/reset && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/reset.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/reset.trace && + + test_write_lines new >new-root-file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=new-root-file \ + git add new-root-file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/new-staged.trace" \ + git status --porcelain=v2 >.git/new-staged && + test_grep "^1 A\\..* new-root-file$" .git/new-staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/new-staged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/new-staged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged new-root-file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/new-unstaged.trace" \ + git status --porcelain=v2 >.git/new-unstaged && + test_grep "^? new-root-file$" .git/new-unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/new-unstaged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/new-unstaged.trace && + + mkdir -p brand-new/deeper && + test_write_lines nested >brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=brand-new/deeper/staged \ + git add brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-staged.trace" \ + git status --porcelain=v2 >.git/nested-staged && + test_grep "^1 A\\..* brand-new/deeper/staged$" \ + .git/nested-staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/nested-staged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/nested-staged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-unstaged.trace" \ + git status --porcelain=v2 >.git/nested-unstaged && + test_grep "^? brand-new/$" .git/nested-unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/nested-unstaged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/nested-unstaged.trace && + + test_write_lines "* text" >brand-new/.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=brand-new/deeper/staged \ + git add brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/attributes-fallback.trace" \ + git status --porcelain=v2 \ + >.git/attributes-fallback && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/attributes-fallback.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary staged paths reuse unchanged tracked ancestor attributes' ' + test_when_finished "rm -rf staged-tracked-ancestor-attributes" && + test_create_repo staged-tracked-ancestor-attributes && + ( + cd staged-tracked-ancestor-attributes && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p api/existing api/brand-new/deeper && + test_write_lines "*.txt text" >api/.gitattributes && + test_write_lines existing >api/existing/tracked && + git add api/.gitattributes api/existing/tracked && + git commit -m base && + initial_branch=$(git symbolic-ref --short HEAD) && + git switch -c changed-tree && + mkdir -p api/branch-only/deeper && + test_write_lines alternate >api/existing/alternate.txt && + test_write_lines alternate >api/branch-only/deeper/alternate.txt && + git add api/existing/alternate.txt \ + api/branch-only/deeper/alternate.txt && + git commit -m alternate && + git switch "$initial_branch" && + test-tool chmtime -120 api/.gitattributes api/existing/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + for location in api/existing/added.txt api/brand-new/deeper/added.txt + do + test_write_lines added >"$location" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH="$location" \ + git add "$location" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-add.trace" \ + git status --porcelain=v2 >.git/ancestor-add && + test_grep "^1 A\\..* $location$" .git/ancestor-add && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/ancestor-add.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/ancestor-add.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged "$location" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-remove.trace" \ + git status --porcelain=v2 >.git/ancestor-remove && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/ancestor-remove.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/ancestor-remove.trace && + rm "$location" .git/ancestor-add.trace \ + .git/ancestor-remove.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH="$location" \ + git status --porcelain=v2 >.git/ancestor-deleted && + test_must_be_empty .git/ancestor-deleted || return 1 + done && + + rmdir api/brand-new/deeper api/brand-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/brand-new/ \ + git status --porcelain=v2 >.git/before-switch && + test_must_be_empty .git/before-switch && + + for branch in changed-tree "$initial_branch" + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git switch "$branch" && + if test "$branch" = changed-tree + then + test_path_is_file api/branch-only/deeper/alternate.txt + else + test_path_is_missing api/branch-only + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-switch.trace" \ + git status --porcelain=v2 >.git/ancestor-switch && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/ancestor-switch.expect && + test_cmp .git/ancestor-switch.expect .git/ancestor-switch && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/ancestor-switch.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/ancestor-switch.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/ancestor-switch.trace && + rm .git/ancestor-switch.trace || return 1 + done && + + cp api/.gitattributes .git/attributes.saved && + rm api/.gitattributes && + test_write_lines missing >api/existing/missing.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/existing/missing.txt \ + git add api/existing/missing.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-missing.trace" \ + git status --porcelain=v2 >.git/ancestor-missing && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/ancestor-missing.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git restore --staged api/existing/missing.txt && + rm api/existing/missing.txt && + cp .git/attributes.saved api/.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/.gitattributes \ + git status --porcelain=v2 >.git/repaired && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/repaired-repeat && + + test_write_lines "*.txt -text" >api/.gitattributes && + test_write_lines changed >api/existing/changed.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/existing/changed.txt \ + git add api/existing/changed.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-changed.trace" \ + git status --porcelain=v2 >.git/ancestor-changed && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/ancestor-changed.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'ordinary add drops history after ITA resolution' ' test_when_finished "rm -rf add-ordinary-ita" && @@ -2265,6 +2644,682 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'read-only exact status skips a proof it cannot publish' ' + test_when_finished "rm -rf read-only-exact-true read-only-exact-false" && + for use_untracked_cache in true false + do + test_create_repo read-only-exact-$use_untracked_cache && + ( + cd read-only-exact-$use_untracked_cache && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache $use_untracked_cache && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=2 >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=2 >.git/prime-repeat && + test_path_is_missing .git/index.csts && + cp .git/index .git/before && + for label in first repeat + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace || return 1 + done && + test_cmp .git/before .git/index && + test_path_is_missing .git/index.csts && + if test_have_prereq MACOS + then + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/writable.trace" \ + git status --porcelain=v2 >.git/writable && + test_trace2_data status clean-proof/sidecar 1 \ + <.git/writable.trace && + test_path_is_file .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/hit.trace" \ + git status --porcelain=v2 >.git/hit && + test_trace2_data status clean-proof/hit 1 \ + <.git/hit.trace && + test_grep ! "\"label\":\"do_read_index\"" .git/hit.trace + fi + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-only status preserves an existing untracked proof' ' + test_when_finished "rm -rf tracked-only-untracked-proof" && + test_create_repo tracked-only-untracked-proof && + ( + cd tracked-only-untracked-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + test_grep "^? outside-new$" .git/prime-repeat && + test_grep FSUC .git/index && + git config status.showUntrackedFiles no && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/configured-first.trace" \ + git status --porcelain=v2 >.git/configured-first && + test_must_be_empty .git/configured-first && + test_grep FSUC .git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/configured-first.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/configured-first.trace && + for label in exact plain short + do + case "$label" in + exact) set -- --porcelain=v2 ;; + plain) set -- ;; + short) set -- --short ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status "$@" >.git/$label && + test_grep ! "outside-new" .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace || return 1 + done && + test_write_lines selected >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT="$PWD/.git/hidden-new.trace" \ + git status --porcelain=v2 >.git/hidden-new && + test_trace2_data fsmonitor apply_count 1 \ + <.git/hidden-new.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/hidden-new-repeat.trace" \ + git status --porcelain=v2 >.git/hidden-new-repeat && + for label in hidden-new hidden-new-repeat + do + test_must_be_empty .git/$label && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + if test "$label" = hidden-new + then + test_grep "\"label\":\"do_write_index\"" \ + .git/$label.trace + else + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace + fi && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + --untracked-files=normal >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/visible.trace" \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual && + test_write_lines changed >tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/changed.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain=v2 >.git/changed.actual && + test_cmp .git/changed.expect .git/changed.actual && + test_grep "^1 \\.M .* tracked$" .git/changed.actual && + test_grep ! "outside-new" .git/changed.actual && + test_grep "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/changed.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ignored status preserves an existing untracked proof' ' + test_when_finished "rm -rf ignored-untracked-proof" && + test_create_repo ignored-untracked-proof && + ( + cd ignored-untracked-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test_write_lines "*.ignored" >.gitignore && + git add .gitignore && + git commit -m ignore && + test-tool chmtime -120 tracked scoped/tracked .gitignore && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines outside >outside-new && + test_write_lines ignored >skip.ignored && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + test_grep FSUC .git/index && + git config status.showUntrackedFiles normal && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + --ignored >.git/ignored.expect && + for label in first repeat + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 --ignored >.git/$label && + test_cmp .git/ignored.expect .git/$label && + test_grep FSUC .git/index && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace && + if test "$label" = repeat + then + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace + fi || return 1 + done && + test_grep "^? outside-new$" .git/repeat && + test_grep "^! skip\\.ignored$" .git/repeat && + test_write_lines selected >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain=v2 --ignored >.git/changed && + test_trace2_data fsmonitor apply_count 1 \ + <.git/changed.trace && + test_grep "^? scoped/new$" .git/changed && + test_grep "^! skip\\.ignored$" .git/changed && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual + ) +' + +test_expect_success UNTRACKED_CACHE,HARDLINKS,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'status modes revalidate cached exclude contents' ' + test_when_finished "rm -rf all-untracked-excludes" && + test_when_finished "rm -f all-untracked-exclude-alias" && + test_create_repo all-untracked-excludes && + ( + cd all-untracked-excludes && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines ignored >cached/.gitignore && + test_write_lines hidden >cached/ignored && + git add cached/.gitignore && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + ln cached/.gitignore ../all-untracked-exclude-alias && + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible >../all-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + cp .git/index .git/index.before && + for label in all-root all-directory all-exclude \ + normal-directory normal-exclude \ + tracked-root tracked-directory tracked-exclude \ + ignored-root ignored-directory ignored-exclude + do + cp .git/index.before .git/index && + case "$label" in + all-root) set -- --untracked-files=all ;; + all-directory) set -- --untracked-files=all -- cached ;; + all-exclude) \ + set -- --untracked-files=all -- cached/.gitignore ;; + normal-directory) set -- -- cached ;; + normal-exclude) set -- -- cached/.gitignore ;; + tracked-root) set -- --untracked-files=no ;; + tracked-directory) set -- --untracked-files=no -- cached ;; + tracked-exclude) \ + set -- --untracked-files=no -- cached/.gitignore ;; + ignored-root) set -- --ignored ;; + ignored-directory) set -- --ignored -- cached ;; + ignored-exclude) set -- --ignored -- cached/.gitignore ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 "$@" \ + >.git/$label.actual && + test_grep "^1 \\.M .* cached/.gitignore$" \ + .git/$label.actual && + test_trace2_data status \ + fsmonitor/exclude-index-invalidated 1 \ + <.git/$label.trace && + case "$label" in + all-root|all-directory|normal-directory|ignored-root|ignored-directory) + test_grep "^? cached/ignored$" \ + .git/$label.actual ;; + *) : ;; + esac || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked pathspecs reuse scoped fsmonitor proofs' ' + test_when_finished "rm -rf scoped-fsmonitor-proof" && + test_create_repo scoped-fsmonitor-proof && + ( + cd scoped-fsmonitor-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit root-prefix track && + test_commit root-longer tracked-extra && + mkdir scoped other && + test_commit selected scoped/tracked && + mkdir scoped/deep && + test_commit excluded scoped/deep/tracked && + test_commit unrelated other/tracked && + test-tool chmtime -120 track tracked tracked-extra scoped/tracked \ + scoped/deep/tracked other/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + git config status.showUntrackedFiles all && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/all-prime && + test_must_be_empty .git/all-prime && + test_write_lines nested >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT="$PWD/.git/first.trace" \ + git status --porcelain=v2 -- tracked >.git/first && + test_must_be_empty .git/first && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + -- scoped/tracked/ >.git/trailing-first.expect \ + 2>.git/trailing-first.expect.err && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/trailing-first.trace" \ + git status --porcelain=v2 -- scoped/tracked/ \ + >.git/trailing-first.actual \ + 2>.git/trailing-first.actual.err && + test_cmp .git/trailing-first.expect \ + .git/trailing-first.actual && + test_cmp .git/trailing-first.expect.err \ + .git/trailing-first.actual.err && + test_grep "could not open directory" \ + .git/trailing-first.actual.err && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/trailing-first.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/trailing-first.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/trailing-first.trace && + for label in repeat repeated + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- tracked >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 -- scoped \ + ":(exclude)scoped/deep" >.git/directory-prime && + test_grep "^? scoped/new$" .git/directory-prime && + for label in directory directory-repeat mixed \ + excluded-glob excluded-icase + do + case "$label" in + directory|directory-repeat) \ + set -- scoped ":(exclude)scoped/deep" ;; + mixed) set -- tracked scoped ":(exclude)scoped/deep" ;; + excluded-glob) \ + set -- scoped ":(exclude,glob)scoped/deep/**" ;; + excluded-icase) \ + set -- scoped ":(exclude,icase)SCOPED/DEEP" ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" >.git/$label && + test_grep "^? scoped/new$" .git/$label && + test_line_count = 1 .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + test_write_lines deep >scoped/deep/new && + for label in excluded-self excluded-all-files + do + query_sequence=CCCC && + case "$label" in + excluded-self) query_sequence=DDCCC && + set -- scoped ":(exclude)scoped" ;; + excluded-all-files) \ + set -- scoped/deep ":(exclude)scoped/deep/tracked" ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=$query_sequence \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/deep/new \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" >.git/$label && + if test "$label" = excluded-self + then + test_must_be_empty .git/$label && + test_trace2_data fsmonitor apply_count 1 \ + <.git/$label.trace + else + test_grep "^? scoped/deep/new$" .git/$label && + test_line_count = 1 .git/$label + fi && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 -- scoped/deep \ + >.git/deep-prime && + test_grep "^? scoped/deep/new$" .git/deep-prime && + for label in nested-directory nested-cwd + do + status_dir=. && + case "$label" in + nested-directory) set -- scoped/deep/ ;; + nested-cwd) status_dir=scoped/deep && set -- . ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git -C "$status_dir" status --porcelain=v2 \ + -- "$@" >.git/$label && + if test "$label" = nested-cwd + then + test_grep "^? new$" .git/$label + else + test_grep "^? scoped/deep/new$" .git/$label + fi && + test_line_count = 1 .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 -- "track*" \ + >.git/root-wildcard-prime && + for label in wildcard wildcard-glob wildcard-deep \ + wildcard-mixed wildcard-excluded \ + wildcard-root wildcard-root-glob wildcard-root-question \ + wildcard-root-excluded untracked-exact untracked-missing \ + untracked-wildcard untracked-mixed nested-trailing + do + case "$label" in + wildcard) set -- "scoped/*" ;; + wildcard-glob) set -- ":(glob)scoped/*" ;; + wildcard-deep) set -- "scoped/deep/trac*" ;; + wildcard-mixed) set -- tracked "scoped/*" ;; + wildcard-excluded) \ + set -- "scoped/*" ":(exclude)scoped/deep" ;; + wildcard-root) set -- "track*" ;; + wildcard-root-glob) set -- ":(glob)track*" ;; + wildcard-root-question) set -- "track?*" ;; + wildcard-root-excluded) \ + set -- "track*" ":(exclude)tracked-extra" ;; + untracked-exact) set -- scoped/new ;; + untracked-missing) set -- missing ;; + untracked-wildcard) set -- "missing-*" ;; + untracked-mixed) set -- tracked scoped/new ;; + nested-trailing) set -- scoped/tracked/ ;; + esac && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 -- "$@" >.git/$label.expect \ + 2>.git/$label.expect.err && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" \ + >.git/$label.actual 2>.git/$label.actual.err && + test_cmp .git/$label.expect .git/$label.actual && + test_cmp .git/$label.expect.err .git/$label.actual.err && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'explicit all-untracked status retains configured normal history' ' + test_when_finished "rm -rf explicit-all-untracked-history" && + test_create_repo explicit-all-untracked-history && + ( + cd explicit-all-untracked-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit nested scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for label in first second third + do + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --untracked-files=all \ + >.git/$label.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 --untracked-files=all \ + >.git/$label.actual && + test_cmp .git/$label.expect .git/$label.actual && + if test "$label" != first + then + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace + fi || return 1 + done && + test_write_lines outside >outside-new && + test_write_lines nested >scoped/new && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --untracked-files=all \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + git status --porcelain=v2 --untracked-files=all \ + >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? outside-new$" .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ignored submodule pathspecs avoid needless tracked refresh' ' + test_when_finished "rm -rf ignored-submodule-proof" && + test_create_repo ignored-submodule-proof && + ( + cd ignored-submodule-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit root tracked && + git init -q child && + git -C child config user.name "Submodule Fixture" && + git -C child config user.email fixture@example.invalid && + test_write_lines original >child/tracked && + git -C child add tracked && + git -C child commit -qm base && + git add child && + git commit -qm "add child gitlink" && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for label in prime repeat + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --ignore-submodules=all -- child \ + >.git/$label || return 1 + done && + for label in clean dirty committed staged + do + case "$label" in + dirty) test_write_lines modified >child/tracked ;; + committed) + git -C child add tracked && + git -C child commit -qm changed ;; + staged) + git add child && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --ignore-submodules=all -- child \ + >.git/staged-prime ;; + esac && + for shape in scoped root + do + if test "$shape" = scoped + then + set -- -- child + else + set -- + fi && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --ignore-submodules=all "$@" \ + >.git/$label-$shape.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label-$shape.trace" \ + git status --porcelain=v2 \ + --ignore-submodules=all "$@" \ + >.git/$label-$shape.actual && + test_cmp .git/$label-$shape.expect \ + .git/$label-$shape.actual && + if test "$label" != staged || test "$shape" != root + then + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label-$shape.trace && + test_grep ! \ + "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label-$shape.trace && + test_grep ! \ + "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label-$shape.trace + fi || return 1 + done || return 1 + done && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --ignore-submodules=none -- child \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --ignore-submodules=none -- child \ + >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'tracked-directory pathspec reads a closed untracked-cache subtree' ' test_when_finished "rm -rf pathspec-cached-subtree" && @@ -2273,9 +3328,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ cd pathspec-cached-subtree && sane_unset GIT_TEST_SPLIT_INDEX && test_commit base tracked && - mkdir scoped && + mkdir scoped scope scoped-extra aaa zzz && test_commit selected scoped/tracked && - test-tool chmtime -120 tracked scoped/tracked && + test_commit prefix scope/tracked && + test_commit extended scoped-extra/tracked && + test_commit before aaa/tracked && + test_commit after zzz/tracked && + test-tool chmtime -120 tracked scoped/tracked scope/tracked \ + scoped-extra/tracked aaa/tracked zzz/tracked && git update-index --refresh && git config core.untrackedCache true && git config core.fsmonitor true && @@ -2304,7 +3364,354 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git -C scoped status --porcelain=v2 -- . >.git/nested && test_grep "^? new$" .git/nested && test_trace2_data status untracked/pathspec-cache 1 \ - <.git/nested.trace + <.git/nested.trace && + for label in tracked-file nested-file tracked-files nested-files \ + root-trailing root-trailing-glob root-trailing-nested \ + root-trailing-top root-trailing-top-nested \ + excluded excluded-self excluded-first \ + glob glob-nested glob-excluded \ + excluded-wildcard excluded-icase excluded-attr \ + all ignored ignored-matching + do + status_dir=. && + case "$label" in + tracked-file) set -- -- scoped/tracked ;; + nested-file) status_dir=scoped && set -- -- tracked ;; + tracked-files) set -- -- tracked scoped/tracked ;; + nested-files) status_dir=scoped && \ + set -- -- tracked ../tracked ;; + root-trailing) set -- -- tracked/ ;; + root-trailing-glob) set -- -- ":(glob)tracked/" ;; + root-trailing-nested) status_dir=scoped && \ + set -- -- ../tracked/ ;; + root-trailing-top) set -- -- ":(top,literal)tracked//" ;; + root-trailing-top-nested) status_dir=scoped && \ + set -- -- ":(top)tracked//" ;; + excluded) set -- -- tracked ":(exclude)scoped/tracked" ;; + excluded-self) set -- -- tracked ":(exclude)tracked" ;; + excluded-first) set -- -- ":(exclude)scoped/tracked" tracked ;; + glob) set -- -- ":(glob)tracked" ;; + glob-nested) set -- -- ":(glob)scoped/tracked" ;; + glob-excluded) set -- -- ":(glob)tracked" \ + ":(exclude,glob)scoped/tracked" ;; + excluded-wildcard) set -- -- tracked \ + ":(exclude,glob)scoped/*" ;; + excluded-icase) set -- -- tracked \ + ":(exclude,icase)SCOPED/TRACKED" ;; + excluded-attr) set -- -- tracked \ + ":(exclude,attr:proof)scoped/tracked" ;; + all) set -- --untracked-files=all -- tracked scoped/tracked ;; + ignored) set -- --ignored -- tracked scoped/tracked ;; + ignored-matching) set -- --ignored=matching -- \ + tracked scoped/tracked ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git -C "$status_dir" status --porcelain=v2 \ + "$@" >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/$label.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + for label in excluded-only glob-wildcard positive-icase \ + mixed-ignored mixed-untracked mixed-directory + do + expect_untracked=outside-new && + case "$label" in + excluded-only) set -- -- ":(exclude)scoped/tracked" ;; + glob-wildcard) set -- -- ":(glob)*" ;; + positive-icase) set -- -- ":(icase)OUTSIDE-NEW" ;; + mixed-ignored) expect_untracked=scoped/new && \ + set -- --ignored -- tracked scoped ;; + mixed-untracked) set -- -- tracked outside-new ;; + mixed-directory) expect_untracked=scoped/new && \ + set -- -- tracked scoped ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 "$@" >.git/$label && + test_grep "^? $expect_untracked$" .git/$label && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-trailing.trace" \ + git status --porcelain=v2 -- scoped/tracked/ \ + >.git/nested-trailing \ + 2>.git/nested-trailing.err && + test_must_be_empty .git/nested-trailing && + test_grep "could not open directory" \ + .git/nested-trailing.err && + test_grep "\"label\":\"read_directory\"" \ + .git/nested-trailing.trace && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-repeated.trace" \ + test_must_fail git status --porcelain=v2 -- \ + ":(top)scoped//tracked" \ + >.git/nested-repeated 2>.git/nested-repeated.err && + test_grep "fatal: oops in prep_exclude" \ + .git/nested-repeated.err && + rm scoped/tracked && + mkdir scoped/tracked && + test_write_lines child >scoped/tracked/new && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + -- scoped/tracked >.git/tracked-file-dirty.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/tracked-file-dirty.trace" \ + git status --porcelain=v2 -- scoped/tracked \ + >.git/tracked-file-dirty.actual && + test_cmp .git/tracked-file-dirty.expect \ + .git/tracked-file-dirty.actual && + test_grep "scoped/tracked" .git/tracked-file-dirty.actual && + test_grep "\"label\":\"read_directory\"" \ + .git/tracked-file-dirty.trace + ) +' + +assert_clean_tracked_status () { + label=$1 && + directory=$2 && + shift 2 && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -C "$directory" status "$@" >".git/$label.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git -C "$directory" status "$@" >".git/$label.actual" && + test_cmp_bin ".git/$label.expect" ".git/$label.actual" && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <".git/$label.trace" && + test_trace2_data status index/cache-tree-match 1 \ + <".git/$label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + ".git/$label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + ".git/$label.trace" +} + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked file pathspecs avoid traversal without an untracked cache' ' + test_when_finished "rm -rf pathspec-no-untracked-cache" && + test_create_repo pathspec-no-untracked-cache && + ( + cd pathspec-no-untracked-cache && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.untrackedCache false && + git config core.fsmonitor true && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor --no-untracked-cache && + test_grep ! UNTR .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/exact.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/exact.trace" \ + git status --porcelain=v2 >.git/exact.actual && + test_cmp .git/exact.expect .git/exact.actual && + test_grep "^? outside-new$" .git/exact.actual && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/exact.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/exact.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/exact.trace && + for label in one multiple excluded glob glob-excluded \ + excluded-special root-trailing root-trailing-top + do + case "$label" in + one) set -- scoped/tracked ;; + multiple) set -- tracked scoped/tracked ;; + excluded) set -- tracked ":(exclude)scoped/tracked" ;; + glob) set -- ":(glob)scoped/tracked" ;; + glob-excluded) set -- ":(glob)tracked" \ + ":(exclude,glob)scoped/tracked" ;; + excluded-special) set -- tracked \ + ":(exclude,glob)scoped/*" \ + ":(exclude,icase)SCOPED/TRACKED" \ + ":(exclude,attr:proof)scoped/tracked" ;; + root-trailing) set -- tracked/ ;; + root-trailing-top) set -- ":(top,literal)tracked//" ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/$label.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/mixed.trace" \ + git status --porcelain=v2 -- tracked outside-new \ + >.git/mixed && + test_grep "^? outside-new$" .git/mixed && + test_grep "\"label\":\"read_directory\"" .git/mixed.trace && + test_write_lines changed >scoped/tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/closing-dirty.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/closing-dirty.trace" \ + git status --porcelain=v2 >.git/closing-dirty.actual && + test_cmp .git/closing-dirty.expect .git/closing-dirty.actual && + test_grep "^1 \\.M .* scoped/tracked$" \ + .git/closing-dirty.actual && + test_grep "^? outside-new$" .git/closing-dirty.actual && + test_grep ! "\"key\":\"fsmonitor/tracked-clean\"" \ + .git/closing-dirty.trace && + test_grep "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/closing-dirty.trace && + test_write_lines selected >scoped/tracked && + test-tool chmtime -120 scoped/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + git update-index --refresh && + rm outside-new && + git config core.preloadIndex false && + for label in prime prime-repeat + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --untracked-files=normal >.git/clean-$label && + test_must_be_empty .git/clean-$label || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean-issue.trace" \ + git status --porcelain=v2 >.git/clean-issue && + test_must_be_empty .git/clean-issue && + if test_have_prereq MACOS + then + test_trace2_data status clean-proof/sidecar 1 \ + <.git/clean-issue.trace && + test_path_is_file .git/index.csts && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean-hit.trace" \ + git status --porcelain=v2 >.git/clean-hit && + test_must_be_empty .git/clean-hit && + test_trace2_data status clean-proof/hit 1 \ + <.git/clean-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" \ + .git/clean-hit.trace + fi + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'clean tracked entries avoid refresh across dirty status shapes' ' + test_when_finished "rm -rf tracked-clean-status-shapes" && + test_create_repo tracked-clean-status-shapes && + ( + cd tracked-clean-status-shapes && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped scoped-extra sibling && + test_commit selected scoped/tracked && + test_commit colliding scoped-extra/tracked && + test_commit other sibling/tracked && + test-tool chmtime -120 \ + tracked scoped/tracked scoped-extra/tracked sibling/tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines selected >scoped/new && + test_write_lines sibling >sibling/new && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_grep "^? scoped/new$" .git/prime && + test_grep "^? sibling/new$" .git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-repeat && + test_cmp .git/prime .git/prime-repeat && + test_path_is_missing .git/index.csts && + + assert_clean_tracked_status root-long . && + assert_clean_tracked_status root-short . --short && + assert_clean_tracked_status root-porcelain . --porcelain && + assert_clean_tracked_status root-v2-exact . --porcelain=v2 && + assert_clean_tracked_status root-v2 . \ + --porcelain=v2 --untracked-files=normal && + assert_clean_tracked_status root-daemon . \ + --porcelain=v2 -z --branch --show-stash \ + --no-ahead-behind --untracked-files=normal \ + --ignore-submodules=all && + assert_clean_tracked_status scoped-long . -- scoped && + assert_clean_tracked_status scoped-v2 . \ + --porcelain=v2 -- scoped && + assert_clean_tracked_status sibling-v2 . \ + --porcelain=v2 -- sibling && + assert_clean_tracked_status nested-root scoped \ + --porcelain=v2 --untracked-files=normal && + assert_clean_tracked_status nested-scoped scoped \ + --porcelain=v2 -- . && + assert_clean_tracked_status multiple-v2 . \ + --porcelain=v2 -- scoped sibling && + test_grep "^? scoped/new$" .git/scoped-v2.actual && + test_grep ! "sibling/new\|outside-new" \ + .git/scoped-v2.actual && + test_grep "^? sibling/new$" .git/sibling-v2.actual && + test_grep ! "scoped/new\|outside-new" \ + .git/sibling-v2.actual && + test_grep "^? new$" .git/nested-scoped.actual && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/scoped-v2.trace && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/sibling-v2.trace && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/nested-scoped.trace && + + test_write_lines changed >scoped/tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/closing-dirty.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/closing-dirty.trace" \ + git status --porcelain=v2 >.git/closing-dirty.actual && + test_cmp .git/closing-dirty.expect .git/closing-dirty.actual && + test_grep "^1 \\.M .* scoped/tracked$" .git/closing-dirty.actual && + test_grep ! "\"key\":\"fsmonitor/tracked-clean\"" \ + .git/closing-dirty.trace && + + test_write_lines changed >scoped/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/dirty-tracked.trace" \ + git status --porcelain=v2 -- scoped \ + >.git/dirty-tracked.actual && + test_grep "^1 \\.M .* scoped/tracked$" \ + .git/dirty-tracked.actual && + test_grep "^? scoped/new$" .git/dirty-tracked.actual && + test_grep ! "sibling/new\|outside-new" \ + .git/dirty-tracked.actual && + ! test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/dirty-tracked.trace ) ' @@ -2323,6 +3730,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_write_lines "ignored-dir/" >scoped/.gitignore && git add .gitignore scoped/.gitignore && git commit -qm "add tracked ignore files" && + test-tool chmtime -120 tracked scoped/tracked outside/tracked \ + .gitignore scoped/.gitignore && + git update-index --refresh && git config core.untrackedCache true && git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ @@ -2348,6 +3758,17 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/created.trace && test_grep ! "\"label\":\"read_directory\"" \ .git/created.trace && + test_grep "\"label\":\"do_write_index\"" \ + .git/created.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/created-repeat.trace" \ + git status --porcelain=v2 -- scoped >.git/created-repeat && + test_cmp .git/created .git/created-repeat && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/created-repeat.trace && rm scoped/new && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ @@ -2366,6 +3787,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/removed.trace && test_grep ! "\"label\":\"read_directory\"" \ .git/removed.trace && + test_grep "\"label\":\"do_write_index\"" \ + .git/removed.trace && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT_NESTING=5 \ diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index eddcc3e8d08f51..788ed1cf9aa75a 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -964,6 +964,51 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" reissued-hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'a plain clean status repairs a stale proof with an invalid cache tree' ' + test_when_finished "stop_daemon sidecar-invalid-tree" && + setup_repo sidecar-invalid-tree && + git -C sidecar-invalid-tree config core.untrackedCache true && + issue_sidecar sidecar-invalid-tree && + cp sidecar-invalid-tree/.git/index.csts stale-proof && + cp sidecar-invalid-tree/tracked tracked.original && + test_write_lines changed >sidecar-invalid-tree/tracked && + git -C sidecar-invalid-tree add tracked && + cp tracked.original sidecar-invalid-tree/tracked && + test-tool chmtime -120 sidecar-invalid-tree/tracked && + git -C sidecar-invalid-tree add tracked && + test-tool -C sidecar-invalid-tree dump-cache-tree >tree.dump && + test_grep "^invalid " tree.dump && + GIT_OPTIONAL_LOCKS=0 \ + git -C sidecar-invalid-tree status --porcelain=v2 >before && + test_must_be_empty before && + cp stale-proof sidecar-invalid-tree/.git/index.csts && + rm -f sidecar-invalid-tree/.git/index.csh1.* && + cp sidecar-invalid-tree/.git/index invalid-tree.index && + test_env GIT_TRACE2_EVENT="$PWD/invalid-tree-reissue.trace" \ + git -C sidecar-invalid-tree status >actual && + test_grep "working tree clean" actual && + test_cmp invalid-tree.index sidecar-invalid-tree/.git/index && + test_trace2_data status index/full-tree-match 1 \ + actual.hit && + test_cmp actual actual.hit && + test_trace2_data status clean-proof/hit 1 \ + rawsz)); cl_assert(memcmp(initial.namespace_hash, metadata.namespace_hash, algo->rawsz)); + cl_assert(memcmp(initial.portable_namespace_hash, + metadata.portable_namespace_hash, algo->rawsz)); write_file(path.buf, "*.txt -text\n"); fingerprint(path.buf, 1, algo, &changed); cl_assert(memcmp(metadata.content_hash, changed.content_hash, algo->rawsz)); + cl_assert(memcmp(metadata.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); strbuf_release(&path); remove_directory(directory); @@ -100,6 +105,8 @@ void test_attr_fingerprint__records_missing_parent_namespaces(void) cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); cl_assert(memcmp(before.namespace_hash, after.namespace_hash, algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); strbuf_release(&path); remove_directory(directory); @@ -121,7 +128,118 @@ void test_attr_fingerprint__does_not_observe_disabled_sources(void) cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); cl_assert(!memcmp(before.namespace_hash, after.namespace_hash, algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); strbuf_release(&path); remove_directory(directory); } + +void test_attr_fingerprint__equates_distinct_absent_source_paths(void) +{ +#ifndef O_NONBLOCK + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + char *directory; + struct strbuf absent_a = STRBUF_INIT; + struct strbuf absent_b = STRBUF_INIT; + struct strbuf present = STRBUF_INIT; + struct attr_fingerprint_source first[2], second[2]; + struct attr_fingerprint before, after, changed; + + if (!fstat_is_reliable()) + cl_skip(); + directory = create_directory(); + strbuf_addf(&absent_a, "%s/system-a/attributes", directory); + strbuf_addf(&absent_b, "%s/system-b/attributes", directory); + strbuf_addf(&present, "%s/global-attributes", directory); + write_file(present.buf, "*.txt text\n"); + + first[0] = (struct attr_fingerprint_source) { + .path = absent_a.buf, + .enabled = 1, + }; + first[1] = (struct attr_fingerprint_source) { + .path = present.buf, + .enabled = 1, + }; + second[0] = first[0]; + second[0].path = absent_b.buf; + second[1] = first[1]; + + cl_assert_equal_i(attr_fingerprint_sources( + first, ARRAY_SIZE(first), algo, &before), 0); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &after), 0); + cl_assert(before.sources_present); + cl_assert(after.sources_present); + cl_assert(!memcmp(before.content_hash, after.content_hash, + algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); + cl_assert(memcmp(before.namespace_hash, after.namespace_hash, + algo->rawsz)); + + second[0].enabled = 0; + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &changed), 0); + cl_assert(memcmp(before.content_hash, changed.content_hash, + algo->rawsz)); + cl_assert(memcmp(before.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); + + first[0].enabled = 0; + cl_assert_equal_i(attr_fingerprint_sources( + first, ARRAY_SIZE(first), algo, &before), 0); + cl_assert(!memcmp(before.content_hash, changed.content_hash, + algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); + cl_assert(memcmp(before.namespace_hash, changed.namespace_hash, + algo->rawsz)); + + first[0].enabled = 1; + first[0].path = present.buf; + first[1].path = absent_a.buf; + second[0].enabled = 1; + cl_assert_equal_i(attr_fingerprint_sources( + first, ARRAY_SIZE(first), algo, &before), 0); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &after), 0); + cl_assert(memcmp(before.content_hash, after.content_hash, + algo->rawsz)); + cl_assert(memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); + + write_file(present.buf, "*.txt -text\n"); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &changed), 0); + cl_assert(memcmp(after.content_hash, changed.content_hash, + algo->rawsz)); + cl_assert(memcmp(after.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); + + cl_assert_equal_i( + safe_create_leading_directories_no_share(absent_b.buf), 0); + write_file(absent_b.buf, "*.system text\n"); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &before), 0); + cl_assert(memcmp(changed.content_hash, before.content_hash, + algo->rawsz)); + cl_assert(memcmp(changed.portable_namespace_hash, + before.portable_namespace_hash, algo->rawsz)); + write_file(absent_b.buf, "*.system -text\n"); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &after), 0); + cl_assert(memcmp(before.content_hash, after.content_hash, + algo->rawsz)); + cl_assert(memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); + + strbuf_release(&present); + strbuf_release(&absent_b); + strbuf_release(&absent_a); + remove_directory(directory); +#endif +} diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 74e40b205d85c0..582875fa2bf54d 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -207,6 +207,8 @@ void test_clean_status_config__attaches_only_to_the_staged_repository(void) algo->rawsz)); cl_assert(!memcmp(state->current_attr_namespace_hash, attrs.namespace_hash, algo->rawsz)); + cl_assert(!memcmp(state->current_attr_portable_namespace_hash, + attrs.portable_namespace_hash, algo->rawsz)); clean_status_set_config_digest(&repo_a, &replacement); clean_status_attach_config(&istate_a); diff --git a/unpack-trees.c b/unpack-trees.c index 06bcb0ee9bff0e..eb90eaa40e5d19 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -1877,6 +1877,43 @@ static void update_sparsity_for_prefix(const char *prefix, static int verify_absent(const struct cache_entry *, enum unpack_trees_error_types, struct unpack_trees_options *); + +static int checkout_introduces_new_indexed_directory( + struct index_state *source, const struct index_state *result) +{ + unsigned int source_pos = 0; + + for (unsigned int result_pos = 0; + result_pos < result->cache_nr; result_pos++) { + const struct cache_entry *entry = result->cache[result_pos]; + const char *slash; + + while (source_pos < source->cache_nr && + strcmp(source->cache[source_pos]->name, entry->name) < 0) + source_pos++; + if (source_pos < source->cache_nr && + !strcmp(source->cache[source_pos]->name, entry->name)) + continue; + + for (slash = strchr(entry->name, '/'); slash; + slash = strchr(slash + 1, '/')) { + size_t len = slash - entry->name; + int position = index_name_pos(source, entry->name, len); + + if (position >= 0) + return 1; + position = -position - 1; + if (position >= source->cache_nr || + ce_namelen(source->cache[position]) <= len || + source->cache[position]->name[len] != '/' || + memcmp(source->cache[position]->name, + entry->name, len)) + return 1; + } + } + return 0; +} + /* * N-way merge "len" trees. Returns 0 on success, -1 on failure to manipulate the * resulting index, -2 on failure to reflect the changes to the work tree. @@ -2077,10 +2114,56 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options ret = check_updates(o, &o->internal.result) ? (-2) : 0; if (o->dst_index) { - if (!ret) - clean_status_transfer_current_proof_if_same_index( - &o->internal.result, o->src_index); + int history_transferred = 0; + int new_indexed_directory = 0; + + if (!ret) { + history_transferred = + clean_status_transfer_current_proof_if_same_index( + &o->internal.result, o->src_index); + if (!history_transferred && o->preserve_semantic_history) + history_transferred = + clean_status_transfer_current_proof_if_semantically_same_index( + &o->internal.result, o->src_index); + if (history_transferred && o->preserve_semantic_history) + new_indexed_directory = + checkout_introduces_new_indexed_directory( + o->src_index, &o->internal.result); + } move_index_extensions(&o->internal.result, o->src_index); + if (!ret && o->preserve_semantic_history && history_transferred && + !new_indexed_directory && + !o->src_index->sparse_index && + !o->internal.result.sparse_index && + !o->src_index->split_index && + !o->internal.result.split_index && + o->internal.result.untracked && + o->src_index->fsmonitor_token_valid && + o->internal.result.fsmonitor_token_valid && + o->src_index->fsmonitor_untracked_valid && + o->src_index->fsmonitor_untracked_extension_seen && + !o->src_index->fsmonitor_untracked_extension_invalid && + !o->src_index->fsmonitor_legacy_untracked_fallback && + o->src_index->fsmonitor_untracked_token && + o->src_index->fsmonitor_last_update && + o->internal.result.fsmonitor_last_update && + !strcmp(o->src_index->fsmonitor_untracked_token, + o->src_index->fsmonitor_last_update) && + !strcmp(o->src_index->fsmonitor_untracked_token, + o->internal.result.fsmonitor_last_update)) { + o->internal.result.fsmonitor_untracked_token = + xstrdup(o->src_index->fsmonitor_untracked_token); + o->internal.result.fsmonitor_untracked_extension_seen = 1; + o->internal.result.fsmonitor_untracked_extension_invalid = 0; + o->internal.result.fsmonitor_untracked_valid = 1; + o->internal.result.untracked->use_fsmonitor = 1; + trace2_data_intmax("fsmonitor", repo, + "history/untracked-paired-transfer", 1); + } else if (new_indexed_directory) { + trace2_data_intmax( + "fsmonitor", repo, + "history/untracked-paired-new-directory-deferred", 1); + } if (!ret) { if (git_env_bool("GIT_TEST_CHECK_CACHE_TREE", 0) && cache_tree_verify(the_repository, @@ -2309,6 +2392,45 @@ static void invalidate_ce_path(const struct cache_entry *ce, untracked_cache_invalidate_path(o->src_index, ce->name, 1); } +static void invalidate_replaced_ce_path(const struct cache_entry *old, + const struct cache_entry *new, + struct unpack_trees_options *o) +{ + const unsigned int unsafe_flags = CE_SKIP_WORKTREE | + CE_NEW_SKIP_WORKTREE | CE_INTENT_TO_ADD | CE_CONFLICTED; + const char *basename; + + if (!o->preserve_semantic_history || + o->src_index->sparse_index || o->src_index->split_index || + !o->src_index->fsmonitor_untracked_valid || + !o->src_index->untracked || + !o->src_index->untracked->use_fsmonitor || + ce_stage(old) || + strcmp(old->name, new->name) || + ((old->ce_flags | new->ce_flags) & unsafe_flags) || + (!S_ISREG(old->ce_mode) && !S_ISLNK(old->ce_mode)) || + ((old->ce_mode & S_IFMT) != (new->ce_mode & S_IFMT))) + goto rooted; + + basename = find_last_dir_sep(old->name); + basename = basename ? basename + 1 : old->name; + if (!fspathcmp(basename, ".gitattributes") || + !fspathcmp(basename, ".gitignore")) + goto rooted; + + cache_tree_invalidate_path(o->src_index, old->name); + untracked_cache_invalidate_path(o->src_index, old->name, 0); + trace2_data_intmax("fsmonitor", o->src_index->repo, + "checkout/untracked-replacement-targeted", 1); + return; + +rooted: + invalidate_ce_path(old, o); + if (o->preserve_semantic_history) + trace2_data_intmax("fsmonitor", o->src_index->repo, + "checkout/untracked-replacement-rooted", 1); +} + /* * Check that checking out ce->sha1 in subdir ce->name is not * going to overwrite any working files. @@ -2621,7 +2743,7 @@ static int merged_entry(const struct cache_entry *ce, } /* Migrate old flags over */ update |= old->ce_flags & (CE_SKIP_WORKTREE | CE_NEW_SKIP_WORKTREE); - invalidate_ce_path(old, o); + invalidate_replaced_ce_path(old, merge, o); } if (submodule_from_ce(ce) && file_exists(ce->name)) { diff --git a/unpack-trees.h b/unpack-trees.h index 5867e26e177774..b09b7e38dce988 100644 --- a/unpack-trees.h +++ b/unpack-trees.h @@ -70,7 +70,8 @@ struct unpack_trees_options { quiet, exiting_early, dry_run, - skip_cache_tree_update; + skip_cache_tree_update, + preserve_semantic_history; enum unpack_trees_reset_type reset; const char *prefix; const char *super_prefix; diff --git a/wt-status.c b/wt-status.c index 018dcc36efb705..9c6d060d9cf18b 100644 --- a/wt-status.c +++ b/wt-status.c @@ -831,6 +831,13 @@ static void wt_status_collect_changes_index(struct wt_status *s) copy_pathspec(&rev.prune_data, &s->pathspec); run_diff_index(&rev, DIFF_INDEX_CACHED); + if (!s->pathspec.nr && !s->is_initial && + !s->ignore_submodule_arg && !s->repo->index->split_index && + s->repo->index->sparse_index == INDEX_EXPANDED && !s->change.nr) { + s->index_tree_verified = 1; + trace2_data_intmax("status", s->repo, + "index/full-tree-match", 1); + } release_revisions(&rev); } @@ -935,6 +942,15 @@ static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; } +static unsigned int wt_status_exclude_preload_flags(const struct wt_status *s) +{ + const struct untracked_cache *untracked = s->repo->index->untracked; + + if (untracked) + return untracked->dir_flags; + return wt_status_untracked_dir_flags(s); +} + struct wt_status_exclude_context { int root_fd; }; @@ -1089,26 +1105,29 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); + if (!use_optional_locks()) + s->certify_clean_status = 0; wt_status_begin_attr_snapshot(s); /* Record the provider token before either filesystem traversal. */ refresh_fsmonitor(istate); if (s->certify_clean_status && !fsmonitor_has_pending_token(istate)) fsmonitor_reopen_token(istate); - if (s->pathspec.nr || - s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || - s->show_ignored_mode) - return; - - dir_flags = wt_status_untracked_dir_flags(s); if (has_fsmonitor && (!fsmonitor_has_pending_token(istate) || !fstat_is_reliable())) { s->untracked_cache_preload = untracked_cache_preload_start_fsmonitor_excludes( - istate, dir_flags); + istate, wt_status_exclude_preload_flags(s), + s->pathspec.nr ? &s->pathspec : NULL); return; } + if (s->pathspec.nr || + s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode) + return; + + dir_flags = wt_status_untracked_dir_flags(s); if (s->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !istate->untracked && @@ -1140,7 +1159,7 @@ static void wt_status_finish_untracked_cache_preload(struct wt_status *s) return; s->untracked_cache_preloaded = untracked_cache_preload_finish( s->untracked_cache_preload, istate, - wt_status_untracked_dir_flags(s), &index_invalidated); + wt_status_exclude_preload_flags(s), &index_invalidated); s->untracked_cache_preload = NULL; if (!index_invalidated) return; @@ -1164,19 +1183,28 @@ static struct untracked_cache_dir *wt_status_find_cached_directory( const char *slash = memchr(path, '/', end - path); size_t component_len = slash ? slash - path : end - path; struct untracked_cache_dir *child = NULL; + size_t first = 0, last = dir->dirs_nr; if (!component_len) { path++; continue; } - for (size_t i = 0; i < dir->dirs_nr; i++) { - struct untracked_cache_dir *candidate = dir->dirs[i]; - - if (strlen(candidate->name) == component_len && - !strncmp(candidate->name, path, component_len)) { + while (last > first) { + size_t next = first + ((last - first) >> 1); + struct untracked_cache_dir *candidate = dir->dirs[next]; + int compare = strncmp(path, candidate->name, + component_len); + + if (!compare && candidate->name[component_len]) + compare = -1; + if (!compare) { child = candidate; break; } + if (compare < 0) + last = next; + else + first = next + 1; } if (!child || !child->recurse || child->check_only) return NULL; @@ -1225,6 +1253,161 @@ static void wt_status_collect_cached_directory( strbuf_setlen(path, base_len); } +static int wt_status_index_directory_pos( + struct index_state *istate, const char *path, size_t len, int first) +{ + int last = istate->cache_nr; + + if (first < last && + ce_namelen(istate->cache[first]) == len && + !memcmp(istate->cache[first]->name, path, len)) + return -1; + + while (last > first) { + int next = first + ((last - first) >> 1); + const struct cache_entry *ce = istate->cache[next]; + int compare = strncmp(ce->name, path, len); + + if (!compare) + compare = (unsigned char)ce->name[len] - '/'; + if (compare < 0) + first = next + 1; + else + last = next; + } + return first; +} + +static int wt_status_pathspec_matches_clean_tracked_entries( + struct wt_status *s, int validate_entries) +{ + struct index_state *istate = s->repo->index; + int i, positive = 0; + + if ((!validate_entries && !s->tracked_from_fsmonitor) || + !s->pathspec.nr || + istate->sparse_index != INDEX_EXPANDED || + fsmonitor_has_pending_token(istate) || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) + return 0; + + for (i = 0; i < s->pathspec.nr; i++) { + const struct pathspec_item *item = &s->pathspec.items[i]; + const struct cache_entry *ce; + size_t len = item->len; + int pos, subtree = 0, selected = 0, trailing = 0, wildcard = 0; + + if (item->magic & PATHSPEC_EXCLUDE) + continue; + positive = 1; + if ((item->magic & ~(PATHSPEC_FROMTOP | PATHSPEC_LITERAL | + PATHSPEC_GLOB)) || !len) + return 0; + if (item->nowildcard_len != item->len) { + if (!validate_entries || + (s->pathspec.magic & PATHSPEC_ATTR)) + return 0; + len = item->nowildcard_len; + if (!len) + return 0; + wildcard = 1; + } + if (!wildcard && item->match[len - 1] == '/') { + trailing = 1; + while (len && item->match[len - 1] == '/') + len--; + if (!len) + return 0; + } + pos = index_name_pos(istate, item->match, len); + if (pos >= 0 && trailing && memchr(item->match, '/', len) && + !validate_entries) + return 0; + if (pos < 0) { + if (!validate_entries) + return 0; + pos = -pos - 1; + if (!wildcard) { + pos = wt_status_index_directory_pos( + istate, item->match, len, pos); + if (pos < 0) + return 0; + } + subtree = 1; + } + if (wildcard) + subtree = 1; + for (; pos < istate->cache_nr; pos++) { + ce = istate->cache[pos]; + if (subtree && + (ce_namelen(ce) < len || + strncmp(ce->name, item->match, len) || + (!wildcard && (ce_namelen(ce) == len || + ce->name[len] != '/')))) + break; + if (((subtree && + (wildcard || (s->pathspec.magic & PATHSPEC_EXCLUDE))) || + (validate_entries && trailing)) && + !(s->pathspec.magic & PATHSPEC_ATTR) && + !ce_path_match(istate, ce, &s->pathspec, NULL)) { + selected = 1; + if (!subtree) + break; + continue; + } + if (S_ISGITLINK(ce->ce_mode) && + s->ignore_submodule_arg && + !strcmp(s->ignore_submodule_arg, "all")) { + if (validate_entries && + (ce->ce_flags & ~(CE_UPTODATE | CE_HASHED | + CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE))) + return 0; + selected = 1; + if (!subtree) + break; + continue; + } + if ((!S_ISREG(ce->ce_mode) && !S_ISLNK(ce->ce_mode)) || + (validate_entries && + (!(ce->ce_flags & CE_FSMONITOR_VALID) || + (ce->ce_flags & ~(CE_UPTODATE | CE_HASHED | + CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE))))) + return 0; + selected = 1; + if (!subtree) + break; + } + if (!selected && !validate_entries) + return 0; + } + return positive; +} + +static int wt_status_ignored_submodules_are_clean(struct wt_status *s) +{ + const struct index_state *istate = s->repo->index; + const unsigned int supported_flags = + CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE; + + if (s->pathspec.nr || !s->ignore_submodule_arg || + strcmp(s->ignore_submodule_arg, "all")) + return 0; + + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + if ((ce->ce_flags & ~supported_flags) || + (!S_ISGITLINK(ce->ce_mode) && + (!(ce->ce_flags & CE_FSMONITOR_VALID) || + (!S_ISREG(ce->ce_mode) && !S_ISLNK(ce->ce_mode))))) + return 0; + } + return 1; +} + static int wt_status_collect_cached_pathspec( struct wt_status *s, struct dir_struct *dir, @@ -1239,7 +1422,7 @@ static int wt_status_collect_cached_pathspec( size_t len; int pos; - if (s->pathspec.nr != 1 || s->pathspec.has_wildcard || + if (!s->pathspec.nr || s->pathspec.has_wildcard || (s->pathspec.magic & ~(PATHSPEC_FROMTOP | PATHSPEC_LITERAL)) || s->show_ignored_mode || s->show_untracked_files != SHOW_NORMAL_UNTRACKED_FILES || @@ -1257,6 +1440,14 @@ static int wt_status_collect_cached_pathspec( &uc->ss_excludes_file.oid)) return 0; + if (wt_status_pathspec_matches_clean_tracked_entries(s, 0)) { + trace2_data_intmax("status", s->repo, + "untracked/pathspec-cache", 1); + return 1; + } + if (s->pathspec.nr != 1) + return 0; + item = &s->pathspec.items[0]; if (item->nowildcard_len != item->len) return 0; @@ -1269,7 +1460,10 @@ static int wt_status_collect_cached_pathspec( pos = index_name_pos(istate, item->match, len); if (pos >= 0) return 0; - pos = -pos - 1; + pos = wt_status_index_directory_pos( + istate, item->match, len, -pos - 1); + if (pos < 0) + return 0; if (pos >= istate->cache_nr) return 0; ce = istate->cache[pos]; @@ -1303,6 +1497,28 @@ static int wt_status_collect_cached_pathspec( return 1; } +static void wt_status_materialize_deferred_untracked( + struct index_state *istate) +{ + const char *path, *end; + + if (!istate->untracked || + !istate->untracked->fsmonitor_dirty_paths.len) + return; + path = istate->untracked->fsmonitor_dirty_paths.buf; + end = path + istate->untracked->fsmonitor_dirty_paths.len; + + /* Deferred provider paths are not saved with the cache. */ + while (path < end) { + size_t len = strlen(path) + 1; + + untracked_cache_invalidate_path(istate, path, 1); + path += len; + } + istate->cache_changed |= UNTRACKED_CHANGED; + istate->fsmonitor_untracked_must_persist = 1; +} + static int wt_status_collect_untracked_1( struct wt_status *s, struct string_list *untracked, @@ -1314,8 +1530,10 @@ static int wt_status_collect_untracked_1( uint64_t t_begin = getnanotime(); struct index_state *istate = s->repo->index; - if (!s->show_untracked_files) + if (!s->show_untracked_files) { + wt_status_materialize_deferred_untracked(istate); return 0; + } if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -1337,6 +1555,10 @@ static int wt_status_collect_untracked_1( if (wt_status_collect_cached_pathspec(s, &dir, untracked)) { used_untracked_cache = 1; + } else if (wt_status_pathspec_matches_clean_tracked_entries(s, 0)) { + trace2_data_intmax("status", s->repo, + "untracked/pathspec-cache", 1); + used_untracked_cache = 0; } else { fill_directory(&dir, istate, &s->pathspec); if (s->certify_clean_status && dir.internal.traversal_failed) @@ -1351,6 +1573,11 @@ static int wt_status_collect_untracked_1( } } string_list_sort_u(untracked, 0); + if (!s->pathspec.nr && used_untracked_cache && dir.nr && + dir.untracked->dir_opened && !dir.internal.traversal_failed && + !clean_status_external_history_was_restored(istate) && + (istate->cache_changed & UNTRACKED_CHANGED)) + istate->fsmonitor_untracked_must_persist = 1; for (i = 0; i < dir.ignored_nr; i++) { struct dir_entry *ent = dir.ignored[i]; @@ -1360,6 +1587,8 @@ static int wt_status_collect_untracked_1( string_list_sort_u(ignored, 0); dir_clear(&dir); + if (!used_untracked_cache) + wt_status_materialize_deferred_untracked(istate); if (advice_enabled(ADVICE_STATUS_U_OPTION)) s->untracked_in_ms = (getnanotime() - t_begin) / 1000000; @@ -1440,6 +1669,7 @@ struct wt_status_token_closure { struct string_list staged_untracked; struct string_list staged_ignored; int staged_untracked_ready; + int staged_output_matches_status; int refresh_result; int queries; }; @@ -1456,17 +1686,43 @@ static int wt_status_stage_untracked( struct wt_status_token_closure *closure) { struct wt_status *s = closure->status; + struct index_state *istate = s->repo->index; struct pathspec pathspec = s->pathspec; + enum untracked_status_type requested_untracked = + s->show_untracked_files; + int prime_configured_cache = + requested_untracked == SHOW_ALL_UNTRACKED_FILES && + istate->untracked && !s->untracked_cache_preload && + istate->untracked->dir_flags == + (DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES); wt_status_discard_staged_untracked(closure); + closure->staged_output_matches_status = !prime_configured_cache; /* A provider token can certify only a complete untracked traversal. */ if (pathspec.nr) memset(&s->pathspec, 0, sizeof(s->pathspec)); + if (prime_configured_cache) + s->show_untracked_files = SHOW_NORMAL_UNTRACKED_FILES; closure->staged_untracked_ready = wt_status_collect_untracked_1( s, &closure->staged_untracked, - &closure->staged_ignored); + &closure->staged_ignored) || + (!istate->untracked && + !s->certify_untracked_scan_failed); + s->show_untracked_files = requested_untracked; + if (prime_configured_cache) { + string_list_clear(&closure->staged_untracked, 0); + string_list_clear(&closure->staged_ignored, 0); + } + if (closure->staged_untracked_ready && + istate->preload_untracked == &s->untracked) { + if (closure->staged_untracked.nr || + !closure->use_bulk_provider) + istate->preload_untracked = NULL; + else + wt_status_discard_staged_untracked(closure); + } if (pathspec.nr) { s->pathspec = pathspec; /* The ordinary scoped traversal supplies the displayed results. */ @@ -1483,7 +1739,8 @@ static void wt_status_publish_staged_untracked( { struct wt_status *s = closure->status; - if (!closure->staged_untracked_ready || s->pathspec.nr) + if (!closure->staged_untracked_ready || + !closure->staged_output_matches_status || s->pathspec.nr) return; if (s->untracked.nr || s->ignored.nr) BUG("publishing untracked results over collected status"); @@ -1564,9 +1821,9 @@ static void wt_status_refresh_for_token( { struct index_state *istate = s->repo->index; - clean_status_release_proof_epoch(*epoch); - *epoch = clean_status_capture_proof_epoch( - istate, s->attr_source_snapshot, 0); + if (!*epoch) + *epoch = clean_status_capture_proof_epoch( + istate, s->attr_source_snapshot, 0); if (*epoch && use_bulk_provider) istate->preload_bulk_proof_epoch = *epoch; if (*epoch) { @@ -1594,10 +1851,36 @@ static int wt_status_close_ordinary_fsmonitor_token( * be validated by capturing its inputs afterward. */ if (validate_epoch) { - wt_status_refresh_for_token( - s, closure->refresh_flags, &scan_epoch, - closure->use_bulk_provider, - &closure->refresh_result); + if (s->allow_clean_status_shortcuts && + s->certify_clean_status && + closure->can_prime && + !s->untracked_cache_preload && + !getenv(INDEX_ENVIRONMENT) && + !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + istate->fsmonitor_token_valid && + clean_status_revalidated_token_matches(istate) && + !clean_status_manifest_global_fallback(istate) && + !clean_status_worktree_manifest_needs_refresh(istate) && + clean_status_index_entries_are_certifiable(istate) && + (scan_epoch = clean_status_capture_proof_epoch( + istate, s->attr_source_snapshot, 0)) && + wt_status_stage_untracked(closure) && + closure->staged_untracked.nr && + !clean_status_worktree_manifest_needs_refresh(istate)) { + s->tracked_from_fsmonitor = 1; + closure->untracked_ready = 1; + closure->untracked_proof_complete = 1; + } else { + if (closure->staged_untracked_ready) { + closure->untracked_ready = 1; + closure->untracked_proof_complete = 1; + } + wt_status_refresh_for_token( + s, closure->refresh_flags, &scan_epoch, + closure->use_bulk_provider, + &closure->refresh_result); + } if (!scan_epoch) return 0; } else if (!refreshed_before_closure || @@ -1651,13 +1934,20 @@ static int wt_status_close_ordinary_fsmonitor_token( closure->untracked_proof_complete, wt_status_untracked_cache_valid( closure)); + if (s->tracked_from_fsmonitor) { + s->certify_clean_status = 0; + trace2_data_intmax("status", s->repo, + "fsmonitor/tracked-clean", 1); + } return 1; } break; } + s->tracked_from_fsmonitor = 0; wt_status_discard_staged_untracked(closure); closure->untracked_proof_complete = - !closure->require_untracked || !istate->untracked; + !closure->require_untracked || + (!istate->untracked && !closure->can_prime); clean_status_release_proof_epoch(scan_epoch); scan_epoch = NULL; if (!fsmonitor_token_requires_rescan(result)) @@ -1807,7 +2097,7 @@ static int wt_status_tracked_fsmonitor_state_is_current( struct index_state *istate = s->repo->index; return s->allow_clean_status_shortcuts && - !s->certify_clean_status && !s->pathspec.nr && + !s->certify_clean_status && !getenv(INDEX_ENVIRONMENT) && !istate->split_index && istate->sparse_index == INDEX_EXPANDED && fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && @@ -1847,7 +2137,9 @@ static int wt_status_close_fsmonitor_token( s, &proof, "provider-unavailable"); if (!refreshed_before_closure && attr_inputs_match && wt_status_tracked_fsmonitor_state_is_current(s) && - clean_status_index_entries_are_certifiable(istate)) { + (wt_status_pathspec_matches_clean_tracked_entries(s, 1) || + clean_status_index_entries_are_certifiable(istate) || + wt_status_ignored_submodules_are_clean(s))) { s->tracked_from_fsmonitor = 1; trace2_data_intmax( "status", s->repo, @@ -1878,7 +2170,7 @@ static int wt_status_close_fsmonitor_token( s->tracked_from_fsmonitor = 0; closure.can_prime = require_untracked && - istate->untracked && + (istate->untracked || s->certify_clean_status) && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode; closure.use_bulk_provider = @@ -1887,7 +2179,14 @@ static int wt_status_close_fsmonitor_token( !istate->untracked->root || (istate->fsmonitor_legacy_untracked_adopted && istate->fsmonitor_untracked_valid && - istate->untracked->root->valid_recursive); + istate->untracked->root->valid_recursive) || + (!require_untracked && + (s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode) && + istate->fsmonitor_untracked_token && + istate->fsmonitor_last_update && + !strcmp(istate->fsmonitor_untracked_token, + istate->fsmonitor_last_update)); closure.untracked_proof_complete = !require_untracked || !istate->untracked || (istate->fsmonitor_legacy_untracked_adopted && @@ -1918,6 +2217,7 @@ static int wt_status_close_fsmonitor_token( /* Keep the last valid token and fall back to complete scans. */ fallback: + s->tracked_from_fsmonitor = 0; wt_status_discard_semantic_verify(s, &proof, "fallback"); wt_status_discard_staged_untracked(&closure); preload_index_bulk_result_clear(istate); diff --git a/wt-status.h b/wt-status.h index 6f5300fe8e5481..5106c02384dda4 100644 --- a/wt-status.h +++ b/wt-status.h @@ -145,6 +145,7 @@ struct wt_status { int workdir_dirty; unsigned allow_clean_status_shortcuts : 1; unsigned certify_clean_status : 1; + unsigned index_tree_verified : 1; unsigned tracked_from_fsmonitor : 1; unsigned untracked_from_token_closure : 1; unsigned untracked_from_preload : 1; From 08f76b5d2fd633fd66bb1d66af5db90487c3e14f Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Tue, 28 Jul 2026 21:51:08 -0500 Subject: [PATCH 260/403] checkout: avoid rewriting an unchanged index Checking out an unchanged path currently rewrites the index even when no entries changed. This also affects "git restore", which uses the same path checkout machinery. Use SKIP_IF_UNCHANGED to avoid the write when the index is unchanged and no post-index-change hook is installed. Preserve the existing write when such a hook exists, since checkout has invoked it even for unchanged paths and t7113 explicitly covers that behavior. Actual worktree writes still refresh cached stat information and mark the index dirty, so those updates continue to be written. Mark sparse-directory entries dirty when replacing their object IDs in non-overlay mode. These in-place updates previously relied on the unconditional write and must not be skipped. On a repository with 1,000,001 tracked paths and a 103 MiB index, checking out an unchanged path improves from 242 ms to 32 ms. Add checkout and restore coverage while disabling fsmonitor within timestamp-sensitive tests, since fsmonitor metadata can itself dirty the index. Signed-off-by: Ted Nyman --- builtin/checkout.c | 8 +++++++- t/t2022-checkout-paths.sh | 10 ++++++++++ t/t2070-restore.sh | 10 ++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/builtin/checkout.c b/builtin/checkout.c index 57e2ffb1ae76b2..a724d7a6018526 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -193,6 +193,7 @@ static int try_update_sparse_directory(const struct object_id *oid, *context->index_changed = 1; oidcpy(&old->oid, oid); old->ce_flags |= CE_UPDATE; + the_repository->index->cache_changed |= CE_ENTRY_CHANGED; result = 0; } @@ -752,11 +753,16 @@ static int checkout_paths(const struct checkout_opts *opts, checkout_index = opts->checkout_index; if (checkout_index) { + unsigned int flags = COMMIT_LOCK; + if (preserve_source_tree_history && (source_tree_index_changed || errs)) clean_status_invalidate_current_proof( the_repository->index); - if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) + if (!the_repository->index->cache_changed && + !hook_exists(the_repository, "post-index-change")) + flags |= SKIP_IF_UNCHANGED; + if (write_locked_index(the_repository->index, &lock_file, flags)) die(_("unable to write new index file")); } else { /* diff --git a/t/t2022-checkout-paths.sh b/t/t2022-checkout-paths.sh index c49ba7f9bd4fe0..ac1ba6e3558672 100755 --- a/t/t2022-checkout-paths.sh +++ b/t/t2022-checkout-paths.sh @@ -19,6 +19,16 @@ test_expect_success setup ' test_tick && git commit -m "next has dir/next but not dir/main" ' +test_expect_success 'checkout does not rewrite an unchanged index' ' + test_config core.fsmonitor false && + git update-index --no-fsmonitor && + test-tool chmtime =1000000000 .git/index && + git checkout -- dir/common && + test "$(test-tool chmtime --get .git/index)" = 1000000000 && + git checkout HEAD -- dir/common && + test "$(test-tool chmtime --get .git/index)" = 1000000000 +' + test_expect_success 'checking out paths out of a tree does not clobber unrelated paths' ' git checkout next && git reset --hard && diff --git a/t/t2070-restore.sh b/t/t2070-restore.sh index 2c222fb9342777..81b870c77074ec 100755 --- a/t/t2070-restore.sh +++ b/t/t2070-restore.sh @@ -21,6 +21,16 @@ test_expect_success 'setup' ' git update-ref refs/heads/one main ' +test_expect_success 'restore does not rewrite an unchanged index' ' + test_config core.fsmonitor false && + git update-index --no-fsmonitor && + test-tool chmtime =1000000000 .git/index && + git restore --worktree first.t && + test "$(test-tool chmtime --get .git/index)" = 1000000000 && + git restore --staged first.t && + test "$(test-tool chmtime --get .git/index)" = 1000000000 +' + test_expect_success 'restore without pathspec is not ok' ' test_must_fail git restore && test_must_fail git restore --source=first From 9b86dcf25ecdc1a38d2b128a6cd841dd58a63f14 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:10 -0500 Subject: [PATCH 261/403] t7530: tolerate an already-exited fast fallback The exclude-race helper observes a Trace2 fallback marker before stopping its background status process. A fast fallback can exit successfully between that observation and kill, which made an otherwise correct race test fail nondeterministically. Keep terminating a process that is still running. If it has already exited, require wait to report successful completion instead of treating the failed signal as a test failure. --- t/t7530-status-clean-sidecar.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 788ed1cf9aa75a..ac153951f1e490 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -232,8 +232,12 @@ stop_after_fast_fallback () { if grep -q "\"value\":\"fast-excludes-raced\"" \ "$race_trace" then - kill "$status_pid" 2>/dev/null || return 1 - wait "$status_pid" 2>/dev/null || : + if kill "$status_pid" 2>/dev/null + then + wait "$status_pid" 2>/dev/null || : + else + wait "$status_pid" 2>/dev/null || return 1 + fi status_pid= return 0 fi From 9d4b9062185c4b4402ee138c1172ec00e4895ac4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:33 -0500 Subject: [PATCH 262/403] commit: honor optional locks during a dry run A commit dry run refreshes the index to report whether anything could be committed. Its as-is preparation also takes the real index lock and persists refreshed stat information, even when optional locks have been explicitly disabled. Avoid taking or writing the real index lock for an as-is dry run under --no-optional-locks. Keep the in-memory refresh and cache-tree update, so clean stat mismatches, genuine worktree changes, and staged changes produce the same result as before. Real commits and partial dry runs retain their existing locking behavior. Cover clean and dirty dry runs, a preexisting index lock, and the ordinary dry run that still persists its stat repair. --- builtin/commit.c | 10 ++++++--- t/t7501-commit-basic-functionality.sh | 31 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 2b27964c888642..928ddec801838a 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -510,8 +510,11 @@ static const char *prepare_index(const char **argv, const char *prefix, * We still need to refresh the index here. */ if (!only && !pathspec.nr) { - repo_hold_locked_index(the_repository, &index_lock, - LOCK_DIE_ON_ERROR); + int update_index = !is_status || use_optional_locks(); + + if (update_index) + repo_hold_locked_index(the_repository, &index_lock, + LOCK_DIE_ON_ERROR); if (!fstat_is_reliable() || the_repository->index->split_index || fsm_settings__get_mode(the_repository) != @@ -523,7 +526,8 @@ static const char *prepare_index(const char **argv, const char *prefix, if (the_repository->index->cache_changed || !cache_tree_fully_valid(the_repository->index->cache_tree)) cache_tree_update(the_repository->index, WRITE_TREE_SILENT); - if (write_locked_index(the_repository->index, &index_lock, + if (update_index && + write_locked_index(the_repository->index, &index_lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) die(_("unable to write new index file")); commit_style = COMMIT_AS_IS; diff --git a/t/t7501-commit-basic-functionality.sh b/t/t7501-commit-basic-functionality.sh index 7794babe462bbe..a15de3a3a32097 100755 --- a/t/t7501-commit-basic-functionality.sh +++ b/t/t7501-commit-basic-functionality.sh @@ -79,6 +79,37 @@ test_expect_success '--dry-run fails with nothing to commit' ' test_must_fail git commit -m initial --dry-run ' +test_expect_success '--no-optional-locks prevents dry-run index updates' ' + test_when_finished "rm -rf optional-locks-dry-run" && + test_create_repo optional-locks-dry-run && + ( + cd optional-locks-dry-run && + git config core.fsmonitor false && + test_commit base tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + test_must_fail git --no-optional-locks commit --dry-run >../actual && + test_grep "working tree clean" ../actual && + test_is_magic_mtime .git/index +1 && + echo modified >>tracked && + test_must_fail git --no-optional-locks commit --dry-run >../actual && + test_grep "modified:.*tracked" ../actual && + test_is_magic_mtime .git/index +1 && + git add tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + >.git/index.lock && + git --no-optional-locks commit --dry-run >../actual && + test_grep "modified:.*tracked" ../actual && + test_is_magic_mtime .git/index +1 && + test_must_fail git commit --dry-run >../actual 2>../err && + test_grep "index.lock" ../err && + rm .git/index.lock && + git commit --dry-run >../actual && + ! test_is_magic_mtime .git/index +1 + ) +' + test_expect_success '--short fails with nothing to commit' ' test_must_fail git commit -m initial --short ' From c2089ad496f2cb4517889e6e73b865cac478dc72 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:43 -0500 Subject: [PATCH 263/403] reset: avoid rewriting an unchanged index A mixed reset always writes the index after reading its target tree, even when its selected paths and stat information are already current. Replacing an identical index invalidates its physical clean-status proof and makes the following status rescan the repository. Skip the write only for a mixed reset with no index changes and no post-index-change hook. Continue taking the index lock, refreshing as requested, updating HEAD and ORIG_HEAD, and running configured hooks; hard, merge, and keep resets retain their existing behavior. Cover same-HEAD and pathspec resets, resetting to another commit with an identical tree, preserved ORIG_HEAD, and an installed hook that still forces the original index write. --- builtin/reset.c | 9 ++++++++- t/t7102-reset.sh | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/builtin/reset.c b/builtin/reset.c index 539be66cd64936..3f18f921032fce 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -19,6 +19,7 @@ #include "gettext.h" #include "hash.h" #include "hex.h" +#include "hook.h" #include "lockfile.h" #include "object.h" #include "pretty.h" @@ -526,6 +527,8 @@ int cmd_reset(int argc, if (reset_type != SOFT) { struct lock_file lock = LOCK_INIT; + unsigned int write_flags = COMMIT_LOCK; + repo_hold_locked_index(the_repository, &lock, LOCK_DIE_ON_ERROR); if (reset_type == MIXED) { @@ -573,7 +576,11 @@ int cmd_reset(int argc, free(ref); } - if (write_locked_index(the_repository->index, &lock, COMMIT_LOCK)) + if (reset_type == MIXED && + !the_repository->index->cache_changed && + !hook_exists(the_repository, "post-index-change")) + write_flags |= SKIP_IF_UNCHANGED; + if (write_locked_index(the_repository->index, &lock, write_flags)) die(_("Could not write new index file.")); } diff --git a/t/t7102-reset.sh b/t/t7102-reset.sh index 298272cb13c033..993b7c260d0f84 100755 --- a/t/t7102-reset.sh +++ b/t/t7102-reset.sh @@ -482,6 +482,48 @@ test_expect_success 'resetting an unmodified path is a no-op' ' git diff-index --cached --exit-code HEAD ' +test_expect_success 'mixed resets do not rewrite an unchanged index' ' + test_when_finished "rm -rf reset-unchanged-index" && + git init reset-unchanged-index && + ( + cd reset-unchanged-index && + sane_unset GIT_TEST_SPLIT_INDEX && + git config core.fsmonitor false && + test_commit base tracked && + git commit --allow-empty -m same-tree && + git update-index --no-fsmonitor && + test_set_magic_mtime .git/index && + + GIT_TRACE2_EVENT="$PWD/.git/head.trace" \ + git reset --mixed HEAD && + test_is_magic_mtime .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/head.trace && + + GIT_TRACE2_EVENT="$PWD/.git/path.trace" \ + git reset HEAD -- tracked && + test_is_magic_mtime .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/path.trace && + + old_head=$(git rev-parse HEAD) && + GIT_TRACE2_EVENT="$PWD/.git/same-tree.trace" \ + git reset --mixed HEAD^ && + test_is_magic_mtime .git/index && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/same-tree.trace && + test "$(git rev-parse ORIG_HEAD)" = "$old_head" && + test "$(git rev-parse HEAD)" = "$(git rev-parse base)" && + + test_hook --setup post-index-change <<-\EOF && + echo "$1 $2" >.git/hook-args + EOF + GIT_TRACE2_EVENT="$PWD/.git/hook.trace" \ + git reset --mixed HEAD && + test_grep "\"label\":\"do_write_index\"" .git/hook.trace && + test_grep "^0 1$" .git/hook-args && + ! test_is_magic_mtime .git/index + ) +' + test_reset_refreshes_index () { # To test whether the index is refreshed in `git reset --mixed` with From 7a39a2e3d805950ddab42ab1c35d62a84957895c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:58 -0500 Subject: [PATCH 264/403] status: certify clean output with configured stash display Enabling status.showStash prevented every clean-status proof issuance path. Exact porcelain output is no longer empty when a stash exists, and ordinary root status rejected the stash decoration even after a complete verified worktree scan. Allow only the existing normal, root-wide issuance path to include the live stash summary. Preserve the exact-porcelain issuance restriction and every untracked, ignored, sparse, pathspec, and provider safety check. Exercise a real stash and configuration-namespace transition: exact porcelain saves reusable history without issuing a proof, ordinary status restores that history and issues one, and later long and porcelain queries reuse it while displaying current stash information. --- builtin/commit.c | 2 +- clean-status-sidecar-issue.c | 3 ++- t/t7530-status-clean-sidecar.sh | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 928ddec801838a..4f8fcb5b8ad18d 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1773,7 +1773,7 @@ struct repository *repo UNUSED) s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES; normal_clean_query = default_status_command && status_format == STATUS_FORMAT_NONE && normal_has_head && - !s.pathspec.nr && !s.show_branch && !s.show_stash && + !s.pathspec.nr && !s.show_branch && !s.show_ignored_mode && !s.null_termination && !s.verbose && !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index a13898d2bba190..3c424417849f4e 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -47,7 +47,8 @@ static int output_is_certifiable(const struct wt_status *status, (normal_clean_query && status->status_format == STATUS_FORMAT_NONE)) && !status->pathspec.nr && !status->show_branch && - !status->show_stash && !status->show_ignored_mode && + (!status->show_stash || normal_clean_query) && + !status->show_ignored_mode && !status->null_termination && !status->verbose && status->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !status->change.nr && !status->untracked.nr && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index ac153951f1e490..8fb23109360935 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -464,6 +464,49 @@ test_expect_success DURABLE_FSMONITOR \ --porcelain=v2 --branch ' +test_expect_success DURABLE_FSMONITOR \ + 'configured stash output does not prevent clean sidecar issuance' ' + stash_repo=sidecar-configured-stash && + test_when_finished "stop_daemon $stash_repo" && + setup_repo "$stash_repo" && + git -C "$stash_repo" config core.autocrlf false && + git -C "$stash_repo" config core.untrackedCache true && + test_write_lines stashed >"$stash_repo/tracked" && + git -C "$stash_repo" stash push -qm configured-stash && + test-tool chmtime -120 "$stash_repo/tracked" && + git -C "$stash_repo" update-index --refresh && + prime_semantic_history "$stash_repo" && + git -C "$stash_repo" config status.showStash true && + test_path_is_missing "$stash_repo/.git/index.csts" && + + test_env GIT_TRACE2_EVENT="$PWD/configured-stash.exact.trace" \ + bulk_status -C "$stash_repo" status --porcelain=v2 \ + >configured-stash.exact && + test_grep "^# stash 1$" configured-stash.exact && + test_trace2_data fsmonitor history/external-stored 1 \ + configured-stash.issue && + test_grep "nothing to commit, working tree clean" \ + configured-stash.issue && + test_grep "Your stash currently has 1 entry" configured-stash.issue && + test_trace2_data fsmonitor history/external-restored 1 \ + Date: Wed, 12 Aug 2026 00:00:07 -0500 Subject: [PATCH 265/403] t7530: preserve clean proofs across no-op index commands Avoiding an index rewrite matters because the clean-status sidecar is bound to the physical index. A logically harmless checkout, restore, or mixed reset must leave both artifacts unchanged so the next status can reuse its existing proof. Exercise unchanged checkout and restore paths plus mixed HEAD and pathspec resets with the real fsmonitor provider. Require identical index and sidecar bytes, no index write, and an output-equivalent subsequent status without an index read, refresh, preload, or directory traversal. --- t/t7530-status-clean-sidecar.sh | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 8fb23109360935..a5baa6c839c878 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -685,6 +685,45 @@ test_expect_success DURABLE_FSMONITOR \ test_grep "^1 \.M .* tracked$" external-pathspec-status.root ' +test_expect_success DURABLE_FSMONITOR \ + 'no-op checkout, restore, and mixed reset preserve a clean sidecar' ' + checkout_repo=sidecar-noop-checkout && + test_when_finished "stop_daemon $checkout_repo" && + setup_repo "$checkout_repo" && + git -C "$checkout_repo" config core.untrackedCache true && + issue_sidecar "$checkout_repo" && + + for checkout_case in checkout-index checkout-head \ + restore-worktree restore-staged reset-path reset-head \ + reset-mixed-head reset-mixed-no-refresh + do + case "$checkout_case" in + checkout-index) set -- checkout -- tracked ;; + checkout-head) set -- checkout HEAD -- tracked ;; + restore-worktree) set -- restore --worktree tracked ;; + restore-staged) set -- restore --staged tracked ;; + reset-path) set -- reset -- tracked ;; + reset-head) set -- reset HEAD -- tracked ;; + reset-mixed-head) set -- reset --mixed HEAD ;; + reset-mixed-no-refresh) + set -- reset --mixed --no-refresh HEAD ;; + esac && + cp "$checkout_repo/.git/index" "$checkout_case.before" && + cp "$checkout_repo/.git/index.csts" \ + "$checkout_case.sidecar" && + GIT_TRACE2_EVENT="$PWD/$checkout_case.command.trace" \ + git -C "$checkout_repo" "$@" && + test_cmp_bin "$checkout_case.before" \ + "$checkout_repo/.git/index" && + test_cmp_bin "$checkout_case.sidecar" \ + "$checkout_repo/.git/index.csts" && + test_grep ! "\"label\":\"do_write_index\"" \ + "$checkout_case.command.trace" && + assert_clean_sidecar_hit "$checkout_repo" "$checkout_repo" \ + "$checkout_case.hit" || return 1 + done +' + test_expect_success DURABLE_FSMONITOR \ 'clean pathspec status reuses an existing root-wide clean proof' ' test_when_finished "stop_daemon clean-pathspec-status" && From 7d581808480bf24171070cfe610d731c61aa20a5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 12 Aug 2026 00:39:37 -0500 Subject: [PATCH 266/403] checkout-index: avoid rewriting an unchanged index With -u, checkout-index always commits its index lock after checking out the requested paths. An already-current entry leaves cache_changed clear, but the unconditional write still replaces a byte-identical index and invalidates a clean-status sidecar tied to its identity. Skip that write only when the index is unchanged and no post-index-change hook is installed. Keep taking the lock, writing genuine stat updates, and invoking configured hooks as before. Exercise the unchanged index and hook cases directly. Also cover path, force, all-files, and stdin invocations with a real fsmonitor daemon, requiring both the index and sidecar to survive and the next status to reuse its clean proof without reading the index. --- builtin/checkout-index.c | 13 ++++++++--- t/t2006-checkout-index-basic.sh | 31 +++++++++++++++++++++++++ t/t7530-status-clean-sidecar.sh | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/builtin/checkout-index.c b/builtin/checkout-index.c index 1807696b1c92c8..ac17acea58233e 100644 --- a/builtin/checkout-index.c +++ b/builtin/checkout-index.c @@ -13,6 +13,7 @@ #include "config.h" #include "environment.h" #include "gettext.h" +#include "hook.h" #include "lockfile.h" #include "quote.h" #include "cache-tree.h" @@ -360,8 +361,14 @@ int cmd_checkout_index(int argc, if (err) return 1; - if (is_lock_file_locked(&lock_file) && - write_locked_index(repo->index, &lock_file, COMMIT_LOCK)) - die("Unable to write new index file"); + if (is_lock_file_locked(&lock_file)) { + unsigned int flags = COMMIT_LOCK; + + if (!repo->index->cache_changed && + !hook_exists(repo, "post-index-change")) + flags |= SKIP_IF_UNCHANGED; + if (write_locked_index(repo->index, &lock_file, flags)) + die("Unable to write new index file"); + } return 0; } diff --git a/t/t2006-checkout-index-basic.sh b/t/t2006-checkout-index-basic.sh index 6538a24c951f9b..f1ade19c9c9f4d 100755 --- a/t/t2006-checkout-index-basic.sh +++ b/t/t2006-checkout-index-basic.sh @@ -107,4 +107,35 @@ test_expect_success 'checkout-index --temp correctly reports error for submodule test_grep "cannot create temporary submodule sub" stderr ' +test_expect_success 'checkout-index -u preserves an unchanged index' ' + test_when_finished "rm -rf checkout-index-unchanged" && + test_create_repo checkout-index-unchanged && + test_commit -C checkout-index-unchanged base tracked && + test-tool -C checkout-index-unchanged chmtime -120 tracked && + git -C checkout-index-unchanged update-index --refresh && + cp checkout-index-unchanged/.git/index checkout-index.before && + GIT_TRACE2_EVENT="$PWD/checkout-index.trace" \ + git -C checkout-index-unchanged checkout-index -u tracked && + test_cmp_bin checkout-index.before checkout-index-unchanged/.git/index && + test_grep ! "\"label\":\"do_write_index\"" checkout-index.trace +' + +test_expect_success 'checkout-index -u retains post-index-change hooks' ' + test_when_finished "rm -rf checkout-index-hook" && + test_create_repo checkout-index-hook && + test_commit -C checkout-index-hook base tracked && + test-tool -C checkout-index-hook chmtime -120 tracked && + git -C checkout-index-hook update-index --refresh && + mkdir checkout-index-hook/hooks && + git -C checkout-index-hook config core.hooksPath hooks && + write_script checkout-index-hook/hooks/post-index-change <<-\EOF && + printf "%s %s\n" "$1" "$2" >hook-actual + EOF + GIT_TRACE2_EVENT="$PWD/checkout-index-hook.trace" \ + git -C checkout-index-hook checkout-index -u tracked && + test_write_lines "0 0" >checkout-index-hook.expect && + test_cmp checkout-index-hook.expect checkout-index-hook/hook-actual && + test_grep "\"label\":\"do_write_index\"" checkout-index-hook.trace +' + test_done diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index a5baa6c839c878..a2c70fcf53c210 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -1562,4 +1562,44 @@ test_expect_success DURABLE_FSMONITOR \ external-token.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'no-op checkout-index -u preserves a clean status proof' ' + update_repo=sidecar-noop-checkout-index && + test_when_finished "stop_daemon $update_repo" && + setup_repo "$update_repo" && + git -C "$update_repo" config core.untrackedCache true && + issue_sidecar "$update_repo" && + + for update_case in path force all stdin + do + case "$update_case" in + path) set -- -u tracked ;; + force) set -- -u -f tracked ;; + all) set -- -u -a ;; + stdin) set -- -u --stdin ;; + esac && + if test "$update_case" = stdin + then + echo tracked >checkout-update.stdin + else + : >checkout-update.stdin + fi && + cp "$update_repo/.git/index" \ + "checkout-update-$update_case.index" && + cp "$update_repo/.git/index.csts" \ + "checkout-update-$update_case.sidecar" && + GIT_TRACE2_EVENT="$PWD/checkout-update-$update_case.trace" \ + git -C "$update_repo" checkout-index "$@" \ + Date: Wed, 12 Aug 2026 00:39:56 -0500 Subject: [PATCH 267/403] diff: restore external fsmonitor history when available A Git implementation without clean-status extensions can rewrite the same logical index while dropping its fsmonitor state. A later diff currently initializes an unusable timestamp token, receives a full invalidation from the daemon, scans every attribute directory, and stats every tracked entry even when a valid external checkpoint exists. Opt into existing external-history restoration only when a validated clean-status sidecar names the current configuration. Reject alternate indexes, non-main or sparse worktrees, configured clean filters, unsupported filesystems, and repositories without builtin fsmonitor. The existing checkpoint checks still validate the index, attributes, provider token, and staged entries before restoring any history. Reproduce a foreign rewrite without depending on another Git binary by rebuilding its index with fsmonitor disabled. Require a clean diff to restore the checkpoint without scanning worktree metadata, statting tracked entries, or rewriting the index, and verify that a subsequent real tracked change still appears in the diff. --- builtin.h | 1 + builtin/describe.c | 1 + builtin/diff-files.c | 1 + builtin/diff-index.c | 1 + builtin/diff.c | 34 +++++++++++++++++++++ t/t7530-status-clean-sidecar.sh | 53 +++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+) diff --git a/builtin.h b/builtin.h index 4e47a4ebd30ba3..512df065158d40 100644 --- a/builtin.h +++ b/builtin.h @@ -177,6 +177,7 @@ int cmd_diagnose(int argc, const char **argv, const char *prefix, struct reposit int cmd_diff_files(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff_index(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff(int argc, const char **argv, const char *prefix, struct repository *repo); +void prepare_diff_external_history(struct repository *repo); int cmd_diff_pairs(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff_tree(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_difftool(int argc, const char **argv, const char *prefix, struct repository *repo); diff --git a/builtin/describe.c b/builtin/describe.c index 8e216206bcc19f..a2d8c60e16e0e5 100644 --- a/builtin/describe.c +++ b/builtin/describe.c @@ -790,6 +790,7 @@ int cmd_describe(int argc, */ clean_status_set_config_digest(the_repository, &clean_digest); + prepare_diff_external_history(the_repository); repo_read_index(the_repository); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED, NULL, NULL, NULL); diff --git a/builtin/diff-files.c b/builtin/diff-files.c index ea91347ce23beb..0de2094ca2a62d 100644 --- a/builtin/diff-files.c +++ b/builtin/diff-files.c @@ -84,6 +84,7 @@ int cmd_diff_files(int argc, (rev.diffopt.output_format & DIFF_FORMAT_PATCH)) diff_merges_set_dense_combined_if_unset(&rev); + prepare_diff_external_history(the_repository); if (repo_read_index_preload(the_repository, &rev.diffopt.pathspec, 0) < 0) die_errno("repo_read_index_preload"); run_diff_files(&rev, options); diff --git a/builtin/diff-index.c b/builtin/diff-index.c index 3db7cffede578c..880a12d34b258f 100644 --- a/builtin/diff-index.c +++ b/builtin/diff-index.c @@ -68,6 +68,7 @@ int cmd_diff_index(int argc, if (rev.pending.nr != 1 || rev.max_count != -1 || rev.min_age != -1 || rev.max_age != -1) usage(diff_cache_usage); + prepare_diff_external_history(the_repository); if (!(option & DIFF_INDEX_CACHED)) { setup_work_tree(the_repository); if (repo_read_index_preload(the_repository, &rev.diffopt.pathspec, 0) < 0) { diff --git a/builtin/diff.c b/builtin/diff.c index c597935957c74e..2ebd04fa940780 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -8,6 +8,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-sidecar.h" #include "config.h" #include "ewah/ewok.h" #include "lockfile.h" @@ -15,6 +17,7 @@ #include "commit.h" #include "environment.h" #include "gettext.h" +#include "fsmonitor-settings.h" #include "tag.h" #include "diff.h" #include "diff-merges.h" @@ -26,6 +29,7 @@ #include "setup.h" #include "oid-array.h" #include "tree.h" +#include "worktree.h" #define DIFF_NO_INDEX_EXPLICIT 1 #define DIFF_NO_INDEX_IMPLICIT 2 @@ -400,6 +404,35 @@ static void symdiff_release(struct symdiff *sdiff) bitmap_free(sdiff->skip); } +void prepare_diff_external_history(struct repository *repo) +{ + struct clean_status_sidecar_record sidecar = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct clean_status_config_digest digest; + struct worktree *worktree = NULL; + + if (!fstat_is_reliable() || getenv(INDEX_ENVIRONMENT) || + is_bare_repository(repo) || !repo_get_work_tree(repo) || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC || + repo_config_values(repo)->apply_sparse_checkout || + clean_status_sidecar_load(repo_get_index_file(repo), + repo->hash_algo, &sidecar)) + goto done; + worktree = get_current_worktree(repo); + if (!worktree || !is_main_worktree(worktree) || + clean_status_config_read_repository(repo, &digest) || + digest.filter_configured || + memcmp(digest.hash, sidecar.sidecar.proof.config_hash, + repo->hash_algo->rawsz)) + goto done; + clean_status_set_config_digest(repo, &digest); + clean_status_enable_external_history(repo); + +done: + free_worktree(worktree); + clean_status_sidecar_record_release(&sidecar); +} + int cmd_diff(int argc, const char **argv, const char *prefix, @@ -537,6 +570,7 @@ int cmd_diff(int argc, if (nongit) die(_("Not a git repository")); + prepare_diff_external_history(the_repository); argc = setup_revisions(argc, argv, &rev, NULL); if (!rev.diffopt.output_format) { rev.diffopt.output_format = DIFF_FORMAT_PATCH; diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index a2c70fcf53c210..d3355ce258c5dc 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -1562,6 +1562,59 @@ test_expect_success DURABLE_FSMONITOR \ external-token.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'diff restores clean history lost by a foreign index writer' ' + diff_repo=sidecar-foreign-diff && + test_when_finished "stop_daemon $diff_repo" && + setup_repo "$diff_repo" && + git -C "$diff_repo" config core.untrackedCache true && + issue_sidecar "$diff_repo" && + test_grep FSMN "$diff_repo/.git/index" && + test_grep FSCF "$diff_repo/.git/index" && + find "$diff_repo/.git" -maxdepth 1 -type f \ + -name "index.csh1.*" >diff-history.checkpoints && + test_line_count = 1 diff-history.checkpoints && + git -C "$diff_repo" ls-files --stage >diff-history.stage && + + rm "$diff_repo/.git/index" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -C "$diff_repo" read-tree HEAD && + test_grep ! FSMN "$diff_repo/.git/index" && + test_grep ! FSCF "$diff_repo/.git/index" && + git -c core.fsmonitor=false -C "$diff_repo" \ + ls-files --stage >diff-history.rewritten.stage && + test_cmp diff-history.stage diff-history.rewritten.stage && + cp "$diff_repo/.git/index" diff-history.index && + cp "$diff_repo/.git/index.csts" diff-history.sidecar && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/diff-history.clean.trace" \ + git -C "$diff_repo" diff --no-ext-diff \ + >diff-history.clean && + test_must_be_empty diff-history.clean && + test_cmp_bin diff-history.index "$diff_repo/.git/index" && + test_cmp_bin diff-history.sidecar "$diff_repo/.git/index.csts" && + test_trace2_data fsmonitor history/external-restored 1 \ + "$diff_repo/tracked" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/diff-history.dirty.trace" \ + git -C "$diff_repo" diff --no-ext-diff \ + >diff-history.dirty && + test_grep "^+changed$" diff-history.dirty && + test_trace2_data fsmonitor history/external-restored 1 \ + Date: Wed, 12 Aug 2026 00:50:57 -0500 Subject: [PATCH 268/403] sparse-checkout: avoid rewriting an unchanged index Sparse-checkout reapply and repeated set or add commands always commit the index after updating sparsity, even when no entries or stat data changed. Replacing an identical index discards its physical identity and needlessly refreshes repository metadata. Skip the write only when the index and its pending worktree flags are unchanged and no post-index-change hook is installed. Explicit --sparse-index and --no-sparse-index requests set updated_workdir before converting the index, so retain their required rewrite even when cache_changed is clear. Preserve index locking, warnings, cleanup, and configured hook invocations. Cover all three settled no-op commands, the configured hook, and both explicit sparse-index format transitions. --- builtin/sparse-checkout.c | 14 +++++-- t/t1091-sparse-checkout-builtin.sh | 59 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/builtin/sparse-checkout.c b/builtin/sparse-checkout.c index cb4a037b770291..f48d981ec10ccd 100644 --- a/builtin/sparse-checkout.c +++ b/builtin/sparse-checkout.c @@ -7,6 +7,7 @@ #include "dir.h" #include "environment.h" #include "gettext.h" +#include "hook.h" #include "object-file.h" #include "object-name.h" #include "parse-options.h" @@ -243,9 +244,16 @@ static int update_working_directory(struct repository *r, * files in the way or dirty entries that can't be removed. */ result = UPDATE_SPARSITY_SUCCESS; - if (result == UPDATE_SPARSITY_SUCCESS) - write_locked_index(r->index, &lock_file, COMMIT_LOCK); - else + if (result == UPDATE_SPARSITY_SUCCESS) { + unsigned int flags = COMMIT_LOCK; + + if (!r->index->cache_changed && + !r->index->updated_workdir && + !r->index->updated_skipworktree && + !hook_exists(r, "post-index-change")) + flags |= SKIP_IF_UNCHANGED; + write_locked_index(r->index, &lock_file, flags); + } else rollback_lock_file(&lock_file); clean_tracked_sparse_directories(r); diff --git a/t/t1091-sparse-checkout-builtin.sh b/t/t1091-sparse-checkout-builtin.sh index 74b1761e0c8507..48d4d30cae67e7 100755 --- a/t/t1091-sparse-checkout-builtin.sh +++ b/t/t1091-sparse-checkout-builtin.sh @@ -1274,4 +1274,63 @@ test_expect_success 'sparse-checkout operations with merge conflicts' ' ) ' +test_expect_success 'unchanged sparse-checkout commands preserve the index' ' + test_when_finished "rm -rf sparse-unchanged" && + test_create_repo sparse-unchanged && + mkdir sparse-unchanged/included sparse-unchanged/omitted && + test_write_lines included >sparse-unchanged/included/tracked && + test_write_lines omitted >sparse-unchanged/omitted/tracked && + git -C sparse-unchanged add . && + git -C sparse-unchanged commit -qm base && + git -C sparse-unchanged sparse-checkout set included && + for sparse_command in reapply set add + do + case "$sparse_command" in + reapply) set -- reapply ;; + set) set -- set included ;; + add) set -- add included ;; + esac && + cp sparse-unchanged/.git/index \ + "sparse-$sparse_command.index" && + GIT_TRACE2_EVENT="$PWD/sparse-$sparse_command.trace" \ + git -C sparse-unchanged sparse-checkout "$@" && + test_cmp_bin "sparse-$sparse_command.index" \ + sparse-unchanged/.git/index && + test_grep ! "\"label\":\"do_write_index\"" \ + "sparse-$sparse_command.trace" || return 1 + done +' + +test_expect_success 'sparse-checkout preserves hooks and explicit index modes' ' + test_when_finished "rm -rf sparse-hook" && + test_create_repo sparse-hook && + mkdir sparse-hook/included sparse-hook/omitted && + test_write_lines included >sparse-hook/included/tracked && + test_write_lines omitted >sparse-hook/omitted/tracked && + git -C sparse-hook add . && + git -C sparse-hook commit -qm base && + git -C sparse-hook sparse-checkout set included && + mkdir sparse-hook/hooks && + git -C sparse-hook config core.hooksPath hooks && + write_script sparse-hook/hooks/post-index-change <<-\EOF && + printf "%s %s\n" "$1" "$2" >>hook-actual + EOF + GIT_TRACE2_EVENT="$PWD/sparse-hook.trace" \ + git -C sparse-hook sparse-checkout reapply && + test_write_lines "0 0" >sparse-hook.expect && + test_cmp sparse-hook.expect sparse-hook/hook-actual && + test_grep "\"label\":\"do_write_index\"" sparse-hook.trace && + rm sparse-hook/hooks/post-index-change && + GIT_TRACE2_EVENT="$PWD/sparse-collapse.trace" \ + git -C sparse-hook sparse-checkout reapply --sparse-index && + git -C sparse-hook ls-files --sparse >sparse-collapsed && + test_grep "^omitted/$" sparse-collapsed && + test_grep "\"label\":\"do_write_index\"" sparse-collapse.trace && + GIT_TRACE2_EVENT="$PWD/sparse-expand.trace" \ + git -C sparse-hook sparse-checkout reapply --no-sparse-index && + git -C sparse-hook ls-files --sparse >sparse-expanded && + test_grep "^omitted/tracked$" sparse-expanded && + test_grep "\"label\":\"do_write_index\"" sparse-expand.trace +' + test_done From 60077b92ac3276464cd1ea7ab69ccb21308d8fa2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:37 -0500 Subject: [PATCH 269/403] status: preserve semantic history across scoped and index changes An otherwise clean worktree containing untracked files cannot use the whole-worktree clean sidecar. Status still has a closed fsmonitor token and individually certified tracked entries, but previously reused that state only for unqualified top-level status. Allow the existing tracked-clean and cache-tree shortcuts for safely supported query shapes, including pathspecs, machine-readable formats, branch and stash headers, and nested working directories. Preserve the provider, semantic, token, expanded-index, and tracked-entry checks. Staging, unstaging, or switching branches also invalidated the semantic proof whenever the logical index changed. Preserve it when the affected entries cannot change attribute or ignore rules. Verify new directory ancestors with anchored, no-follow attribute probes, and reject filters, sparse indexes, resolve-undo state, and changed semantic sources. For ordinary branch switches, transfer semantic history only after each changed entry passes those same checks. Keep modified worktree entries fsmonitor-invalid and clean sidecars bound to the new logical index. Cover scoped queries, staging, unstaging, newly indexed directories, branch switches, and attribute changes with provider regressions. --- attr-fingerprint.c | 129 +- attr-fingerprint.h | 3 + attr-manifest.c | 81 + attr-manifest.h | 3 + builtin/add.c | 2 + builtin/checkout.c | 19 +- builtin/commit.c | 106 +- builtin/fsmonitor--daemon.c | 10 +- builtin/read-tree.c | 1 + builtin/reset.c | 18 +- builtin/stash.c | 12 +- builtin/update-index.c | 74 +- clean-status-config.c | 267 ++ clean-status-config.h | 5 + clean-status-fast.c | 92 + clean-status-history-store.c | 74 + clean-status-history-store.h | 3 + clean-status-history.c | 1003 +++++- clean-status-index.c | 39 + clean-status-index.h | 2 + clean-status-internal.h | 11 + clean-status-manifest.c | 665 +++- clean-status-manifest.h | 6 + clean-status-sidecar-issue.c | 125 +- clean-status-sidecar.c | 151 +- clean-status-sidecar.h | 13 + clean-status.c | 289 ++ clean-status.h | 26 + compat/fsmonitor/fsm-listen-darwin.c | 20 + compat/fsmonitor/fsm-listen-linux.c | 42 +- compat/simple-ipc/ipc-unix-socket.c | 26 +- compat/simple-ipc/ipc-win32.c | 26 +- dir.c | 118 +- dir.h | 3 +- fsmonitor-clean-proof.c | 16 +- fsmonitor-clean-proof.h | 5 +- fsmonitor-ipc.c | 284 +- fsmonitor-ipc.h | 7 + fsmonitor-settings.c | 7 +- fsmonitor.c | 64 +- preload-index-bulk-index.c | 9 + preload-index.c | 7 + read-cache-ll.h | 1 + read-cache.c | 21 +- simple-ipc.h | 9 + t/helper/test-simple-ipc.c | 32 +- t/t7519-status-fsmonitor.sh | 536 +++- t/t7527-builtin-fsmonitor.sh | 4012 +++++++++++++++++++++++- t/t7530-status-clean-sidecar.sh | 726 ++++- t/unit-tests/u-attr-fingerprint.c | 118 + t/unit-tests/u-attr-manifest.c | 26 + t/unit-tests/u-clean-status-config.c | 81 + t/unit-tests/u-clean-status-sidecar.c | 121 + t/unit-tests/u-clean-status-store.c | 2 +- t/unit-tests/u-exclude-source-proof.c | 13 +- t/unit-tests/u-fsmonitor-clean-proof.c | 52 + unpack-trees.c | 130 +- unpack-trees.h | 3 +- wt-status.c | 398 ++- wt-status.h | 1 + 60 files changed, 9896 insertions(+), 249 deletions(-) diff --git a/attr-fingerprint.c b/attr-fingerprint.c index d0152d6fe23963..3f6fbf18ba4007 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -34,6 +34,7 @@ static int open_attr_source(const char *path) static int hash_source(struct git_hash_ctx *content_ctx, struct git_hash_ctx *namespace_ctx, + struct git_hash_ctx *portable_namespace_ctx, const struct attr_fingerprint_source *source, int *present, struct attr_source_snapshot_entry *snapshot) @@ -49,14 +50,20 @@ static int hash_source(struct git_hash_ctx *content_ctx, int fd = -1, ret = -1; char extra; - hash_optional_cstring(content_ctx, source->path); hash_optional_cstring(namespace_ctx, source->path); put_be32(&state, source->enabled); - hash_length_delimited(content_ctx, &state, sizeof(state)); hash_length_delimited(namespace_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, sizeof(state)); *present = 0; - if (!source->enabled || !source->path) + if (!source->enabled || !source->path) { + hash_optional_cstring(content_ctx, NULL); + hash_length_delimited(content_ctx, &state, sizeof(state)); + state = 0; + hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, + sizeof(state)); return 0; + } absolute = absolute_pathdup(source->path); strbuf_addstr(&normalized, absolute); @@ -64,12 +71,18 @@ static int hash_source(struct git_hash_ctx *content_ctx, path_namespace_capture(normalized.buf, &before)) goto done; *present = path_namespace_target_present(before); + hash_optional_cstring(content_ctx, + *present ? source->path : NULL); + put_be32(&state, source->enabled); + hash_length_delimited(content_ctx, &state, sizeof(state)); if (!*present) { if (path_namespace_capture(normalized.buf, &after) || !path_namespace_equal(before, after)) goto done; state = 0; hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, + sizeof(state)); path_namespace_hash(namespace_ctx, before); ret = 0; goto done; @@ -93,9 +106,15 @@ static int hash_source(struct git_hash_ctx *content_ctx, goto done; state = 1; hash_length_delimited(content_ctx, &state, sizeof(state)); + hash_length_delimited(portable_namespace_ctx, &state, + sizeof(state)); + hash_optional_cstring(portable_namespace_ctx, source->path); path_namespace_hash(namespace_ctx, before); + path_namespace_hash(portable_namespace_ctx, before); path_namespace_hash_stat(namespace_ctx, &opened_after); + path_namespace_hash_stat(portable_namespace_ctx, &opened_after); hash_length_delimited(content_ctx, buf, size); + hash_length_delimited(portable_namespace_ctx, buf, size); if (snapshot) { snapshot->path = xstrdup(source->path); snapshot->buf = buf; @@ -119,7 +138,7 @@ static int fingerprint_sources( const struct git_hash_algo *algo, struct attr_fingerprint *result, struct attr_source_snapshot *snapshot) { - struct git_hash_ctx content_ctx, namespace_ctx; + struct git_hash_ctx content_ctx, namespace_ctx, portable_namespace_ctx; uint32_t count; if (snapshot && nr != ARRAY_SIZE(snapshot->sources)) @@ -127,26 +146,33 @@ static int fingerprint_sources( memset(result, 0, sizeof(*result)); git_hash_init(&content_ctx, algo); git_hash_init(&namespace_ctx, algo); + git_hash_init(&portable_namespace_ctx, algo); hash_optional_cstring(&content_ctx, "attribute-source-content-v1"); hash_optional_cstring(&namespace_ctx, "attribute-source-namespace-v1"); + hash_optional_cstring(&portable_namespace_ctx, + "attribute-source-portable-namespace-v1"); if (nr > UINT32_MAX) return -1; put_be32(&count, nr); hash_length_delimited(&content_ctx, &count, sizeof(count)); hash_length_delimited(&namespace_ctx, &count, sizeof(count)); + hash_length_delimited(&portable_namespace_ctx, &count, sizeof(count)); for (size_t i = 0; i < nr; i++) { int present; struct attr_source_snapshot_entry *entry = snapshot ? &snapshot->sources[i] : NULL; - if (hash_source(&content_ctx, &namespace_ctx, &sources[i], - &present, entry)) + if (hash_source(&content_ctx, &namespace_ctx, + &portable_namespace_ctx, &sources[i], &present, + entry)) return -1; result->sources_present |= present; } git_hash_final(result->content_hash, &content_ctx); git_hash_final(result->namespace_hash, &namespace_ctx); + git_hash_final(result->portable_namespace_hash, + &portable_namespace_ctx); return 0; } @@ -189,6 +215,97 @@ int attr_fingerprint_repository(struct repository *repo, return ret; } +static int legacy_absent_path_is_stable(const char *path) +{ + struct path_namespace_snapshot *before = NULL, *after = NULL; + struct strbuf normalized = STRBUF_INIT; + char *absolute = NULL; + int stable = 0; + + if (!path) + return 0; + absolute = absolute_pathdup(path); + strbuf_addstr(&normalized, absolute); + if (!strbuf_normalize_path(&normalized) && + !path_namespace_capture(normalized.buf, &before) && + !path_namespace_target_present(before) && + !path_namespace_capture(normalized.buf, &after) && + !path_namespace_target_present(after) && + path_namespace_equal(before, after)) + stable = 1; + free(absolute); + path_namespace_clear(before); + path_namespace_clear(after); + strbuf_release(&normalized); + return stable; +} + +static void legacy_absent_source_hash( + const struct attr_fingerprint_source *sources, + const char *system_path, const struct git_hash_algo *algo, + unsigned char *hash) +{ + struct git_hash_ctx ctx; + uint32_t value; + + git_hash_init(&ctx, algo); + hash_optional_cstring(&ctx, "attribute-source-content-v1"); + put_be32(&value, ATTR_SOURCE_SNAPSHOT_NR); + hash_length_delimited(&ctx, &value, sizeof(value)); + for (size_t i = 0; i < ATTR_SOURCE_SNAPSHOT_NR; i++) { + const char *path = i == ATTR_SOURCE_SNAPSHOT_SYSTEM ? + system_path : sources[i].path; + + hash_optional_cstring(&ctx, path); + put_be32(&value, sources[i].enabled); + hash_length_delimited(&ctx, &value, sizeof(value)); + if (!sources[i].enabled || !path) + continue; + put_be32(&value, 0); + hash_length_delimited(&ctx, &value, sizeof(value)); + } + git_hash_final(hash, &ctx); +} + +int attr_fingerprint_matches_legacy_absent_sources( + struct repository *repo, const unsigned char *expected) +{ + static const char shipped_system_path[] = "//etc/gitattributes"; + struct attr_fingerprint_source sources[ATTR_SOURCE_SNAPSHOT_NR]; + struct attr_fingerprint before, after; + unsigned char legacy[GIT_MAX_RAWSZ]; + char *info_attributes = NULL; + int matches = 0; + + if (!expected || !fstat_is_reliable() || + repository_sources(repo, sources, &info_attributes) || + !sources[ATTR_SOURCE_SNAPSHOT_SYSTEM].enabled || + attr_fingerprint_repository(repo, &before) || + before.sources_present) + goto done; + legacy_absent_source_hash( + sources, sources[ATTR_SOURCE_SNAPSHOT_SYSTEM].path, + repo->hash_algo, legacy); + if (!memcmp(legacy, expected, repo->hash_algo->rawsz)) { + matches = 1; + } else if (legacy_absent_path_is_stable(shipped_system_path)) { + legacy_absent_source_hash( + sources, shipped_system_path, repo->hash_algo, legacy); + matches = !memcmp(legacy, expected, repo->hash_algo->rawsz); + } + if (!matches || attr_fingerprint_repository(repo, &after) || + after.sources_present || + memcmp(before.content_hash, after.content_hash, + repo->hash_algo->rawsz) || + memcmp(before.namespace_hash, after.namespace_hash, + repo->hash_algo->rawsz)) + matches = 0; + +done: + free(info_attributes); + return matches; +} + int attr_source_snapshot_repository(struct repository *repo, struct attr_source_snapshot **result) { diff --git a/attr-fingerprint.h b/attr-fingerprint.h index 518a5b31b4e485..5d2bedf93d38a4 100644 --- a/attr-fingerprint.h +++ b/attr-fingerprint.h @@ -13,6 +13,7 @@ struct attr_fingerprint_source { struct attr_fingerprint { unsigned char content_hash[GIT_MAX_RAWSZ]; unsigned char namespace_hash[GIT_MAX_RAWSZ]; + unsigned char portable_namespace_hash[GIT_MAX_RAWSZ]; unsigned int sources_present : 1; }; @@ -30,6 +31,8 @@ int attr_fingerprint_sources( const struct git_hash_algo *algo, struct attr_fingerprint *result); int attr_fingerprint_repository(struct repository *repo, struct attr_fingerprint *result); +int attr_fingerprint_matches_legacy_absent_sources( + struct repository *repo, const unsigned char *expected); int attr_source_snapshot_repository(struct repository *repo, struct attr_source_snapshot **result); int attr_source_snapshot_matches_repository( diff --git a/attr-manifest.c b/attr-manifest.c index 46aed49a430050..6c835f26cb410c 100644 --- a/attr-manifest.c +++ b/attr-manifest.c @@ -1,4 +1,5 @@ #include "git-compat-util.h" +#include "attr.h" #include "attr-manifest.h" #include "environment.h" #include "read-cache-ll.h" @@ -45,6 +46,86 @@ static int attr_manifest_entry_equal(const struct attr_manifest_entry *a, !memcmp(a->hash, b->hash, algo->rawsz); } +static void release_parsed_attr(struct match_attr *match) +{ + for (size_t i = 0; i < match->num_attr; i++) { + const char *value = match->state[i].setto; + + if (!ATTR_TRUE(value) && !ATTR_FALSE(value) && + !ATTR_UNSET(value)) + free((char *)value); + } + free(match); +} + +static int normalize_conversion_attributes( + const char *data, size_t len, struct strbuf *normalized) +{ + struct strbuf line = STRBUF_INIT; + size_t offset = 0; + int lineno = 0, ret = -1; + + if ((!data && len) || memchr(data, '\0', len)) + goto done; + while (offset < len) { + const char *start = data + offset; + const char *newline = memchr(start, '\n', len - offset); + const char *trimmed; + struct match_attr *match; + size_t line_len = newline ? + (size_t)(newline - start) + 1 : len - offset; + int display_only; + + strbuf_reset(&line); + strbuf_add(&line, start, line_len); + trimmed = line.buf + strspn(line.buf, " \t\r\n"); + lineno++; + if (!*trimmed || *trimmed == '#') { + strbuf_add(normalized, start, line_len); + offset += line_len; + continue; + } + if (starts_with(trimmed, ATTRIBUTE_MACRO_PREFIX)) + goto done; + match = parse_attr_line(line.buf, GITATTRIBUTES_FILE, lineno, 0); + if (!match || match->is_macro || !match->num_attr) { + if (match) + release_parsed_attr(match); + goto done; + } + display_only = 1; + for (size_t i = 0; i < match->num_attr; i++) + if (strcmp(git_attr_name(match->state[i].attr), + "linguist-generated")) + display_only = 0; + release_parsed_attr(match); + if (!display_only) + strbuf_add(normalized, start, line_len); + offset += line_len; + } + ret = 0; + +done: + strbuf_release(&line); + return ret; +} + +int attr_manifest_only_linguist_generated_changed( + const char *old_data, size_t old_len, + const char *new_data, size_t new_len) +{ + struct strbuf old = STRBUF_INIT, new = STRBUF_INIT; + int equal = 0; + + if (!normalize_conversion_attributes(old_data, old_len, &old) && + !normalize_conversion_attributes(new_data, new_len, &new)) + equal = old.len == new.len && + !memcmp(old.buf, new.buf, old.len); + strbuf_release(&old); + strbuf_release(&new); + return equal; +} + void attr_manifest_writer_init(struct attr_manifest_writer *writer, struct strbuf *buf, const struct git_hash_algo *algo) diff --git a/attr-manifest.h b/attr-manifest.h index a38acccc224832..6718afd5c897f9 100644 --- a/attr-manifest.h +++ b/attr-manifest.h @@ -55,5 +55,8 @@ int attr_manifest_for_each_changed(const void *old_data, size_t old_len, const void *new_data, size_t new_len, const struct git_hash_algo *algo, attr_manifest_change_fn fn, void *data); +int attr_manifest_only_linguist_generated_changed( + const char *old_data, size_t old_len, + const char *new_data, size_t new_len); #endif /* ATTR_MANIFEST_H */ diff --git a/builtin/add.c b/builtin/add.c index b13ba9580cf896..e7871643c6b97b 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -511,11 +511,13 @@ int cmd_add(int argc, * add/remove decision below. */ if (refresh_only) { + clean_status_enable_external_history(repo); clean_status_set_config_digest(repo, &clean_digest); } else if (!show_only && !intent_to_add && !add_renormalize && !chmod_arg && !include_sparse && !ignore_add_errors) { preserve_add_history = 1; flags |= ADD_CACHE_TRACK_CLEAN_HISTORY; + clean_status_enable_external_history(repo); clean_status_set_config_digest(repo, &clean_digest); } diff --git a/builtin/checkout.c b/builtin/checkout.c index c18b8ce85f2a51..bae66a8ae5456c 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -239,7 +239,10 @@ static int update_some(const struct object_id *oid, struct strbuf *base, } } - if (checkout_context && checkout_context->index_changed) + if (checkout_context && checkout_context->index_changed && + !clean_status_index_entry_is_semantically_safe( + the_repository->index, + pos >= 0 ? the_repository->index->cache[pos] : NULL, ce)) *checkout_context->index_changed = 1; add_index_entry(the_repository->index, ce, ADD_CACHE_OK_TO_ADD | ADD_CACHE_OK_TO_REPLACE); @@ -452,7 +455,9 @@ static void mark_ce_for_checkout_no_overlay(struct cache_entry *ce, * tree-ish, which means we should remove it * from the index and the working tree. */ - if (index_changed) + if (index_changed && + !clean_status_index_entry_is_semantically_safe( + the_repository->index, ce, NULL)) *index_changed = 1; ce->ce_flags |= CE_REMOVE | CE_WT_REMOVE; } @@ -665,9 +670,11 @@ static int checkout_paths(const struct checkout_opts *opts, !opts->merge && !opts->writeout_stage; if ((opts->checkout_worktree && !opts->source_tree && !opts->merge && !opts->writeout_stage) || - preserve_source_tree_history) + preserve_source_tree_history) { + clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &opts->clean_digest); + } if (repo_read_index_preload(the_repository, &opts->pathspec, 0) < 0) return error(_("index file corrupt")); @@ -904,9 +911,12 @@ static int merge_working_tree(const struct checkout_opts *opts, * target tree matches the index. Let unpack_trees() transfer the * proof only after it proves that the rebuilt index is identical. */ - if (opts->discard_changes) + if (opts->discard_changes || + (!opts->merge && !opts->new_orphan_branch)) { + clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &opts->clean_digest); + } if (repo_read_index_preload(the_repository, NULL, 0) < 0) { rollback_lock_file(&lock_file); return error(_("index file corrupt")); @@ -952,6 +962,7 @@ static int merge_working_tree(const struct checkout_opts *opts, /* 2-way merge to the new branch */ init_topts(&topts, opts->show_progress, opts->overwrite_ignore, quiet); + topts.preserve_semantic_history = 1; init_checkout_metadata(&topts.meta, new_branch_info->refname, new_branch_info->commit ? &new_branch_info->commit->object.oid : diff --git a/builtin/commit.c b/builtin/commit.c index fc0e043ac109f8..731ef7df12b4aa 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -15,6 +15,7 @@ #include "cache-tree.h" #include "clean-status.h" #include "clean-status-index.h" +#include "clean-status-sidecar.h" #include "color.h" #include "dir.h" #include "editor.h" @@ -44,9 +45,11 @@ #include "sparse-index.h" #include "mailmap.h" #include "help.h" +#include "hook.h" #include "commit-reach.h" #include "commit-graph.h" #include "pretty.h" +#include "trace2.h" #include "trailer.h" static const char * const builtin_commit_usage[] = { @@ -1636,6 +1639,37 @@ static int print_clean_sidecar(struct wt_status *s, const char *prefix) return 1; } +static int clean_status_sidecar_needs_reissue(struct repository *repo) +{ + struct clean_status_sidecar_record record = + CLEAN_STATUS_SIDECAR_RECORD_INIT; + struct clean_status_index_snapshot index = { .fd = -1 }; + char *path = xstrfmt("%s.csts", repo->index_file); + struct stat st; + int safe_existing = !lstat(path, &st) && + S_ISREG(st.st_mode) && st.st_nlink == 1 && + is_path_owned_by_current_user(path, NULL); + int reissue = 0; + + if (!clean_status_sidecar_load( + repo->index_file, repo->hash_algo, &record)) + reissue = safe_existing && + (!!clean_status_sidecar_pin_source( + repo->index_file, &record.sidecar, + repo->hash_algo, &index) || + record.sidecar.hardlink_nr > 0); + else { + if (lstat(path, &st) < 0) + reissue = errno == ENOENT; + else + reissue = safe_existing; + } + free(path); + clean_status_index_snapshot_release(&index); + clean_status_sidecar_record_release(&record); + return reissue; +} + int cmd_status(int argc, const char **argv, const char *prefix, @@ -1654,6 +1688,9 @@ struct repository *repo UNUSED) int normal_clean_query; int reusable_clean_query; int normal_has_head; + int reissue_clean_sidecar = 0; + int reissue_after_write = 0; + int save_history_after_write = 0; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1774,6 +1811,10 @@ struct repository *repo UNUSED) return 0; } } + if (normal_clean_query && use_optional_locks() && + clean_status_identity_is_durable()) + reissue_clean_sidecar = + clean_status_sidecar_needs_reissue(the_repository); if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) { @@ -1786,8 +1827,10 @@ struct repository *repo UNUSED) clean_status_capture_external_history_source( the_repository->index); if (normal_clean_query && use_optional_locks() && - clean_status_external_history_was_restored( - the_repository->index)) + clean_status_identity_is_durable() && + (reissue_clean_sidecar || + clean_status_external_history_was_restored( + the_repository->index))) s.certify_clean_status = 1; wt_status_start_untracked_cache_preload(&s); wt_status_refresh_index( @@ -1827,10 +1870,18 @@ struct repository *repo UNUSED) clean_status_external_history_was_restored( the_repository->index); int external_saved = 0; + int persist_restored_boundary = 0; int preserve_entry_changes = + (!external_restored && + (the_repository->index->cache_changed & CE_ENTRY_CHANGED)) || + the_repository->index->fsmonitor_untracked_must_persist; + int deferred_history = preserve_entry_changes && !external_restored && - (the_repository->index->cache_changed & - CE_ENTRY_CHANGED); + clean_status_has_recovered_tracked_stat( + the_repository->index); + int preserve_history_witness = external_restored && + clean_status_external_history_needs_witness_preservation( + the_repository->index); /* * Publish resumable history before the physical clean proof. @@ -1843,8 +1894,24 @@ struct repository *repo UNUSED) * entry repair durable. Restored checkpoints stay no-spill * for foreign index writers. */ - external_saved = clean_status_save_external_history( - the_repository->index); + if (!deferred_history && !preserve_history_witness) + external_saved = clean_status_save_external_history( + the_repository->index); + else if (deferred_history && + !hook_exists(the_repository, "post-index-change")) + save_history_after_write = 1; + if (external_restored && !external_saved && + clean_status_external_history_owns_index( + the_repository->index) && + has_racy_timestamp(the_repository->index)) { + persist_restored_boundary = 1; + trace2_data_intmax("fsmonitor", the_repository, + "history/external-racy-index-persisted", 1); + } + reissue_after_write = normal_clean_query && + reissue_clean_sidecar && preserve_entry_changes && + !external_restored && !persist_restored_boundary && + !hook_exists(the_repository, "post-index-change"); if (the_repository->index->fsmonitor_legacy_untracked_fallback && !preserve_entry_changes && !external_saved) { @@ -1856,23 +1923,46 @@ struct repository *repo UNUSED) &s, &clean_digest, &index_lock, 0)) fd = -1; else if (!preserve_entry_changes && + !persist_restored_boundary && (external_restored || external_saved)) { rollback_lock_file(&index_lock); fd = -1; } } else if (!preserve_entry_changes && - normal_clean_query && external_restored && + !persist_restored_boundary && + normal_clean_query && + (external_restored || reissue_clean_sidecar) && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 1)) { fd = -1; } else if (!preserve_entry_changes && + !persist_restored_boundary && (external_restored || external_saved)) { rollback_lock_file(&index_lock); fd = -1; } } - if (0 <= fd) + if (0 <= fd) { repo_update_index_if_able(the_repository, &index_lock); + if (save_history_after_write && + !hook_exists(the_repository, "post-index-change") && + repo_hold_locked_index(the_repository, &index_lock, 0) >= 0) { + if (clean_status_save_external_history( + the_repository->index)) + trace2_data_intmax("fsmonitor", the_repository, + "history/external-postwrite-stored", 1); + rollback_lock_file(&index_lock); + } + if (reissue_after_write && + repo_hold_locked_index(the_repository, &index_lock, 0) >= 0) { + if (clean_status_issue_sidecar( + &s, &clean_digest, &index_lock, 1)) + trace2_data_intmax("status", the_repository, + "clean-proof/postwrite-reissued", 1); + else + rollback_lock_file(&index_lock); + } + } if (s.relative_paths) s.prefix = prefix; diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 65780205798554..6adef864a65a7c 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -415,6 +415,10 @@ static struct fsmonitor_token_data *fsmonitor_new_token_data(void) struct tm tm; time_t secs; +#ifdef __APPLE__ + strbuf_addstr(&token->token_id, + FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX); +#endif gettimeofday(&tv, NULL); secs = tv.tv_sec; gmtime_r(&secs, &tm); @@ -742,7 +746,11 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, if (!strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { static const char capabilities[] = - FSMONITOR_IPC_QUERY_VERSION "\n"; + FSMONITOR_IPC_QUERY_VERSION "\n" +#ifdef __APPLE__ + FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" +#endif + ; return reply(reply_data, capabilities, sizeof(capabilities) - 1); diff --git a/builtin/read-tree.c b/builtin/read-tree.c index 8e3b023271723c..9a9f2c4a8b7e47 100644 --- a/builtin/read-tree.c +++ b/builtin/read-tree.c @@ -217,6 +217,7 @@ int cmd_read_tree(int argc, !opts.super_prefix && !index_output && !should_update_submodules()) { preserve_history = 1; + clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &clean_digest); } diff --git a/builtin/reset.c b/builtin/reset.c index dfa0b3624c792a..69dda83f02ca8f 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -161,9 +161,17 @@ static void update_index_from_diff(struct diff_queue_struct *q, int pos; struct diff_filespec *one = q->queue[i]->one; int is_in_reset_tree = one->mode && !is_null_oid(&one->oid); + struct cache_entry *old; struct cache_entry *ce; + pos = index_name_pos(the_repository->index, one->path, + strlen(one->path)); + old = pos >= 0 ? the_repository->index->cache[pos] : NULL; if (!is_in_reset_tree && !intent_to_add) { + if (!clean_status_index_entry_is_semantically_safe( + the_repository->index, old, NULL)) + clean_status_invalidate_current_proof( + the_repository->index); remove_file_from_index(the_repository->index, one->path); continue; } @@ -179,7 +187,6 @@ static void update_index_from_diff(struct diff_queue_struct *q, * if this entry is outside the sparse cone - this is necessary * to properly construct the reset sparse directory. */ - pos = index_name_pos(the_repository->index, one->path, strlen(one->path)); if ((pos >= 0 && ce_skip_worktree(the_repository->index->cache[pos])) || (pos < 0 && !path_in_sparse_checkout(one->path, the_repository->index))) ce->ce_flags |= CE_SKIP_WORKTREE; @@ -191,6 +198,10 @@ static void update_index_from_diff(struct diff_queue_struct *q, ce->ce_flags |= CE_INTENT_TO_ADD; set_object_name_for_intent_to_add_entry(ce); } + if (!clean_status_index_entry_is_semantically_safe( + the_repository->index, old, ce)) + clean_status_invalidate_current_proof( + the_repository->index); add_index_entry(the_repository->index, ce, ADD_CACHE_OK_TO_ADD | ADD_CACHE_OK_TO_REPLACE); } @@ -498,6 +509,11 @@ int cmd_reset(int argc, !pathspec.nr && !intent_to_add && !unborn) { preserve_mixed_history = reset_type == MIXED; + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, &clean_digest); + } else if (reset_type == MIXED && pathspec.nr && + !intent_to_add && !unborn) { + clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &clean_digest); } diff --git a/builtin/stash.c b/builtin/stash.c index 6458ca7f9d91f8..898dc41007cfc1 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1710,9 +1710,11 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q * If changes are found, invalidate it before stash machinery * mutates the index or worktree. */ - if (preserve_clean_history) + if (preserve_clean_history) { + clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &stash_clean_digest); + } repo_read_index_preload(the_repository, NULL, 0); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); @@ -1746,13 +1748,17 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q printf_ln(_("No local changes to save")); goto done; } + if (preserve_clean_history) { + clean_status_invalidate_current_proof(the_repository->index); + if (clean_status_should_write_fsmonitor_config( + the_repository->index)) + the_repository->index->cache_changed |= FSMONITOR_CHANGED; + } if (write_locked_index(the_repository->index, &index_lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) { ret = error(_("could not write index")); goto done; } - if (preserve_clean_history) - clean_status_invalidate_current_proof(the_repository->index); if (!refs_reflog_exists(get_main_ref_store(the_repository), ref_stash) && do_clear_stash()) { ret = -1; diff --git a/builtin/update-index.c b/builtin/update-index.c index b8b565f0d6f632..66746d11352e67 100644 --- a/builtin/update-index.c +++ b/builtin/update-index.c @@ -65,6 +65,19 @@ static int update_index_config(const char *key, const char *value, static int is_proof_preserving_rewrite(int argc, const char **argv) { + if (argc >= 4 && !strcmp(argv[1], "--refresh") && + !strcmp(argv[2], "--")) { + for (int i = 3; i < argc; i++) { + const char *base = strrchr(argv[i], '/'); + + base = base ? base + 1 : argv[i]; + if (!*base || !strcasecmp(base, ".gitattributes") || + !strcasecmp(base, ".gitignore")) + return 0; + } + return 1; + } + if (argc == 2) return !strcmp(argv[1], "--refresh") || !strcmp(argv[1], "--force-write-index"); @@ -78,6 +91,20 @@ static int is_proof_preserving_rewrite(int argc, const char **argv) !strcmp(argv[2], "--refresh")); } +static int is_fsmonitor_invalidation_rewrite(int argc, const char **argv) +{ + int first_path = 2; + + if (argc < 3 || strcmp(argv[1], "--no-fsmonitor-valid")) + return 0; + if (!strcmp(argv[first_path], "--")) + return argc > ++first_path; + for (int i = first_path; i < argc; i++) + if (argv[i][0] == '-') + return 0; + return 1; +} + /* Untracked cache mode */ enum uc_mode { UC_UNSPECIFIED = -1, @@ -273,6 +300,14 @@ static int mark_ce_flags(const char *path, int flag, int mark) the_repository->index->cache[pos]->ce_flags |= flag; else the_repository->index->cache[pos]->ce_flags &= ~flag; + if (flag == CE_FSMONITOR_VALID && !mark && + clean_status_external_history_enabled( + the_repository->index) && + !the_repository->index->split_index) { + /* The fsmonitor bitmap does not change the indexed tree. */ + the_repository->index->cache_changed |= FSMONITOR_CHANGED; + return 0; + } the_repository->index->cache[pos]->ce_flags |= CE_UPDATE_IN_BASE; cache_tree_invalidate_path(the_repository->index, path); the_repository->index->cache_changed |= CE_ENTRY_CHANGED; @@ -285,6 +320,8 @@ static int remove_one_path(const char *path) { if (!allow_remove) return error("%s: does not exist and --remove not passed", path); + if (clean_status_external_history_enabled(the_repository->index)) + clean_status_invalidate_current_proof(the_repository->index); if (remove_file_from_index(the_repository->index, path)) return error("%s: cannot remove from the index", path); return 0; @@ -327,6 +364,12 @@ static int add_one_path(const struct cache_entry *old, const char *path, int len } option = allow_add ? ADD_CACHE_OK_TO_ADD : 0; option |= allow_replace ? ADD_CACHE_OK_TO_REPLACE : 0; + if (clean_status_external_history_enabled(the_repository->index) && + (!old || old->ce_mode != ce->ce_mode || + !oideq(&old->oid, &ce->oid)) && + !clean_status_index_entry_is_semantically_safe( + the_repository->index, old, ce)) + clean_status_invalidate_current_proof(the_repository->index); if (add_index_entry(the_repository->index, ce, option)) { discard_cache_entry(ce); return error("%s: cannot add to the index - missing --add option?", path); @@ -362,6 +405,9 @@ static int process_directory(const char *path, int len, struct stat *st) struct object_id oid; int pos = index_name_pos(the_repository->index, path, len); + if (clean_status_external_history_enabled(the_repository->index)) + clean_status_invalidate_current_proof(the_repository->index); + /* Exact match: file or existing gitlink */ if (pos >= 0) { const struct cache_entry *ce = the_repository->index->cache[pos]; @@ -957,8 +1003,11 @@ int cmd_update_index(int argc, struct parse_opt_ctx_t ctx; strbuf_getline_fn getline_fn; int parseopt_state = PARSE_OPT_UNKNOWN; + int preserve_fsmonitor_history = + is_fsmonitor_invalidation_rewrite(argc, argv); int preserve_clean_history = - is_proof_preserving_rewrite(argc, argv); + is_proof_preserving_rewrite(argc, argv) || + preserve_fsmonitor_history; struct repository *r = the_repository; struct odb_transaction *transaction; struct option options[] = { @@ -1135,6 +1184,7 @@ int cmd_update_index(int argc, * but cannot change the logical contents of the index. */ clean_status_set_config_digest(the_repository, &clean_digest); + clean_status_enable_external_history(the_repository); } else { repo_config(the_repository, git_default_config, NULL); } @@ -1150,6 +1200,28 @@ int cmd_update_index(int argc, entries = repo_read_index(the_repository); if (entries < 0) die("cache corrupted"); + if (preserve_clean_history && argc >= 4 && + !strcmp(argv[1], "--refresh") && !strcmp(argv[2], "--")) { + if (the_repository->index->split_index || + the_repository->index->sparse_index) + clean_status_invalidate_current_proof( + the_repository->index); + for (int i = 3; i < argc; i++) { + char *path = prefix_path(the_repository, prefix, + prefix_length, argv[i]); + int pos = index_name_pos(the_repository->index, + path, strlen(path)); + const struct cache_entry *ce = pos < 0 ? NULL : + the_repository->index->cache[pos]; + + if (!ce || !S_ISREG(ce->ce_mode) || ce_stage(ce) || + ce_skip_worktree(ce) || ce_intent_to_add(ce) || + (ce->ce_flags & CE_VALID)) + clean_status_invalidate_current_proof( + the_repository->index); + free(path); + } + } the_repository->index->updated_skipworktree = 1; diff --git a/clean-status-config.c b/clean-status-config.c index 0cbab0fe50acd4..1d32470e1e5586 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -1,9 +1,15 @@ #include "git-compat-util.h" +#include "abspath.h" #include "clean-status-config.h" +#include "clean-status-index.h" #include "config.h" +#include "environment.h" #include "hash-framing.h" +#include "path-namespace.h" +#include "read-cache-ll.h" #include "repository.h" #include "strbuf.h" +#include "wrapper.h" #define CLEAN_STATUS_FILTER_PROOF_DOMAIN \ "clean-status-configured-filter-scope-v1" @@ -16,6 +22,9 @@ void clean_status_config_init(struct clean_status_config_digest *digest, memset(digest, 0, sizeof(*digest)); git_hash_init(&digest->ctx, algo); git_hash_init(&digest->semantic_ctx, algo); + git_hash_init(&digest->tracked_policy_ctx, algo); + hash_optional_cstring(&digest->tracked_policy_ctx, + "clean-status-tracked-policy-v1"); /* Invalidate proofs written before multiply-linked files stayed dirty. */ hash_optional_cstring(&digest->ctx, "clean-status-config-hardlink-v1"); @@ -49,6 +58,40 @@ static void hash_effective_config_entry(struct git_hash_ctx *ctx, hash_optional_cstring(ctx, value); } +static int config_is_command_transport(const char *key, + const struct config_context *ctx) +{ + const char *subsection, *subkey; + size_t subsection_len; + + if (!ctx || !ctx->kvi || ctx->kvi->scope != CONFIG_SCOPE_COMMAND) + return 0; + if (starts_with(key, "credential.")) + return 1; + if (parse_config_key(key, "url", &subsection, &subsection_len, + &subkey) || !subsection || !subsection_len) + return 0; + return !strcmp(subkey, "insteadof") || + !strcmp(subkey, "pushinsteadof"); +} + +static int config_is_tracked_policy(const char *key) +{ + return !strcmp(key, "core.filemode") || + !strcmp(key, "core.trustctime") || + !strcmp(key, "core.checkstat") || + !strcmp(key, "core.symlinks") || + !strcmp(key, "core.ignorecase") || + !strcmp(key, "core.ignorestat") || + !strcmp(key, "core.sparsecheckout") || + !strcmp(key, "core.sparsecheckoutcone") || + !strcmp(key, "core.precomposeunicode") || + !strcmp(key, "core.protecthfs") || + !strcmp(key, "core.protectntfs") || + !strcmp(key, "core.excludesfile") || + !strcmp(key, "core.attributesfile"); +} + void clean_status_config_add(struct clean_status_config_digest *digest, const char *key, const char *value, const struct config_context *ctx) @@ -58,7 +101,13 @@ void clean_status_config_add(struct clean_status_config_digest *digest, if (!digest->initialized || digest->finalized) BUG("invalid clean-status config digest state"); + /* Process-local transport settings cannot change a worktree proof. */ + if (config_is_command_transport(key, ctx)) + return; hash_config_entry(&digest->ctx, key, value, ctx); + if (config_is_tracked_policy(key)) + hash_effective_config_entry(&digest->tracked_policy_ctx, + key, value); semantic = !strcmp(key, "core.autocrlf") || !strcmp(key, "core.eol") || !strcmp(key, "core.checkroundtripencoding"); @@ -91,6 +140,8 @@ void clean_status_config_final(struct clean_status_config_digest *digest) } git_hash_final(digest->hash, &digest->ctx); git_hash_final(digest->semantic_hash, &digest->semantic_ctx); + git_hash_final(digest->tracked_policy_hash, + &digest->tracked_policy_ctx); digest->finalized = 1; } @@ -118,3 +169,219 @@ int clean_status_config_read_repository( clean_status_config_final(digest); return 0; } + +#ifdef __APPLE__ +struct config_epoch_source { + char *path; + struct path_namespace_snapshot *namespace; + struct stat stat; + int fd; +}; + +struct config_epoch_proof { + struct config_epoch_source *sources; + char *system_path; + size_t nr; + size_t alloc; + struct stat index; + int failed; + int system_seen; +}; + +static int config_epoch_command_is_safe( + const char *key, const struct config_context *ctx) +{ + return starts_with(key, "advice.") || + !strcmp(key, "user.name") || !strcmp(key, "user.email") || + !strcmp(key, "core.preloadindexbulk") || + config_is_command_transport(key, ctx); +} + +static int config_epoch_source_precedes_index( + const struct stat *source, const struct stat *index) +{ + return source->st_ctimespec.tv_sec < index->st_birthtimespec.tv_sec || + (source->st_ctimespec.tv_sec == + index->st_birthtimespec.tv_sec && + source->st_ctimespec.tv_nsec < + index->st_birthtimespec.tv_nsec); +} + +static int config_epoch_capture_source( + const char *key, const char *value UNUSED, + const struct config_context *ctx, void *data) +{ + struct config_epoch_proof *proof = data; + struct config_epoch_source *source; + struct path_namespace_snapshot *after = NULL; + struct strbuf normalized = STRBUF_INIT; + struct stat named; + char *absolute = NULL; + int fd = -1; + int allocated = 0; + + if (proof->failed) + return 0; + if (!ctx || !ctx->kvi) + goto fail; + if (ctx->kvi->scope == CONFIG_SCOPE_COMMAND) { + if (!config_epoch_command_is_safe(key, ctx)) + goto fail; + return 0; + } + if (starts_with(key, "includeif.")) + goto fail; + if (ctx->kvi->origin_type != CONFIG_ORIGIN_FILE || + !ctx->kvi->filename || !*ctx->kvi->filename) + goto fail; + for (size_t i = 0; i < proof->nr; i++) + if (!strcmp(proof->sources[i].path, ctx->kvi->filename)) + return 0; + absolute = absolute_pathdup(ctx->kvi->filename); + strbuf_addstr(&normalized, absolute); + if (strbuf_normalize_path(&normalized)) + goto fail; + if (ctx->kvi->scope == CONFIG_SCOPE_SYSTEM && + proof->system_path && strcmp(normalized.buf, proof->system_path)) + goto fail; + fd = open_nofollow(normalized.buf, O_RDONLY | O_CLOEXEC); + if (fd < 0) + goto fail; + ALLOC_GROW(proof->sources, proof->nr + 1, proof->alloc); + source = &proof->sources[proof->nr]; + memset(source, 0, sizeof(*source)); + source->fd = -1; + allocated = 1; + if (fstat(fd, &source->stat) || + !S_ISREG(source->stat.st_mode) || + source->stat.st_nlink != 1 || + (!is_path_owned_by_current_user(normalized.buf, NULL) && + !(source->stat.st_uid == 0 && + ctx->kvi->scope == CONFIG_SCOPE_SYSTEM)) || + !config_epoch_source_precedes_index(&source->stat, &proof->index) || + lstat(normalized.buf, &named) || + !path_namespace_stat_equal(&source->stat, &named) || + path_namespace_capture(normalized.buf, &source->namespace) || + !path_namespace_target_present(source->namespace) || + path_namespace_capture(normalized.buf, &after) || + !path_namespace_equal(source->namespace, after)) + goto fail; + source->path = xstrdup(ctx->kvi->filename); + source->fd = fd; + proof->nr++; + if (ctx->kvi->scope == CONFIG_SCOPE_SYSTEM && proof->system_path) + proof->system_seen = 1; + fd = -1; + path_namespace_clear(after); + strbuf_release(&normalized); + free(absolute); + return 0; + +fail: + if (fd >= 0) + close(fd); + if (allocated) + path_namespace_clear(proof->sources[proof->nr].namespace); + path_namespace_clear(after); + strbuf_release(&normalized); + free(absolute); + proof->failed = 1; + return 0; +} + +static int config_epoch_sources_still_match( + const struct config_epoch_proof *proof) +{ + for (size_t i = 0; i < proof->nr; i++) { + const struct config_epoch_source *source = &proof->sources[i]; + struct path_namespace_snapshot *namespace = NULL; + struct strbuf normalized = STRBUF_INIT; + struct stat held, named; + char *absolute = absolute_pathdup(source->path); + int valid; + + strbuf_addstr(&normalized, absolute); + valid = !strbuf_normalize_path(&normalized) && + !fstat(source->fd, &held) && + !lstat(normalized.buf, &named) && + path_namespace_stat_equal(&source->stat, &held) && + path_namespace_stat_equal(&held, &named) && + config_epoch_source_precedes_index(&held, &proof->index) && + !path_namespace_capture(normalized.buf, &namespace) && + path_namespace_equal(source->namespace, namespace); + path_namespace_clear(namespace); + strbuf_release(&normalized); + free(absolute); + if (!valid) + return 0; + } + return 1; +} +#endif + +int clean_status_config_tracked_sources_predate_index( + struct index_state *istate) +{ +#ifdef __APPLE__ + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + struct config_epoch_proof proof = { 0 }; + struct config_options opts = { 0 }; + const char *system_path = getenv("GIT_CONFIG_SYSTEM"); + int valid = 0; + + /* + * Version-one proofs did not record their tracked-stat policy. The + * shipped writer is trusted not to have used transient tracked-policy + * overrides; stable configuration sources older than its index then + * authenticate the one-time migration. Version-two proofs carry their + * complete policy instead and never use this compatibility exception. + */ + if (!istate || getenv("GIT_CONFIG_GLOBAL") || + getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || + getenv(INDEX_ENVIRONMENT) || + getenv(ALTERNATE_DB_ENVIRONMENT) || + clean_status_index_snapshot_pin(&snapshot, istate) || + fstat(snapshot.fd, &proof.index) || + proof.index.st_birthtimespec.tv_sec <= 0) + goto done; + if (system_path) { + struct strbuf normalized = STRBUF_INIT; + + if (!is_absolute_path(system_path)) + goto done; + strbuf_addstr(&normalized, system_path); + if (strbuf_normalize_path(&normalized)) { + strbuf_release(&normalized); + goto done; + } + proof.system_path = strbuf_detach(&normalized, NULL); + } + opts.respect_includes = 1; + opts.commondir = istate->repo->commondir; + opts.git_dir = istate->repo->gitdir; + if (config_with_options(config_epoch_capture_source, &proof, NULL, + istate->repo, &opts) < 0 || + proof.failed || !proof.nr || + (proof.system_path && !proof.system_seen) || + !config_epoch_sources_still_match(&proof) || + !clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, istate)) + goto done; + valid = 1; + +done: + free(proof.system_path); + for (size_t i = 0; i < proof.nr; i++) { + close(proof.sources[i].fd); + path_namespace_clear(proof.sources[i].namespace); + free(proof.sources[i].path); + } + free(proof.sources); + clean_status_index_snapshot_release(&snapshot); + return valid; +#else + (void)istate; + return 0; +#endif +} diff --git a/clean-status-config.h b/clean-status-config.h index 0a4275ea92242f..1f72325b9f0059 100644 --- a/clean-status-config.h +++ b/clean-status-config.h @@ -4,13 +4,16 @@ #include "hash.h" struct config_context; +struct index_state; struct repository; struct clean_status_config_digest { struct git_hash_ctx ctx; struct git_hash_ctx semantic_ctx; + struct git_hash_ctx tracked_policy_ctx; unsigned char hash[GIT_MAX_RAWSZ]; unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char tracked_policy_hash[GIT_MAX_RAWSZ]; unsigned initialized : 1; unsigned finalized : 1; unsigned filter_configured : 1; @@ -26,5 +29,7 @@ void clean_status_config_final(struct clean_status_config_digest *digest); int clean_status_config_read_repository( struct repository *repo, struct clean_status_config_digest *digest); +int clean_status_config_tracked_sources_predate_index( + struct index_state *istate); #endif /* CLEAN_STATUS_CONFIG_H */ diff --git a/clean-status-fast.c b/clean-status-fast.c index f41951079f2094..512f6b4b67aba0 100644 --- a/clean-status-fast.c +++ b/clean-status-fast.c @@ -10,7 +10,9 @@ #include "fsmonitor.h" #include "fsmonitor-settings.h" #include "object-name.h" +#include "path-namespace.h" #include "repository.h" +#include "semantic-verify-internal.h" #include "trace2.h" #include "worktree.h" #include "wrapper.h" @@ -81,6 +83,84 @@ static int attr_snapshot_still_matches( repo->hash_algo->rawsz); } +static int hardlink_witnesses_still_match( + struct repository *repo, const struct clean_status_sidecar *sidecar) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN && !defined(NO_NSEC) + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + const unsigned char *cursor, *end; + unsigned int namespace_unstable = 0; + int ret = 0; + + if (!sidecar->hardlink_nr) + return 1; + cursor = sidecar->hardlinks; + end = cursor + sidecar->hardlinks_len; + if (!repo->config_values_private_.trust_ctime || + !repo->config_values_private_.check_stat || + semantic_verify_root_init(repo, &root)) + goto done; + path = semantic_verify_path_new(root); + if (!path) + goto done; + for (uint32_t i = 0; i < sidecar->hardlink_nr; i++) { + struct path_stat_identity expected, observed; + const unsigned char *raw_path; + const char *basename; + struct stat held, named; + size_t path_len; + char *name; + int parent_fd, fd; + + if (clean_status_sidecar_next_hardlink( + &cursor, end, &raw_path, &path_len, &expected) || + !path_len || memchr(raw_path, '\0', path_len)) + goto done; + name = xmemdupz(raw_path, path_len); + if (semantic_verify_resolve_parent( + path, name, i, &parent_fd, &basename)) { + free(name); + goto done; + } + fd = semantic_verify_openat( + parent_fd, basename, + O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0) { + free(name); + goto done; + } + if (fstat(fd, &held) || !S_ISREG(held.st_mode) || + held.st_nlink <= 1 || held.st_dev != root->stat.st_dev || + fstatat(parent_fd, basename, &named, + AT_SYMLINK_NOFOLLOW) || + !path_namespace_stat_equal(&held, &named)) { + close(fd); + free(name); + goto done; + } + path_stat_identity_init(&observed, &held); + close(fd); + free(name); + if (!path_stat_identity_equal(&expected, &observed)) + goto done; + } + if (cursor != end || !semantic_verify_root_stable(root)) + goto done; + ret = 1; + +done: + semantic_verify_path_free(path, &namespace_unstable, NULL); + if (namespace_unstable || (root && !semantic_verify_root_stable(root))) + ret = 0; + semantic_verify_root_clear(root); + return ret; +#else + (void)repo; + return !sidecar->hardlink_nr; +#endif +} + static int fast_path_test_barrier(void) { const char *ready = @@ -187,6 +267,10 @@ int clean_status_try_sidecar( trace_miss(repo, "fast-head-changed"); goto done; } + if (!hardlink_witnesses_still_match(repo, &record.sidecar)) { + trace_miss(repo, "fast-hardlink-changed"); + goto done; + } query_token = xmemdupz( record.sidecar.token, record.sidecar.token_len); @@ -233,7 +317,15 @@ int clean_status_try_sidecar( trace_miss(repo, "fast-index-raced"); goto done; } + if (!hardlink_witnesses_still_match(repo, &record.sidecar)) { + trace_miss(repo, "fast-hardlink-raced"); + goto done; + } + if (record.sidecar.hardlink_nr) + trace2_data_intmax("status", repo, + "clean-proof/hardlink-validated", + record.sidecar.hardlink_nr); trace2_data_intmax("status", repo, "clean-proof/hit", 1); ret = 1; diff --git a/clean-status-history-store.c b/clean-status-history-store.c index 06e6a7219a92b9..ead169a017b6ed 100644 --- a/clean-status-history-store.c +++ b/clean-status-history-store.c @@ -1,6 +1,7 @@ #include "git-compat-util.h" #ifdef __APPLE__ +#include #include #endif @@ -66,6 +67,18 @@ static char *history_store_path(const char *index_path, return xstrfmt("%s.csh1.%s", index_path, hex); } +char *clean_status_history_store_witness_path( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo) +{ + unsigned char hash[GIT_MAX_RAWSZ]; + char hex[GIT_MAX_HEXSZ + 1]; + + proof_namespace_hash(proof_namespace, algo, hash); + hash_to_hex_algop_r(hex, hash, algo); + return xstrfmt("%s.cswi.%s", index_path, hex); +} + struct history_store_file { char *path; timestamp_t mtime; @@ -157,6 +170,21 @@ static int prune_history_store(const char *index_path, if (lstat(files[i].path, &st) || !S_ISREG(st.st_mode) || unlink(files[i].path)) goto done; + { + char *witness = xstrdup(files[i].path); + size_t pathlen = strlen(witness); + char *marker = pathlen >= algo->hexsz + 6 ? + witness + pathlen - algo->hexsz - 6 : NULL; + + if (marker && !memcmp(marker, ".csh1.", 6)) + memcpy(marker, ".cswi.", 6); + else + marker = NULL; + if (marker && !lstat(witness, &st) && + S_ISREG(st.st_mode)) + unlink(witness); + free(witness); + } remove_nr--; } ret = remove_nr ? -1 : 0; @@ -437,6 +465,49 @@ static int local_apfs_id(int fd MAYBE_UNUSED, #endif } +static void install_history_witness( + const char *index_path, const char *proof_namespace, + const struct clean_status_index_snapshot *snapshot, + const struct git_hash_algo *algo, int encoded_matches) +{ +#ifdef __APPLE__ + struct clean_status_filesystem_id fsid; + struct clean_status_index_snapshot existing = { .fd = -1 }; + char *witness = NULL, *temporary = NULL; + + if (!snapshot || snapshot->fd < 0 || + local_apfs_id(snapshot->fd, &fsid)) + return; + witness = clean_status_history_store_witness_path( + index_path, proof_namespace, algo); + if (encoded_matches && + !clean_status_index_snapshot_open(&existing, witness, algo) && + existing.version == snapshot->version && + existing.cache_nr == snapshot->cache_nr && + oideq(&existing.checksum, &snapshot->checksum)) { + clean_status_index_snapshot_release(&existing); + free(witness); + return; + } + clean_status_index_snapshot_release(&existing); + temporary = xstrfmt("%s.tmp.%"PRIuMAX, witness, + (uintmax_t)getpid()); + if (!fclonefileat(snapshot->fd, AT_FDCWD, temporary, 0) && + clean_status_index_snapshot_still_matches_path( + snapshot, index_path, algo)) + rename(temporary, witness); + unlink(temporary); + free(temporary); + free(witness); +#else + (void)index_path; + (void)proof_namespace; + (void)snapshot; + (void)algo; + (void)encoded_matches; +#endif +} + int clean_status_history_checkpoint_source_matches( const char *index_path, const struct clean_status_history_checkpoint *checkpoint, @@ -515,6 +586,9 @@ int clean_status_history_store_install( !clean_status_index_snapshot_still_matches_path( snapshot, index_path, algo)) goto done; + if (aliased.source_alias_valid) + install_history_witness(index_path, proof_namespace, + snapshot, algo, encoded_matches); if (encoded_matches) { ret = 0; goto done; diff --git a/clean-status-history-store.h b/clean-status-history-store.h index 82c7a267efb5bc..2e275c87a62afc 100644 --- a/clean-status-history-store.h +++ b/clean-status-history-store.h @@ -52,6 +52,9 @@ int clean_status_history_store_install( const struct clean_status_history_checkpoint *checkpoint, const struct clean_status_index_snapshot *snapshot, const struct git_hash_algo *algo); +char *clean_status_history_store_witness_path( + const char *index_path, const char *proof_namespace, + const struct git_hash_algo *algo); int clean_status_history_checkpoint_source_matches( const char *index_path, const struct clean_status_history_checkpoint *checkpoint, diff --git a/clean-status-history.c b/clean-status-history.c index d2f487d46296e9..495566d74dbc6e 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "abspath.h" +#include "attr-fingerprint.h" #include "clean-status.h" #include "clean-status-history-store.h" #include "clean-status-index.h" @@ -13,7 +14,9 @@ #include "hash-framing.h" #include "hex.h" #include "read-cache-ll.h" +#include "replace-object.h" #include "repository.h" +#include "semantic-verify-internal.h" #include "strbuf.h" #include "trace2.h" #include "ewah/ewok.h" @@ -26,6 +29,9 @@ static void invalidate_disk_history(struct clean_status_state *state) state->disk_config_invalid = 1; state->disk_config_valid = 0; state->disk_semantic_valid = 0; + state->disk_tracked_policy_valid = 0; + memset(state->disk_tracked_policy_hash, 0, + sizeof(state->disk_tracked_policy_hash)); state->disk_attr_valid = 0; FREE_AND_NULL(state->disk_config_token); strbuf_reset(&state->disk_config_raw); @@ -62,6 +68,16 @@ int clean_status_read_fsmonitor_config(struct index_state *istate, istate->repo->hash_algo->rawsz); memcpy(state->disk_attr_hash, proof.attr_hash, istate->repo->hash_algo->rawsz); + if (proof.tracked_policy_hash) { + memcpy(state->disk_tracked_policy_hash, + proof.tracked_policy_hash, + istate->repo->hash_algo->rawsz); + state->disk_tracked_policy_valid = 1; + } else { + state->disk_tracked_policy_valid = 0; + memset(state->disk_tracked_policy_hash, 0, + sizeof(state->disk_tracked_policy_hash)); + } strbuf_add(&state->disk_config_raw, data, size); state->disk_config_valid = 1; state->disk_semantic_valid = 1; @@ -73,7 +89,10 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) { struct clean_status_state *state = istate->clean_status; const struct git_hash_algo *algo = istate->repo->hash_algo; - int token_coherent, config_coherent, semantic_changed, attr_changed; + int token_coherent, config_coherent, tracked_policy_coherent; + int semantic_changed, attr_changed; + int legacy_empty_attributes = 0; + int manifest_reusable; int coherent; if (!state || !state->current_config_valid) @@ -82,7 +101,12 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) !state->disk_config_invalid && istate->fsmonitor_token_valid && istate->fsmonitor_last_update && state->disk_config_token && !strcmp(state->disk_config_token, istate->fsmonitor_last_update); + tracked_policy_coherent = !state->disk_tracked_policy_valid || + (state->current_tracked_policy_valid && + !memcmp(state->disk_tracked_policy_hash, + state->current_tracked_policy_hash, algo->rawsz)); config_coherent = state->disk_config_valid && + tracked_policy_coherent && !memcmp(state->disk_config_hash, state->current_config_hash, algo->rawsz); semantic_changed = state->disk_semantic_valid && @@ -93,6 +117,26 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) (state->disk_attr_valid && state->current_attr_valid && memcmp(state->disk_attr_hash, state->current_attr_hash, algo->rawsz)); + if (attr_changed && token_coherent && state->disk_semantic_valid && + state->current_semantic_valid && !semantic_changed && + state->disk_attr_valid && state->current_attr_valid && + !state->current_attr_sources_present && !state->filter_configured && + state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && + istate == istate->repo->index && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + !repo_has_replace_refs_uncached(istate->repo) && + attr_fingerprint_matches_legacy_absent_sources( + istate->repo, state->disk_attr_hash)) { + attr_changed = 0; + legacy_empty_attributes = 1; + } coherent = token_coherent && config_coherent && state->disk_semantic_valid && state->current_semantic_valid && !semantic_changed && state->disk_attr_valid && @@ -100,6 +144,13 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) state->manifest.disk_valid && (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == FSMONITOR_CLEAN_PROOF_ALL; + manifest_reusable = token_coherent && !config_coherent && + state->disk_semantic_valid && state->current_semantic_valid && + !semantic_changed && state->disk_attr_valid && + state->current_attr_valid && !attr_changed && + !state->filter_configured && state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL; state->filter_scope_valid = coherent && state->filter_configured; state->config_revalidated = coherent; state->initial_coherent = coherent; @@ -108,6 +159,11 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) state->config_revalidated_token = xstrdup(istate->fsmonitor_last_update); clean_status_manifest_adopt_disk(&state->manifest); + } else if (manifest_reusable) { + clean_status_manifest_adopt_disk(&state->manifest); + if (trace) + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-reused", 1); } state->config_mismatch = state->config_enforced && !coherent; state->strong_mismatch = state->config_enforced && @@ -120,6 +176,9 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) state->current_attr_sources_present) || clean_status_filter_scope_needs_validation(istate)); if (trace) { + if (legacy_empty_attributes) + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/legacy-empty-attributes", 1); trace2_data_intmax("fsmonitor", istate->repo, "config/coherent", coherent); trace2_data_intmax("fsmonitor", istate->repo, @@ -139,6 +198,60 @@ int clean_status_probe_fsmonitor_config(struct index_state *istate) return prepare_fsmonitor_config(istate, 0); } +int clean_status_try_preserve_tracked_config_epoch( + struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo; + int attr_matches; + + if (!state || !istate->repo || istate != istate->repo->index) + return 0; + algo = istate->repo->hash_algo; + attr_matches = state->disk_attr_valid && + state->current_attr_valid && + (!memcmp(state->disk_attr_hash, state->current_attr_hash, + algo->rawsz) || + attr_fingerprint_matches_legacy_absent_sources( + istate->repo, state->disk_attr_hash)); + if (!state->config_enforced || !state->config_mismatch || + state->strong_mismatch || !state->disk_config_valid || + state->disk_config_invalid || !state->disk_config_raw.len || + !state->current_config_valid || !state->disk_semantic_valid || + !state->current_semantic_valid || + memcmp(state->disk_semantic_hash, state->current_semantic_hash, + algo->rawsz) || !attr_matches || + (!state->disk_tracked_policy_valid && + state->current_attr_sources_present) || + state->filter_configured || + !state->manifest.disk_valid || !state->manifest.current_valid || + !state->manifest.checked || state->manifest.current_invalidated || + state->manifest.global_fallback || + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || !state->disk_config_token || + strcmp(state->disk_config_token, istate->fsmonitor_last_update) || + istate->split_index || istate->sparse_index != INDEX_EXPANDED || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + repo_has_replace_refs_uncached(istate->repo) || + !state->current_tracked_policy_valid || + (state->disk_tracked_policy_valid ? + memcmp(state->disk_tracked_policy_hash, + state->current_tracked_policy_hash, algo->rawsz) : + !clean_status_config_tracked_sources_predate_index(istate))) + return 0; + clean_status_mark_fsmonitor_config_valid( + istate, istate->fsmonitor_last_update); + if (!clean_status_revalidated_token_matches(istate)) + return 0; + trace2_data_intmax("fsmonitor", istate->repo, + "config/tracked-epoch-valid", 1); + return 1; +} + int clean_status_has_persistent_fsmonitor_semantic_history( const struct index_state *istate) { @@ -247,6 +360,8 @@ void clean_status_advance_fsmonitor_config_token( if (!next_token || !current_proof_is_writable(istate)) return; + if (strcmp(istate->fsmonitor_last_update, next_token)) + clean_status_clear_authenticated_new_directories(istate); FREE_AND_NULL(state->config_revalidated_token); state->config_revalidated_token = xstrdup(next_token); trace2_data_intmax("fsmonitor", istate->repo, @@ -277,6 +392,9 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, .config_hash = state->current_config_hash, .semantic_hash = state->current_semantic_hash, .attr_hash = state->current_attr_hash, + .tracked_policy_hash = + state->current_tracked_policy_valid ? + state->current_tracked_policy_hash : NULL, .attr_manifest = (const unsigned char *)state->manifest.current.buf, .attr_manifest_len = state->manifest.current.len, @@ -329,7 +447,8 @@ static int external_history_namespace(struct index_state *istate, char *out) istate->repo->hash_algo->rawsz); hash_length_delimited(&ctx, state->current_semantic_hash, istate->repo->hash_algo->rawsz); - hash_length_delimited(&ctx, state->current_attr_namespace_hash, + hash_length_delimited(&ctx, + state->current_attr_portable_namespace_hash, istate->repo->hash_algo->rawsz); hash_length_delimited(&ctx, worktree, strlen(worktree)); hash_length_delimited(&ctx, gitdir, strlen(gitdir)); @@ -572,6 +691,651 @@ static int external_token_is_replayable(const char *token) return replayable; } +static int missing_fsmonitor_token_is_replayable( + struct index_state *istate, struct index_state *parsed) +{ + struct fsmonitor_query_result result = + FSMONITOR_QUERY_RESULT_INIT; + const char *token = parsed->fsmonitor_last_update; + const char *path, *end; + int replayable = 0; + + if (!token || !starts_with(token, "builtin:") || + !strcmp(token, "builtin:fake") || + query_builtin_fsmonitor(token, &result) != + FSMONITOR_QUERY_DELTA) + goto done; + path = result.paths.buf; + end = result.paths.buf + result.paths.len; + while (path < end) { + size_t len = strlen(path); + const char *base = find_last_dir_sep(path); + + base = base ? base + 1 : path; + if (!strcmp(path, FSMONITOR_PATH_GLOBAL_INVALIDATE)) + goto done; + if (!fspathcmp(base, ".gitattributes")) { + struct index_state witness = *istate; + + witness.clean_status = parsed->clean_status; + witness.fsmonitor_last_update = + parsed->fsmonitor_last_update; + witness.fsmonitor_token_valid = + parsed->fsmonitor_token_valid; + if (!clean_status_manifest_reconcile_deleted_attribute( + &witness, path)) { + struct clean_status_state *state = + parsed->clean_status; + struct strbuf proof = STRBUF_INIT; + + if (!clean_status_manifest_reconcile_display_only_attribute( + &witness, path)) + goto done; + clean_status_write_fsmonitor_config( + &proof, parsed); + strbuf_reset(&state->disk_config_raw); + strbuf_addbuf(&state->disk_config_raw, &proof); + strbuf_reset(&state->manifest.disk); + strbuf_addbuf(&state->manifest.disk, + &state->manifest.current); + memcpy(state->manifest.disk_hash, + state->manifest.current_hash, + parsed->repo->hash_algo->rawsz); + state->manifest.disk_flags = + state->manifest.current_flags; + strbuf_release(&proof); + } + } + path += len + 1; + } + replayable = path == end; + +done: + fsmonitor_query_result_release(&result); + return replayable; +} + +static void invalidate_unwatched_recovered_entry(size_t pos, void *data) +{ + struct index_state *istate = data; + + if (pos >= istate->cache_nr) + BUG("recovered fsmonitor entry is outside the index"); + fsmonitor_invalidate_cache_entry(istate->cache[pos]); +} + +#ifdef __APPLE__ +static int external_semantic_delta_is_safe( + const struct strbuf *paths, struct index_state *old_index, + struct index_state *new_index) +{ + const char *path = paths->buf; + const char *end = paths->buf + paths->len; + + while (path < end) { + size_t len = strlen(path); + const char *base = find_last_dir_sep(path); + + base = base ? base + 1 : path; + if (!len || !fspathcmp(base, ".gitattributes") || + !fspathcmp(base, ".gitignore")) + return 0; + if (path[len - 1] == '/') { + int old_pos = index_name_pos(old_index, path, len); + int new_pos = index_name_pos(new_index, path, len); + const struct cache_entry *entry; + + old_pos = old_pos < 0 ? -old_pos - 1 : old_pos; + new_pos = new_pos < 0 ? -new_pos - 1 : new_pos; + if ((unsigned int)old_pos < old_index->cache_nr && + starts_with(old_index->cache[old_pos]->name, path)) + return 0; + if ((unsigned int)new_pos >= new_index->cache_nr || + !starts_with(new_index->cache[new_pos]->name, path)) { + path += len + 1; + continue; + } + entry = new_index->cache[new_pos]; + if (!clean_status_index_entry_is_semantically_safe( + old_index, NULL, entry)) + return 0; + } + path += len + 1; + } + return path == end; +} + +static void invalidate_external_checkpoint_entry(size_t pos, void *data) +{ + struct index_state *istate = data; + + if (pos < istate->cache_nr) + istate->cache[pos]->ce_flags &= ~CE_FSMONITOR_VALID; +} + +static int external_checkpoint_path_was_replayed( + const char *name, const struct strbuf *paths) +{ + const char *path = paths->buf; + const char *end = paths->buf + paths->len; + + while (path < end) { + size_t len = strlen(path); + + if (!fspathcmp(name, path) || + (path[len - 1] == '/' && !fspathncmp(name, path, len))) + return 1; + path += len + 1; + } + return 0; +} + +static void restore_external_tracked_history( + struct index_state *istate, struct index_state *witness, + const struct clean_status_history_checkpoint *checkpoint, + const struct strbuf *paths, const struct fsmonitor_clean_proof *proof) +{ + struct index_state parsed = INDEX_STATE_INIT(istate->repo); + const struct stat_data empty_stat = { 0 }; + unsigned int old_pos = 0, new_pos = 0, restored = 0, i; + const unsigned int unsafe_flags = CE_VALID | CE_SKIP_WORKTREE | + CE_INTENT_TO_ADD | CE_CONTENT_CHECK_REQUIRED | CE_STAGEMASK; + + if (!checkpoint->fsmonitor_len || !istate->fsmonitor_dirty) + return; + parsed.cache_nr = witness->cache_nr; + if (read_fsmonitor_extension(&parsed, checkpoint->fsmonitor, + checkpoint->fsmonitor_len) || + !parsed.fsmonitor_token_valid || !parsed.fsmonitor_dirty || + !parsed.fsmonitor_last_update || + strlen(parsed.fsmonitor_last_update) != proof->token_len || + memcmp(parsed.fsmonitor_last_update, proof->token, + proof->token_len)) + goto done; + for (i = 0; i < witness->cache_nr; i++) + if (!S_ISGITLINK(witness->cache[i]->ce_mode)) + witness->cache[i]->ce_flags |= CE_FSMONITOR_VALID; + ewah_each_bit(parsed.fsmonitor_dirty, + invalidate_external_checkpoint_entry, witness); + for (i = 0; i < istate->cache_nr; i++) + if (!S_ISGITLINK(istate->cache[i]->ce_mode)) + istate->cache[i]->ce_flags |= CE_FSMONITOR_VALID; + ewah_each_bit(istate->fsmonitor_dirty, + invalidate_external_checkpoint_entry, istate); + while (old_pos < witness->cache_nr && new_pos < istate->cache_nr) { + const struct cache_entry *old_entry = witness->cache[old_pos]; + struct cache_entry *new_entry = istate->cache[new_pos]; + int cmp = strcmp(old_entry->name, new_entry->name); + int recover_stat; + + if (cmp < 0) { + old_pos++; + continue; + } + if (cmp > 0) { + new_pos++; + continue; + } + old_pos++; + new_pos++; + if ((new_entry->ce_flags & CE_FSMONITOR_VALID) || + !(old_entry->ce_flags & CE_FSMONITOR_VALID) || + ((old_entry->ce_flags | new_entry->ce_flags) & unsafe_flags) || + (!S_ISREG(new_entry->ce_mode) && + !S_ISLNK(new_entry->ce_mode)) || + old_entry->ce_mode != new_entry->ce_mode || + !oideq(&old_entry->oid, &new_entry->oid) || + external_checkpoint_path_was_replayed( + new_entry->name, paths)) + continue; + recover_stat = !memcmp(&new_entry->ce_stat_data, &empty_stat, + sizeof(empty_stat)); + if (memcmp(&old_entry->ce_stat_data, &new_entry->ce_stat_data, + sizeof(old_entry->ce_stat_data)) && + (!recover_stat || + !memcmp(&old_entry->ce_stat_data, &empty_stat, + sizeof(empty_stat)) || + is_racy_timestamp(witness, old_entry))) + continue; + if (recover_stat) + new_entry->ce_stat_data = old_entry->ce_stat_data; + if (is_racy_timestamp(istate, new_entry)) { + if (recover_stat) + new_entry->ce_stat_data = empty_stat; + continue; + } + if (recover_stat) { + new_entry->ce_flags |= CE_UPDATE_IN_BASE; + istate->cache_changed |= CE_ENTRY_CHANGED; + istate->clean_status->recovered_tracked_stat = 1; + } + new_entry->ce_flags |= CE_FSMONITOR_VALID; + restored++; + } + if (restored) { + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; + fill_fsmonitor_bitmap(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-tracked-restored", restored); + } + +done: + if (parsed.fsmonitor_dirty) + ewah_free(parsed.fsmonitor_dirty); + parsed.fsmonitor_dirty = NULL; + parsed.cache_nr = 0; + release_index(&parsed); +} + +static int external_index_has_other_tracked_sibling( + struct index_state *istate, const char *name, size_t parent_len) +{ + const unsigned int unsafe_flags = + CE_STAGEMASK | CE_SKIP_WORKTREE | CE_INTENT_TO_ADD; + int pos = index_name_pos(istate, name, parent_len); + + if (pos < 0) + pos = -pos - 1; + for (; (unsigned int)pos < istate->cache_nr; pos++) { + const struct cache_entry *entry = istate->cache[pos]; + + if (ce_namelen(entry) <= parent_len || + memcmp(entry->name, name, parent_len)) + break; + if (!strcmp(entry->name, name)) + continue; + if ((entry->ce_flags & unsafe_flags) || + (!S_ISREG(entry->ce_mode) && !S_ISLNK(entry->ce_mode))) + continue; + return 1; + } + return 0; +} + +static int external_untracked_membership_needs_root_invalidation( + struct index_state *istate, struct index_state *witness, + const char *name) +{ + const char *slash = find_last_dir_sep(name); + size_t parent_len; + + if (!slash || istate->sparse_index || witness->sparse_index) + return 1; + parent_len = slash - name + 1; + return !external_index_has_other_tracked_sibling( + witness, name, parent_len) || + !external_index_has_other_tracked_sibling( + istate, name, parent_len); +} + +static void restore_external_untracked_history( + struct index_state *istate, struct index_state *witness, + const struct clean_status_history_checkpoint *checkpoint, + const struct strbuf *paths, const struct fsmonitor_clean_proof *proof) +{ + struct index_state parsed = INDEX_STATE_INIT(istate->repo); + const char *path = paths->buf; + const char *end = paths->buf + paths->len; + unsigned int old_pos = 0, new_pos = 0; + unsigned int targeted_membership = 0, rooted_membership = 0; + + if (istate->fsmonitor_untracked_valid || + !checkpoint->untracked_cache_len || + !checkpoint->fsmonitor_untracked_len) + return; + parsed.untracked = read_untracked_extension( + checkpoint->untracked_cache, + checkpoint->untracked_cache_len); + if (!parsed.untracked || + read_fsmonitor_untracked_extension( + &parsed, checkpoint->fsmonitor_untracked, + checkpoint->fsmonitor_untracked_len) || + parsed.fsmonitor_untracked_extension_invalid || + !parsed.fsmonitor_untracked_token || + strlen(parsed.fsmonitor_untracked_token) != proof->token_len || + memcmp(parsed.fsmonitor_untracked_token, + proof->token, proof->token_len)) + goto done; + free_untracked_cache(istate->untracked); + istate->untracked = parsed.untracked; + parsed.untracked = NULL; + istate->untracked->use_fsmonitor = 1; + FREE_AND_NULL(istate->fsmonitor_untracked_token); + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + istate->fsmonitor_untracked_extension_seen = 1; + istate->fsmonitor_untracked_extension_invalid = 0; + istate->fsmonitor_untracked_valid = 1; + while (old_pos < witness->cache_nr || new_pos < istate->cache_nr) { + const struct cache_entry *old_entry = + old_pos < witness->cache_nr ? + witness->cache[old_pos] : NULL; + const struct cache_entry *new_entry = + new_pos < istate->cache_nr ? + istate->cache[new_pos] : NULL; + int cmp = !old_entry ? 1 : !new_entry ? -1 : + strcmp(old_entry->name, new_entry->name); + + if (cmp < 0) { + int rooted = + external_untracked_membership_needs_root_invalidation( + istate, witness, old_entry->name); + + untracked_cache_invalidate_path( + istate, old_entry->name, rooted); + rooted ? rooted_membership++ : targeted_membership++; + old_pos++; + } else if (cmp > 0) { + int rooted = + external_untracked_membership_needs_root_invalidation( + istate, witness, new_entry->name); + + untracked_cache_invalidate_path( + istate, new_entry->name, rooted); + rooted ? rooted_membership++ : targeted_membership++; + new_pos++; + } else { + old_pos++; + new_pos++; + } + } + while (path < end) { + size_t len = strlen(path); + + untracked_cache_invalidate_trimmed_path(istate, path, 0); + path += len + 1; + } + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-untracked-restored", 1); + if (targeted_membership) + trace2_data_intmax("fsmonitor", istate->repo, + "history/untracked-membership-targeted", + targeted_membership); + if (rooted_membership) + trace2_data_intmax("fsmonitor", istate->repo, + "history/untracked-membership-rooted", + rooted_membership); + +done: + release_index(&parsed); +} +#endif + +static int restore_external_semantic_history( + struct index_state *istate, + const struct clean_status_history_checkpoint *checkpoint, + const char *proof_namespace, + const struct clean_status_index_snapshot *snapshot) +{ +#ifdef __APPLE__ + struct index_state witness = INDEX_STATE_INIT(istate->repo); + struct fsmonitor_query_result old = FSMONITOR_QUERY_RESULT_INIT; + struct fsmonitor_query_result current = FSMONITOR_QUERY_RESULT_INIT; + struct fsmonitor_clean_proof proof; + struct clean_status_identity before_identity, after_identity; + struct stat before, after; + unsigned char witness_hash[GIT_MAX_RAWSZ]; + char *path = NULL; + int fd = -1, transferred = 0; + + if (!checkpoint->source_alias_valid || + !has_usable_on_index_builtin_token(istate) || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) + goto done; + path = clean_status_history_store_witness_path( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo); + fd = open_nofollow(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || + before.st_nlink != 1 || before.st_uid != geteuid() || + clean_status_identity_from_stat(&before_identity, &before) || + lstat(path, &after) || before.st_dev != after.st_dev || + before.st_ino != after.st_ino) + goto done; + do_read_index(&witness, path, 1); + if (fstat(fd, &after) || after.st_nlink != 1 || + after.st_uid != geteuid() || + clean_status_identity_from_stat(&after_identity, &after) || + !clean_status_identity_equal(&before_identity, &after_identity) || + before.st_size != after.st_size || + lstat(path, &after) || before.st_dev != after.st_dev || + before.st_ino != after.st_ino || + witness.version != checkpoint->source_version || + witness.cache_nr != checkpoint->source_cache_nr || + !oideq(&witness.oid, &checkpoint->source_checksum) || + clean_status_index_logical_digest(&witness, witness_hash) || + memcmp(witness_hash, checkpoint->index_hash, + istate->repo->hash_algo->rawsz) || + fsmonitor_clean_proof_parse( + &proof, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len, + istate->repo->hash_algo)) + goto done; + clean_status_release(&witness); + clean_status_attach_config(&witness); + clean_status_read_fsmonitor_config( + &witness, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len); + free(witness.fsmonitor_last_update); + witness.fsmonitor_last_update = + xmemdupz(proof.token, proof.token_len); + witness.fsmonitor_token_valid = 1; + clean_status_prepare_fsmonitor_config(&witness); + if (!current_proof_is_writable(&witness) || + query_builtin_fsmonitor(witness.fsmonitor_last_update, &old) != + FSMONITOR_QUERY_DELTA || + query_builtin_fsmonitor(istate->fsmonitor_last_update, ¤t) != + FSMONITOR_QUERY_DELTA || + strcmp(old.token.buf, current.token.buf) || + !external_semantic_delta_is_safe(&old.paths, &witness, istate) || + !clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + if (strcmp(witness.fsmonitor_last_update, + istate->fsmonitor_last_update)) { + clean_status_advance_fsmonitor_config_token( + &witness, istate->fsmonitor_last_update); + free(witness.fsmonitor_last_update); + witness.fsmonitor_last_update = + xstrdup(istate->fsmonitor_last_update); + } + transferred = + clean_status_transfer_current_proof_if_semantically_same_index( + istate, &witness); + if (transferred) { + clean_status_set_authenticated_new_directories( + istate, &witness, &old.paths); + restore_external_tracked_history( + istate, &witness, checkpoint, &old.paths, &proof); + restore_external_untracked_history( + istate, &witness, checkpoint, &old.paths, &proof); + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-semantic-restored", 1); + } + +done: + if (fd >= 0) + close(fd); + free(path); + fsmonitor_query_result_release(&old); + fsmonitor_query_result_release(¤t); + release_index(&witness); + return transferred; +#else + (void)istate; + (void)checkpoint; + (void)proof_namespace; + (void)snapshot; + return 0; +#endif +} + +static int restore_external_bootstrap_manifest( + struct index_state *istate, + const struct clean_status_history_checkpoint *checkpoint, + const char *proof_namespace, + const struct clean_status_index_snapshot *snapshot) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + const unsigned int semantic_flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + const unsigned int transient_flags = + CE_UPDATE | CE_REMOVE | CE_ADDED | CE_WT_REMOVE | + CE_CONFLICTED | CE_UNPACKED | CE_NEW_SKIP_WORKTREE | + CE_MATCHED | CE_STRIP_NAME | CE_CONTENT_CHECK_REQUIRED; + struct clean_status_state *state = istate->clean_status; + struct index_state witness = INDEX_STATE_INIT(istate->repo); + struct clean_status_identity before_identity, after_identity; + struct fsmonitor_query_result changes = + FSMONITOR_QUERY_RESULT_INIT; + struct fsmonitor_clean_proof proof; + struct strbuf rewritten = STRBUF_INIT; + struct stat before, after; + unsigned char witness_hash[GIT_MAX_RAWSZ]; + char *path = NULL; + int fd = -1, attr_pos = -1, transferred = 0; + + if (!checkpoint->source_alias_valid || !state || + state->disk_config_invalid || state->filter_configured || + state->current_attr_sources_present || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + fsmonitor_clean_proof_parse(&proof, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len, + istate->repo->hash_algo) || + (proof.flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL) + goto done; + path = clean_status_history_store_witness_path( + istate->repo->index_file, proof_namespace, + istate->repo->hash_algo); + fd = open_nofollow(path, O_RDONLY | O_CLOEXEC); + if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || + before.st_nlink != 1 || before.st_uid != geteuid() || + clean_status_identity_from_stat(&before_identity, &before) || + lstat(path, &after) || before.st_dev != after.st_dev || + before.st_ino != after.st_ino) + goto done; + do_read_index(&witness, path, 1); + if (fstat(fd, &after) || after.st_nlink != 1 || + after.st_uid != geteuid() || + clean_status_identity_from_stat(&after_identity, &after) || + !clean_status_identity_equal(&before_identity, &after_identity) || + before.st_size != after.st_size || + lstat(path, &after) || before.st_dev != after.st_dev || + before.st_ino != after.st_ino || + witness.version != checkpoint->source_version || + witness.cache_nr != checkpoint->source_cache_nr || + witness.cache_nr != istate->cache_nr || + !oideq(&witness.oid, &checkpoint->source_checksum) || + clean_status_index_logical_digest(&witness, witness_hash) || + memcmp(witness_hash, checkpoint->index_hash, + istate->repo->hash_algo->rawsz)) + goto done; + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *old = witness.cache[i]; + const struct cache_entry *current = istate->cache[i]; + + if (ce_namelen(old) != ce_namelen(current) || + memcmp(old->name, current->name, ce_namelen(old) + 1) || + old->ce_mode != current->ce_mode || + ((old->ce_flags ^ current->ce_flags) & semantic_flags) || + ((old->ce_flags | current->ce_flags) & transient_flags)) + goto done; + if (oideq(&old->oid, ¤t->oid)) + continue; + if (attr_pos >= 0 || strcmp(current->name, ".gitattributes") || + !S_ISREG(current->ce_mode)) + goto done; + attr_pos = i; + } + if (attr_pos < 0 || + !clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + clean_status_release(&witness); + clean_status_attach_config(&witness); + clean_status_read_fsmonitor_config( + &witness, checkpoint->fsmonitor_config, + checkpoint->fsmonitor_config_len); + free(witness.fsmonitor_last_update); + witness.fsmonitor_last_update = + xmemdupz(proof.token, proof.token_len); + witness.fsmonitor_token_valid = 1; + clean_status_prepare_fsmonitor_config(&witness); + if (!current_proof_is_writable(&witness)) + goto done; + { + struct index_state current = *istate; + + current.clean_status = witness.clean_status; + current.fsmonitor_last_update = + witness.fsmonitor_last_update; + current.fsmonitor_token_valid = 1; + if (!clean_status_manifest_reconcile_display_only_attribute( + ¤t, ".gitattributes")) + goto done; + } + if (!clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + if (query_builtin_fsmonitor(witness.fsmonitor_last_update, + &changes) != FSMONITOR_QUERY_DELTA) + goto done; + for (const char *changed = changes.paths.buf, + *end = changes.paths.buf + changes.paths.len; + changed < end; changed += strlen(changed) + 1) { + size_t len = strlen(changed); + const char *base = find_last_dir_sep(changed); + + base = base ? base + 1 : changed; + if (!len || + !strcmp(changed, FSMONITOR_PATH_GLOBAL_INVALIDATE) || + changed[len - 1] == '/' || + (!fspathcmp(base, ".gitattributes") && + strcmp(changed, ".gitattributes"))) + goto done; + } + if (!clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + clean_status_write_fsmonitor_config(&rewritten, &witness); + clean_status_release(istate); + clean_status_attach_config(istate); + clean_status_read_fsmonitor_config( + istate, rewritten.buf, rewritten.len); + state = istate->clean_status; + state->config_mismatch = 0; + state->strong_mismatch = 0; + state->initial_coherent = 0; + state->config_revalidated = 0; + clean_status_manifest_adopt_disk(&state->manifest); + state->manifest.checked = 1; + state->manifest.global_fallback = 0; + state->manifest.current_invalidated = 0; + state->authenticated_bootstrap_manifest = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-bootstrap-manifest", 1); + transferred = 1; + +done: + if (fd >= 0) + close(fd); + free(path); + fsmonitor_query_result_release(&changes); + strbuf_release(&rewritten); + release_index(&witness); + return transferred; +#else + (void)istate; + (void)checkpoint; + (void)proof_namespace; + (void)snapshot; + return 0; +#endif +} + int clean_status_restore_external_history(struct index_state *istate) { struct clean_status_history_store_record record = @@ -582,9 +1346,13 @@ int clean_status_restore_external_history(struct index_state *istate) unsigned char index_hash[GIT_MAX_RAWSZ]; char proof_namespace[GIT_MAX_HEXSZ + 1]; int record_loaded = 0; + int missing_fsmonitor_recovery = 0; + int owned_index = 0; + int preserve_witness = 0; int restored = 0; if (!clean_status_external_history_enabled(istate) || !state || + state->disk_config_invalid || !state->config_enforced || !state->current_config_valid || !state->current_semantic_valid || !state->current_attr_valid || getenv(INDEX_ENVIRONMENT) || @@ -601,9 +1369,19 @@ int clean_status_restore_external_history(struct index_state *istate) !memcmp(state->disk_config_hash, state->current_config_hash, istate->repo->hash_algo->rawsz) && !clean_status_has_persistent_fsmonitor_semantic_history(istate)) { - trace2_data_intmax("fsmonitor", istate->repo, - "history/external-proof-invalidated", 1); - goto done; + missing_fsmonitor_recovery = + !istate->fsmonitor_extension_seen && + !istate->fsmonitor_token_valid && + !istate->fsmonitor_last_update && + fsm_settings__get_mode(istate->repo) == + FSMONITOR_MODE_IPC && + istate->repo->config_values_private_.trust_ctime && + istate->repo->config_values_private_.check_stat; + if (!missing_fsmonitor_recovery) { + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-proof-invalidated", 1); + goto done; + } } if (external_history_namespace(istate, proof_namespace)) goto done; @@ -629,10 +1407,27 @@ int clean_status_restore_external_history(struct index_state *istate) memcpy(state->source_logical_hash, index_hash, istate->repo->hash_algo->rawsz); state->source_logical_hash_valid = 1; - if (!record_loaded || - memcmp(index_hash, record.checkpoint.index_hash, - istate->repo->hash_algo->rawsz)) + if (!record_loaded) { + if (missing_fsmonitor_recovery) + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-proof-invalidated", 1); goto done; + } + if (memcmp(index_hash, record.checkpoint.index_hash, + istate->repo->hash_algo->rawsz)) { + if (missing_fsmonitor_recovery) { + if (restore_external_bootstrap_manifest( + istate, &record.checkpoint, proof_namespace, + &snapshot)) + goto done; + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-proof-invalidated", 1); + goto done; + } + restored = restore_external_semantic_history( + istate, &record.checkpoint, proof_namespace, &snapshot); + goto done; + } parsed.cache_nr = istate->cache_nr; if (read_fsmonitor_extension( &parsed, record.checkpoint.fsmonitor, @@ -663,6 +1458,13 @@ int clean_status_restore_external_history(struct index_state *istate) if (!current_proof_is_writable(&parsed) || (!!parsed.untracked && !parsed.fsmonitor_untracked_valid)) goto done; + if (missing_fsmonitor_recovery && + (!parsed.untracked || + !missing_fsmonitor_token_is_replayable(istate, &parsed))) { + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-proof-invalidated", 1); + goto done; + } /* * Provider tokens are opaque. A logical-index match says that the * checkpoint names the same staged entries; it does not say that its @@ -686,6 +1488,37 @@ int clean_status_restore_external_history(struct index_state *istate) if (!clean_status_index_snapshot_still_matches_proof_epoch( &snapshot, istate)) goto done; + owned_index = !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !istate->split_index && + !state->current_attr_sources_present && + istate->sparse_index == INDEX_EXPANDED && + !state->disk_config_invalid && + ((!state->disk_config_seen && !state->disk_config_valid && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + has_usable_on_index_builtin_token(istate) && + has_usable_on_index_builtin_token(&parsed) && + !strcmp(istate->fsmonitor_last_update, + parsed.fsmonitor_last_update)) || + (state->disk_config_seen && state->disk_config_valid && + state->disk_semantic_valid && state->disk_attr_valid && + state->manifest.disk_valid && + (((state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL) || + (missing_fsmonitor_recovery && + clean_status_has_worktree_manifest_history(istate))) && + !memcmp(state->disk_config_hash, + state->current_config_hash, + istate->repo->hash_algo->rawsz) && + !memcmp(state->disk_semantic_hash, + state->current_semantic_hash, + istate->repo->hash_algo->rawsz) && + !memcmp(state->disk_attr_hash, + state->current_attr_hash, + istate->repo->hash_algo->rawsz))); + preserve_witness = !state->disk_config_seen && + !istate->fsmonitor_untracked_valid && + has_usable_on_index_builtin_token(istate); clean_status_invalidate_current_proof(istate); clean_status_copy_fsmonitor_history(istate, &parsed); FREE_AND_NULL(istate->fsmonitor_last_update); @@ -697,6 +1530,9 @@ int clean_status_restore_external_history(struct index_state *istate) parsed.fsmonitor_last_update = NULL; istate->fsmonitor_dirty = parsed.fsmonitor_dirty; parsed.fsmonitor_dirty = NULL; + if (missing_fsmonitor_recovery) + ewah_each_bit(istate->fsmonitor_dirty, + invalidate_unwatched_recovered_entry, istate); istate->fsmonitor_token_valid = 1; istate->fsmonitor_extension_seen = 1; free_untracked_cache(istate->untracked); @@ -713,7 +1549,12 @@ int clean_status_restore_external_history(struct index_state *istate) parsed.fsmonitor_untracked_valid; trace2_data_intmax("fsmonitor", istate->repo, "history/external-restored", 1); + if (missing_fsmonitor_recovery) + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-fsmn-recovered", 1); state->external_history_restored = 1; + state->external_history_owned_index = owned_index; + state->external_history_preserve_witness = preserve_witness; restored = 1; done: @@ -735,6 +1576,56 @@ int clean_status_external_history_was_restored( return state && state->external_history_restored; } +int clean_status_external_history_needs_witness_preservation( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->external_history_restored && + state->external_history_preserve_witness && + !state->external_history_owned_index && + istate == istate->repo->index && + current_proof_is_writable(istate); +} + +int clean_status_has_recovered_tracked_stat( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->recovered_tracked_stat && + (istate->cache_changed & CE_ENTRY_CHANGED) && + istate == istate->repo->index && + current_proof_is_writable(istate); +} + +int clean_status_has_authenticated_bootstrap_manifest( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->authenticated_bootstrap_manifest && + state->manifest.current_valid && state->manifest.checked && + !state->manifest.current_invalidated && + (state->manifest.current_flags & + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) == + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); +} + +int clean_status_external_history_owns_index( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->external_history_restored && + state->external_history_owned_index && + istate == istate->repo->index && + !getenv(INDEX_ENVIRONMENT) && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED; +} + void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src) @@ -756,6 +1647,9 @@ void clean_status_copy_fsmonitor_history(struct index_state *dst, dst->repo->hash_algo->rawsz); memcpy(dst_state->disk_semantic_hash, src_state->disk_semantic_hash, dst->repo->hash_algo->rawsz); + memcpy(dst_state->disk_tracked_policy_hash, + src_state->disk_tracked_policy_hash, + dst->repo->hash_algo->rawsz); memcpy(dst_state->disk_attr_hash, src_state->disk_attr_hash, dst->repo->hash_algo->rawsz); if (clean_status_manifest_load( @@ -766,6 +1660,8 @@ void clean_status_copy_fsmonitor_history(struct index_state *dst, dst_state->disk_config_seen = 1; dst_state->disk_config_valid = 1; dst_state->disk_semantic_valid = src_state->disk_semantic_valid; + dst_state->disk_tracked_policy_valid = + src_state->disk_tracked_policy_valid; dst_state->disk_attr_valid = src_state->disk_attr_valid; dst_state->disk_config_invalid = 0; } @@ -830,3 +1726,94 @@ int clean_status_transfer_current_proof_if_same_index( return transferred; } + +int clean_status_transfer_current_proof_if_semantically_same_index( + struct index_state *dst, const struct index_state *src) +{ + const unsigned int semantic_flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + struct strbuf proof = STRBUF_INIT; + unsigned int src_pos = 0, dst_pos = 0; + int transferred; + + if (!current_proof_is_writable(src) || + src->repo != dst->repo || src->split_index || dst->split_index || + src->sparse_index || dst->sparse_index || + (src->cache_changed & RESOLVE_UNDO_CHANGED) || + src->resolve_undo || + !src->fsmonitor_last_update || !dst->fsmonitor_last_update || + strcmp(src->fsmonitor_last_update, dst->fsmonitor_last_update)) + return 0; + + while (src_pos < src->cache_nr || dst_pos < dst->cache_nr) { + const struct cache_entry *old = src_pos < src->cache_nr ? + src->cache[src_pos] : NULL; + const struct cache_entry *new_entry = dst_pos < dst->cache_nr ? + dst->cache[dst_pos] : NULL; + int cmp; + + if (!old) + cmp = 1; + else if (!new_entry) + cmp = -1; + else + cmp = strcmp(old->name, new_entry->name); + if (cmp < 0) { + if (!clean_status_index_entry_is_semantically_safe( + src, old, NULL)) + return 0; + src_pos++; + } else if (cmp > 0) { + if (!clean_status_index_entry_is_semantically_safe( + src, NULL, new_entry)) + return 0; + dst_pos++; + } else { + if ((old->ce_mode != new_entry->ce_mode || + !oideq(&old->oid, &new_entry->oid) || + ((old->ce_flags ^ new_entry->ce_flags) & semantic_flags)) && + !clean_status_index_entry_is_semantically_safe( + src, old, new_entry)) + return 0; + src_pos++; + dst_pos++; + } + } + + if (current_proof_is_writable(dst)) { + const struct clean_status_state *src_state = src->clean_status; + const struct clean_status_state *dst_state = dst->clean_status; + size_t rawsz = dst->repo->hash_algo->rawsz; + + if (memcmp(src_state->current_config_hash, + dst_state->current_config_hash, rawsz) || + memcmp(src_state->current_semantic_hash, + dst_state->current_semantic_hash, rawsz) || + memcmp(src_state->current_attr_hash, + dst_state->current_attr_hash, rawsz) || + src_state->manifest.current_flags != + dst_state->manifest.current_flags || + src_state->manifest.current.len != + dst_state->manifest.current.len || + memcmp(src_state->manifest.current.buf, + dst_state->manifest.current.buf, + src_state->manifest.current.len)) + return 0; + trace2_data_intmax("fsmonitor", dst->repo, + "history/semantic-transferred", 1); + return 1; + } + + clean_status_write_fsmonitor_config(&proof, src); + dst->fsmonitor_token_valid = src->fsmonitor_token_valid; + clean_status_read_fsmonitor_config(dst, proof.buf, proof.len); + clean_status_attach_config(dst); + clean_status_prepare_fsmonitor_config(dst); + transferred = current_proof_is_writable(dst); + if (transferred) + trace2_data_intmax("fsmonitor", dst->repo, + "history/semantic-transferred", 1); + strbuf_release(&proof); + + return transferred; +} diff --git a/clean-status-index.c b/clean-status-index.c index 4ea3c4bfc9d746..34dc23d6c0abfb 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -2,6 +2,7 @@ #include "clean-status.h" #include "clean-status-index.h" #include "clean-status-internal.h" +#include "clean-status-sidecar.h" #include "hash-framing.h" #include "object.h" #include "read-cache-ll.h" @@ -253,6 +254,44 @@ int clean_status_index_is_certifiable(const struct index_state *istate) clean_status_index_entries_are_certifiable(istate); } +int clean_status_index_is_certifiable_with_hardlinks( + const struct index_state *istate, uint32_t *hardlink_nr) +{ + const struct clean_status_state *state = istate->clean_status; + uint32_t nr = 0; + int checksum_is_bound = + !is_null_oid(&istate->oid) || + (clean_status_identity_is_durable() && state && + state->source_identity_valid); + + if (!hardlink_nr || !checksum_is_bound) + return 0; + *hardlink_nr = 0; + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + if (S_ISGITLINK(ce->ce_mode) || ce_stage(ce) || + ce_intent_to_add(ce) || ce_skip_worktree(ce) || + (ce->ce_flags & CE_VALID) || + (ce->ce_flags & ~(CE_UPTODATE | CE_HASHED | + CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE))) + return 0; + if (ce->ce_flags & CE_FSMONITOR_VALID) + continue; + if (!S_ISREG(ce->ce_mode) || + !(ce->ce_flags & CE_UPTODATE) || + nr == CLEAN_STATUS_HARDLINK_WITNESS_MAX) + return 0; + nr++; + } + if (nr && (!istate->repo->config_values_private_.trust_ctime || + !istate->repo->config_values_private_.check_stat)) + return 0; + *hardlink_nr = nr; + return 1; +} + static int index_entry_logical_state_is_supported( const struct cache_entry *ce, unsigned int extra_benign_flags) { diff --git a/clean-status-index.h b/clean-status-index.h index fc473d6c6f1330..1728b6021ddb3c 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -44,6 +44,8 @@ void clean_status_index_snapshot_release( int clean_status_index_entries_are_certifiable( const struct index_state *istate); int clean_status_index_is_certifiable(const struct index_state *istate); +int clean_status_index_is_certifiable_with_hardlinks( + const struct index_state *istate, uint32_t *hardlink_nr); int clean_status_index_logical_digest(const struct index_state *istate, unsigned char *out); int clean_status_index_logical_digest_after_status( diff --git a/clean-status-internal.h b/clean-status-internal.h index f37fdcad4ca79e..1bc3613920c9f1 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -11,19 +11,25 @@ struct clean_status_state { struct clean_status_identity source_index_identity; struct clean_status_manifest_state manifest; struct strbuf disk_config_raw; + struct strbuf authenticated_new_directories; char *disk_config_token; char *config_revalidated_token; + char *authenticated_new_directories_token; int source_index_fd; unsigned char current_config_hash[GIT_MAX_RAWSZ]; unsigned char disk_config_hash[GIT_MAX_RAWSZ]; unsigned char current_semantic_hash[GIT_MAX_RAWSZ]; unsigned char disk_semantic_hash[GIT_MAX_RAWSZ]; + unsigned char current_tracked_policy_hash[GIT_MAX_RAWSZ]; + unsigned char disk_tracked_policy_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_hash[GIT_MAX_RAWSZ]; unsigned char current_attr_namespace_hash[GIT_MAX_RAWSZ]; + unsigned char current_attr_portable_namespace_hash[GIT_MAX_RAWSZ]; unsigned char disk_attr_hash[GIT_MAX_RAWSZ]; unsigned char source_logical_hash[GIT_MAX_RAWSZ]; unsigned current_config_valid : 1; unsigned current_semantic_valid : 1; + unsigned current_tracked_policy_valid : 1; unsigned current_attr_valid : 1; unsigned current_semantic_explicit : 1; unsigned current_attr_sources_present : 1; @@ -38,8 +44,13 @@ struct clean_status_state { unsigned source_index_identity_valid : 1; unsigned source_logical_hash_valid : 1; unsigned external_history_restored : 1; + unsigned external_history_owned_index : 1; + unsigned external_history_preserve_witness : 1; + unsigned recovered_tracked_stat : 1; + unsigned authenticated_bootstrap_manifest : 1; unsigned disk_config_valid : 1; unsigned disk_semantic_valid : 1; + unsigned disk_tracked_policy_valid : 1; unsigned disk_attr_valid : 1; unsigned disk_config_seen : 1; unsigned disk_config_invalid : 1; diff --git a/clean-status-manifest.c b/clean-status-manifest.c index 2750ddeb7280a9..4259385110f016 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -1,19 +1,40 @@ #include "git-compat-util.h" +#include "attr-fingerprint.h" +#include "attr.h" #include "attr-manifest.h" +#include "bloom.h" +#include "clean-status-config.h" #include "clean-status-index.h" +#include "clean-status-internal.h" #include "clean-status-manifest.h" +#include "commit.h" +#include "commit-graph.h" #include "dir.h" +#include "environment.h" +#include "fsmonitor.h" #include "fsmonitor-clean-proof.h" #include "fsmonitor-ll.h" #include "hash-framing.h" +#include "object.h" +#include "object-name.h" +#include "odb.h" +#include "path-namespace.h" #include "read-cache-ll.h" +#include "replace-object.h" #include "repository.h" +#include "semantic-verify-internal.h" #include "sparse-index.h" #include "trace2.h" +#include "tree.h" +#include "tree-walk.h" #include "worktree-attr-manifest.h" +#include "worktree-attr-source.h" +#include "wrapper.h" struct invalidate_manifest_data { struct index_state *istate; + const struct strbuf *baseline; + const struct strbuf *current; int invalidated; }; @@ -96,15 +117,653 @@ void clean_status_manifest_adopt_disk( state->current_invalidated = 0; } +static int find_manifest_entry( + const struct strbuf *manifest, const char *path, + const struct git_hash_algo *algo, struct attr_manifest_entry *found) +{ + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + size_t path_len = strlen(path); + int ret; + + if (attr_manifest_cursor_init(&cursor, manifest->buf, + manifest->len, algo)) + return -1; + while ((ret = attr_manifest_cursor_next(&cursor, &entry)) > 0) { + if (entry.path_len == path_len && + !memcmp(entry.path, path, path_len)) { + *found = entry; + return 0; + } + } + return -1; +} + +int clean_status_manifest_reconcile_deleted_attribute( + struct index_state *istate, const char *name) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + struct attr_fingerprint attrs; + struct attr_manifest_cursor cursor; + struct attr_manifest_writer writer; + struct attr_manifest_entry old, entry; + struct strbuf next = STRBUF_INIT; + const struct cache_entry *ce; + const char *base, *basename; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned char indexed_hash[GIT_MAX_RAWSZ]; + unsigned char worktree_hash[GIT_MAX_RAWSZ]; + unsigned char observed_hash[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable = 0; + enum object_type type; + struct stat st; + void *content = NULL; + size_t size; + int pos, parent_fd, found = 0, next_entry, indexed, safe = 0; + int worktree_found, observed_found, changed; + + if (!name) + goto done; + base = find_last_dir_sep(name); + base = base ? base + 1 : name; + if (fspathcmp(base, GITATTRIBUTES_FILE) || + !state || !state->config_revalidated || + !state->current_attr_valid || state->filter_configured || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.current_invalidated || + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + !state->config_revalidated_token || + strcmp(state->config_revalidated_token, + istate->fsmonitor_last_update)) + goto done; + if (repo_has_replace_refs_uncached(istate->repo) || + find_manifest_entry(&state->manifest.current, + name, algo, &old) || + (old.source != ATTR_MANIFEST_WORKTREE && + old.source != ATTR_MANIFEST_INDEX) || + attr_fingerprint_repository(istate->repo, &attrs) || + attrs.sources_present != state->current_attr_sources_present || + memcmp(attrs.content_hash, state->current_attr_hash, + algo->rawsz) || + memcmp(attrs.namespace_hash, + state->current_attr_namespace_hash, algo->rawsz)) + goto done; + pos = index_name_pos(istate, name, strlen(name)); + if (pos < 0) + goto done; + ce = istate->cache[pos]; + if (!S_ISREG(ce->ce_mode) || ce_stage(ce) || + ce_skip_worktree(ce) || ce_intent_to_add(ce) || + (ce->ce_flags & CE_VALID)) + goto done; + indexed = old.source == ATTR_MANIFEST_INDEX; + if (indexed && memcmp(old.hash, ce->oid.hash, algo->rawsz)) + goto done; + content = odb_read_object(istate->repo->objects, + &ce->oid, &type, &size); + if (!content || type != OBJ_BLOB || size >= ATTR_MAX_FILE_SIZE) + goto done; + hash_buffer_digest(algo, content, size, indexed_hash); + if (!indexed && memcmp(old.hash, indexed_hash, algo->rawsz)) + goto done; + if (semantic_verify_root_init(istate->repo, &root)) + goto done; + path = semantic_verify_path_new(root); + if (!path || worktree_attr_source_read( + path, name, pos, algo, worktree_hash, &worktree_found) || + semantic_verify_resolve_parent( + path, name, pos, &parent_fd, &basename) || + (!worktree_found && + (!fstatat(parent_fd, basename, &st, AT_SYMLINK_NOFOLLOW) || + errno != ENOENT)) || + (worktree_found && + memcmp(worktree_hash, indexed_hash, algo->rawsz)) || + !semantic_verify_root_stable(root) || + !attr_manifest_valid(state->manifest.current.buf, + state->manifest.current.len, algo) || + attr_manifest_cursor_init(&cursor, + state->manifest.current.buf, + state->manifest.current.len, algo)) + goto done; + attr_manifest_writer_init(&writer, &next, algo); + while ((next_entry = attr_manifest_cursor_next(&cursor, &entry)) > 0) { + char *entry_name = xmemdupz(entry.path, entry.path_len); + int matches = !strcmp(entry_name, name); + int invalid = attr_manifest_writer_add( + &writer, entry_name, + matches ? + (worktree_found ? ATTR_MANIFEST_WORKTREE : + ATTR_MANIFEST_INDEX) : + entry.source, + matches ? + (worktree_found ? worktree_hash : ce->oid.hash) : + entry.hash); + + free(entry_name); + if (invalid) + goto done; + found += matches; + } + if (next_entry < 0 || found != 1 || + worktree_attr_source_read( + path, name, pos, algo, observed_hash, &observed_found) || + observed_found != worktree_found || + (!observed_found && + (!fstatat(parent_fd, basename, &st, AT_SYMLINK_NOFOLLOW) || + errno != ENOENT)) || + (observed_found && + memcmp(observed_hash, worktree_hash, algo->rawsz))) + goto done; + semantic_verify_path_free(path, &namespace_unstable, NULL); + path = NULL; + if (namespace_unstable || !semantic_verify_root_stable(root)) + goto done; + changed = indexed == worktree_found; + if (changed) { + hash_buffer_digest(algo, next.buf, next.len, hash); + strbuf_swap(&state->manifest.current, &next); + memcpy(state->manifest.current_hash, hash, algo->rawsz); + state->manifest.changed = 1; + state->manifest.global_fallback = 0; + } + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-reconciled", 1); + safe = 1; + +done: + if (path) + semantic_verify_path_free(path, NULL, NULL); + semantic_verify_root_clear(root); + strbuf_release(&next); + free(content); + return safe; +#else + (void)istate; + (void)name; + return 0; +#endif +} + +static int read_root_worktree_attributes( + struct repository *repo, struct strbuf *out) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + struct semantic_verify_root *root = NULL; + struct stat before, after, named; + size_t size; + int fd = -1, ret = -1; + char extra; + + if (semantic_verify_root_init(repo, &root)) + goto done; + fd = semantic_verify_openat(root->fd, GITATTRIBUTES_FILE, + O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || + before.st_nlink != 1 || before.st_dev != root->stat.st_dev || + before.st_size < 0 || before.st_size >= ATTR_MAX_FILE_SIZE) + goto done; + size = xsize_t(before.st_size); + strbuf_grow(out, size); + strbuf_setlen(out, size); + if ((size_t)read_in_full(fd, out->buf, size) != size || + read(fd, &extra, 1) != 0 || fstat(fd, &after) || + fstatat(root->fd, GITATTRIBUTES_FILE, &named, + AT_SYMLINK_NOFOLLOW) || + !path_namespace_stat_equal(&before, &after) || + !path_namespace_stat_equal(&after, &named) || + !semantic_verify_root_stable(root)) + goto done; + ret = 0; + +done: + if (fd >= 0) + close(fd); + semantic_verify_root_clear(root); + if (ret) + strbuf_reset(out); + return ret; +#else + (void)repo; + (void)out; + return -1; +#endif +} + +static int find_previous_root_attributes( + struct repository *repo, struct object_id *oid) +{ + struct bloom_filter_settings *settings; + struct bloom_key key = { 0 }; + struct object_id head_oid; + struct commit *commit; + unsigned int visited = 0, bloom_hits = 0, tree_inspections = 0, limit; + int found = 0; + + if (repo_get_oid(repo, "HEAD", &head_oid) || + !(commit = lookup_commit_reference_gently( + repo, &head_oid, 1))) + return -1; + settings = get_bloom_filter_settings(repo); + limit = settings ? 8192 : 128; + if (settings) + bloom_key_fill(&key, GITATTRIBUTES_FILE, + strlen(GITATTRIBUTES_FILE), settings); + while (commit && visited < limit) { + struct bloom_filter *filter = NULL; + struct commit *parent; + struct tree *current_tree, *parent_tree; + struct object_id current_oid, parent_oid; + unsigned short current_mode, parent_mode; + + visited++; + if (repo_parse_commit_gently(repo, commit, 1) || + !commit->parents) + break; + parent = commit->parents->item; + if (settings) + filter = get_bloom_filter(repo, commit); + if (filter && filter->version >= 0 && + (uint32_t)filter->version == settings->hash_version && + bloom_filter_contains(filter, &key, settings) == 0) { + bloom_hits++; + commit = parent; + continue; + } + if (tree_inspections >= 512) + break; + tree_inspections++; + if (repo_parse_commit_gently(repo, parent, 1) || + !(current_tree = repo_get_commit_tree(repo, commit)) || + !(parent_tree = repo_get_commit_tree(repo, parent)) || + get_tree_entry(repo, ¤t_tree->object.oid, + GITATTRIBUTES_FILE, + ¤t_oid, ¤t_mode) || + get_tree_entry(repo, &parent_tree->object.oid, + GITATTRIBUTES_FILE, + &parent_oid, &parent_mode) || + !S_ISREG(current_mode) || !S_ISREG(parent_mode)) + break; + if (!oideq(¤t_oid, &parent_oid)) { + oidcpy(oid, &parent_oid); + found = 1; + break; + } + commit = parent; + } + if (settings) + bloom_key_clear(&key); + trace2_data_intmax("fsmonitor", repo, + "semantic/attribute-history-commits", visited); + trace2_data_intmax("fsmonitor", repo, + "semantic/attribute-history-bloom-skips", bloom_hits); + trace2_data_intmax("fsmonitor", repo, + "semantic/attribute-history-tree-inspections", + tree_inspections); + return found ? 0 : -1; +} + +static void *read_authenticated_attribute_blob( + struct index_state *istate, + const struct attr_manifest_entry *old, + const struct object_id *oid, size_t *size) +{ + const struct git_hash_algo *algo = istate->repo->hash_algo; + unsigned char hash[GIT_MAX_RAWSZ]; + enum object_type type; + void *content; + + if (old->source == ATTR_MANIFEST_INDEX && + memcmp(old->hash, oid->hash, algo->rawsz)) + return NULL; + content = odb_read_object(istate->repo->objects, + oid, &type, size); + if (!content || type != OBJ_BLOB || + *size >= ATTR_MAX_FILE_SIZE) { + free(content); + return NULL; + } + if (old->source == ATTR_MANIFEST_WORKTREE) { + hash_buffer_digest(algo, content, *size, hash); + if (memcmp(old->hash, hash, algo->rawsz)) { + free(content); + return NULL; + } + } else if (old->source != ATTR_MANIFEST_INDEX) { + free(content); + return NULL; + } + return content; +} + +static void *read_authenticated_old_attributes( + struct index_state *istate, + const struct attr_manifest_entry *old, + const struct cache_entry *current, size_t *size) +{ + struct object_id parent_oid, historical_oid; + const struct object_id *candidates[2]; + void *content; + size_t nr = 1; + + candidates[0] = ¤t->oid; + if (!repo_get_oid_blob(istate->repo, + "HEAD^:" GITATTRIBUTES_FILE, &parent_oid) && + !oideq(¤t->oid, &parent_oid)) + candidates[nr++] = &parent_oid; + for (size_t i = 0; i < nr; i++) { + content = read_authenticated_attribute_blob( + istate, old, candidates[i], size); + if (content) + return content; + } + if (find_previous_root_attributes(istate->repo, &historical_oid)) + return NULL; + return read_authenticated_attribute_blob( + istate, old, &historical_oid, size); +} + +int clean_status_manifest_reconcile_display_only_attribute( + struct index_state *istate, const char *path) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + struct clean_status_config_digest config; + struct attr_fingerprint attrs; + struct attr_manifest_cursor cursor; + struct attr_manifest_writer writer; + struct attr_manifest_entry old, entry; + struct strbuf worktree = STRBUF_INIT; + struct strbuf observed = STRBUF_INIT; + struct strbuf next = STRBUF_INIT; + const struct cache_entry *ce; + unsigned char worktree_hash[GIT_MAX_RAWSZ]; + unsigned char indexed_hash[GIT_MAX_RAWSZ]; + unsigned char manifest_hash[GIT_MAX_RAWSZ]; + enum object_type type; + void *previous = NULL, *indexed = NULL; + size_t previous_len, indexed_len; + int pos, found = 0, next_entry, safe = 0; + + if (!path || strcmp(path, GITATTRIBUTES_FILE) || !state || + !state->config_enforced || !state->config_revalidated || + !state->current_config_valid || !state->current_semantic_valid || + !state->current_attr_valid || state->current_attr_sources_present || + state->filter_configured || !state->disk_config_valid || + state->disk_config_invalid || !state->disk_config_raw.len || + !state->manifest.disk_valid || !state->manifest.current_valid || + !state->manifest.checked || state->manifest.current_invalidated || + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + !state->config_revalidated_token || + strcmp(state->config_revalidated_token, + istate->fsmonitor_last_update) || + repo_has_replace_refs_uncached(istate->repo) || + find_manifest_entry(&state->manifest.current, + path, algo, &old) || + old.source != ATTR_MANIFEST_WORKTREE || + attr_fingerprint_repository(istate->repo, &attrs) || + attrs.sources_present || + memcmp(attrs.content_hash, state->current_attr_hash, + algo->rawsz) || + memcmp(attrs.namespace_hash, + state->current_attr_namespace_hash, algo->rawsz) || + clean_status_config_read_repository(istate->repo, &config) || + !config.finalized || config.filter_configured || + memcmp(config.hash, state->current_config_hash, algo->rawsz) || + memcmp(config.semantic_hash, + state->current_semantic_hash, algo->rawsz)) + goto done; + pos = index_name_pos(istate, path, strlen(path)); + if (pos < 0) + goto done; + ce = istate->cache[pos]; + if (!S_ISREG(ce->ce_mode) || ce_stage(ce) || + ce_skip_worktree(ce) || ce_intent_to_add(ce) || + (ce->ce_flags & CE_VALID)) + goto done; + previous = read_authenticated_old_attributes( + istate, &old, ce, &previous_len); + if (!previous || + read_root_worktree_attributes(istate->repo, &worktree)) + goto done; + indexed = odb_read_object(istate->repo->objects, + &ce->oid, &type, &indexed_len); + if (!indexed || type != OBJ_BLOB || + indexed_len >= ATTR_MAX_FILE_SIZE) + goto done; + hash_buffer_digest(algo, indexed, indexed_len, indexed_hash); + hash_buffer_digest(algo, worktree.buf, worktree.len, worktree_hash); + if ((memcmp(indexed_hash, worktree_hash, algo->rawsz) && + (indexed_len != previous_len || + memcmp(indexed, previous, indexed_len))) || + !attr_manifest_only_linguist_generated_changed( + previous, previous_len, worktree.buf, worktree.len) || + !attr_manifest_valid(state->manifest.current.buf, + state->manifest.current.len, algo) || + attr_manifest_cursor_init(&cursor, + state->manifest.current.buf, + state->manifest.current.len, algo)) + goto done; + attr_manifest_writer_init(&writer, &next, algo); + while ((next_entry = attr_manifest_cursor_next(&cursor, &entry)) > 0) { + char *entry_path = xmemdupz(entry.path, entry.path_len); + int matches = !strcmp(entry_path, path); + int invalid = attr_manifest_writer_add( + &writer, entry_path, + matches ? ATTR_MANIFEST_WORKTREE : entry.source, + matches ? worktree_hash : entry.hash); + + free(entry_path); + if (invalid) + goto done; + found += matches; + } + if (next_entry < 0 || found != 1 || + read_root_worktree_attributes(istate->repo, &observed) || + observed.len != worktree.len || + memcmp(observed.buf, worktree.buf, worktree.len)) + goto done; + hash_buffer_digest(algo, next.buf, next.len, manifest_hash); + strbuf_swap(&state->manifest.current, &next); + memcpy(state->manifest.current_hash, + manifest_hash, algo->rawsz); + state->manifest.changed = 1; + state->manifest.global_fallback = 0; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/nonconversion-attributes", 1); + safe = 1; + +done: + free(previous); + free(indexed); + strbuf_release(&worktree); + strbuf_release(&observed); + strbuf_release(&next); + return safe; +#else + (void)istate; + (void)path; + return 0; +#endif +} + +int clean_status_manifest_accept_current_display_only_attribute( + struct index_state *istate, const char *path) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + struct clean_status_config_digest config; + struct attr_fingerprint attrs; + struct attr_manifest_entry current; + struct strbuf worktree = STRBUF_INIT; + struct strbuf observed = STRBUF_INIT; + const struct cache_entry *ce; + unsigned char worktree_hash[GIT_MAX_RAWSZ]; + enum object_type type; + void *indexed = NULL; + size_t indexed_len; + int pos, safe = 0; + + if (!path || strcmp(path, GITATTRIBUTES_FILE) || !state || + !state->config_enforced || !state->config_revalidated || + !state->current_config_valid || !state->current_semantic_valid || + !state->current_attr_valid || state->current_attr_sources_present || + state->filter_configured || !state->disk_config_valid || + state->disk_config_invalid || !state->disk_config_raw.len || + !state->manifest.disk_valid || !state->manifest.current_valid || + !state->manifest.checked || state->manifest.current_invalidated || + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + !state->config_revalidated_token || + strcmp(state->config_revalidated_token, + istate->fsmonitor_last_update) || + repo_has_replace_refs_uncached(istate->repo) || + find_manifest_entry(&state->manifest.current, + path, algo, ¤t) || + current.source != ATTR_MANIFEST_WORKTREE || + attr_fingerprint_repository(istate->repo, &attrs) || + attrs.sources_present || + memcmp(attrs.content_hash, state->current_attr_hash, + algo->rawsz) || + memcmp(attrs.namespace_hash, + state->current_attr_namespace_hash, algo->rawsz) || + clean_status_config_read_repository(istate->repo, &config) || + !config.finalized || config.filter_configured || + memcmp(config.hash, state->current_config_hash, algo->rawsz) || + memcmp(config.semantic_hash, + state->current_semantic_hash, algo->rawsz)) + goto done; + pos = index_name_pos(istate, path, strlen(path)); + if (pos < 0) + goto done; + ce = istate->cache[pos]; + if (!S_ISREG(ce->ce_mode) || ce_stage(ce) || + ce_skip_worktree(ce) || ce_intent_to_add(ce) || + (ce->ce_flags & CE_VALID) || + read_root_worktree_attributes(istate->repo, &worktree)) + goto done; + hash_buffer_digest(algo, worktree.buf, worktree.len, worktree_hash); + if (memcmp(current.hash, worktree_hash, algo->rawsz)) + goto done; + indexed = odb_read_object(istate->repo->objects, + &ce->oid, &type, &indexed_len); + if (!indexed || type != OBJ_BLOB || + indexed_len >= ATTR_MAX_FILE_SIZE || + !attr_manifest_only_linguist_generated_changed( + indexed, indexed_len, worktree.buf, worktree.len) || + read_root_worktree_attributes(istate->repo, &observed) || + observed.len != worktree.len || + memcmp(observed.buf, worktree.buf, worktree.len)) + goto done; + safe = 1; + +done: + free(indexed); + strbuf_release(&worktree); + strbuf_release(&observed); + return safe; +#else + (void)istate; + (void)path; + return 0; +#endif +} + +static int root_attributes_only_affect_display( + const struct invalidate_manifest_data *data, const char *path, + int *index_pos) +{ + struct index_state *istate = data->istate; + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + struct clean_status_config_digest config; + struct attr_fingerprint attrs; + struct attr_manifest_entry old, current; + struct strbuf worktree = STRBUF_INIT; + const struct cache_entry *ce; + unsigned char hash[GIT_MAX_RAWSZ]; + void *staged = NULL; + size_t staged_len; + int pos, safe = 0; + + if (strcmp(path, GITATTRIBUTES_FILE) || !data->baseline || + !data->current || !state || + (state->current_attr_valid && state->current_attr_sources_present) || + state->filter_configured || + repo_has_replace_refs_uncached(istate->repo) || + find_manifest_entry(data->baseline, path, algo, &old) || + find_manifest_entry(data->current, path, algo, ¤t) || + current.source != ATTR_MANIFEST_WORKTREE || + attr_fingerprint_repository(istate->repo, &attrs) || + attrs.sources_present || + (state->current_attr_valid && + memcmp(attrs.content_hash, state->current_attr_hash, algo->rawsz)) || + clean_status_config_read_repository(istate->repo, &config) || + !config.finalized || config.filter_configured) + goto done; + pos = index_name_pos(istate, path, strlen(path)); + if (pos < 0) + goto done; + ce = istate->cache[pos]; + if (!S_ISREG(ce->ce_mode) || ce_stage(ce) || + ce_skip_worktree(ce) || ce_intent_to_add(ce) || + (ce->ce_flags & CE_VALID)) + goto done; + staged = read_authenticated_old_attributes( + istate, &old, ce, &staged_len); + if (!staged) + goto done; + if (read_root_worktree_attributes(istate->repo, &worktree)) + goto done; + hash_buffer_digest(algo, worktree.buf, worktree.len, hash); + if (memcmp(current.hash, hash, algo->rawsz) || + !attr_manifest_only_linguist_generated_changed( + staged, staged_len, worktree.buf, worktree.len)) + goto done; + *index_pos = pos; + safe = 1; + +done: + free(staged); + strbuf_release(&worktree); + return safe; +} + static int invalidate_manifest_path(const struct attr_manifest_entry *entry, void *cb_data) { struct invalidate_manifest_data *data = cb_data; char *path = xmemdupz(entry->path, entry->path_len); + int pos; untracked_cache_invalidate_trimmed_path(data->istate, path, 0); - data->invalidated += - fsmonitor_invalidate_attributes_path(data->istate, path); + if (root_attributes_only_affect_display(data, path, &pos)) { + git_attr_invalidate_all(); + fsmonitor_invalidate_cache_entry(data->istate->cache[pos]); + data->istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", data->istate->repo, + "semantic/nonconversion-attributes", 1); + } else { + data->invalidated += + fsmonitor_invalidate_attributes_path(data->istate, path); + } free(path); return 0; } @@ -139,6 +798,8 @@ int clean_status_manifest_refresh(struct index_state *istate, return -1; } if (baseline) { + invalidation.baseline = baseline; + invalidation.current = &next; if (attr_manifest_for_each_changed( baseline->buf, baseline->len, next.buf, next.len, algo, diff --git a/clean-status-manifest.h b/clean-status-manifest.h index 394fe25a888c0d..ba90498d56c247 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -31,6 +31,12 @@ void clean_status_manifest_adopt_disk( struct clean_status_manifest_state *state); int clean_status_manifest_refresh(struct index_state *istate, struct clean_status_manifest_state *state); +int clean_status_manifest_reconcile_deleted_attribute( + struct index_state *istate, const char *path); +int clean_status_manifest_reconcile_display_only_attribute( + struct index_state *istate, const char *path); +int clean_status_manifest_accept_current_display_only_attribute( + struct index_state *istate, const char *path); void clean_status_manifest_invalidate( struct clean_status_manifest_state *state); diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index 06cab58d59699e..274bacbb870965 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -9,10 +9,13 @@ #include "fsmonitor-ll.h" #include "fsmonitor-settings.h" #include "lockfile.h" +#include "object-file.h" #include "object-name.h" +#include "path-namespace.h" #include "preload-index.h" #include "read-cache-ll.h" #include "repository.h" +#include "semantic-verify-internal.h" #include "trace2.h" #include "wt-status.h" @@ -67,7 +70,8 @@ static int history_is_certifiable(const struct index_state *istate) } static int fsmonitor_state_is_certifiable( - struct repository *repo, const struct index_state *istate) + struct repository *repo, const struct index_state *istate, + uint32_t *hardlink_nr) { return !istate->split_index && istate->sparse_index == INDEX_EXPANDED && @@ -77,7 +81,101 @@ static int fsmonitor_state_is_certifiable( istate->fsmonitor_last_update && strlen(istate->fsmonitor_last_update) <= FSMONITOR_CLEAN_PROOF_TOKEN_MAX && - clean_status_index_is_certifiable(istate); + clean_status_index_is_certifiable_with_hardlinks( + istate, hardlink_nr); +} + +static int capture_hardlink_witnesses( + struct repository *repo, const struct index_state *istate, + uint32_t expected, struct strbuf *witnesses) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN && !defined(NO_NSEC) + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + unsigned int namespace_unstable = 0; + uint32_t captured = 0, verified = 0; + int ret = -1; + + if (!expected) + return 0; + if (!repo->config_values_private_.trust_ctime || + !repo->config_values_private_.check_stat || + semantic_verify_root_init(repo, &root)) + goto done; + path = semantic_verify_path_new(root); + if (!path) + goto done; + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + struct path_stat_identity identity; + struct stat held, named; + const char *basename; + int parent_fd, fd; + + if (ce->ce_flags & CE_FSMONITOR_VALID) + continue; + if (semantic_verify_resolve_parent( + path, ce->name, i, &parent_fd, &basename)) + goto done; + fd = semantic_verify_openat( + parent_fd, basename, + O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0) + goto done; + if (fstat(fd, &held) || !S_ISREG(held.st_mode) || + held.st_nlink <= 1 || held.st_dev != root->stat.st_dev || + match_stat_data(&ce->ce_stat_data, &held)) { + close(fd); + goto done; + } + if (ce->ce_stat_data.sd_ctime.nsec != ST_CTIME_NSEC(held) || + ce->ce_stat_data.sd_mtime.nsec != ST_MTIME_NSEC(held)) { + struct object_id observed; + struct stat after; + + if (index_fd(repo->index, &observed, xdup(fd), &held, + OBJ_BLOB, ce->name, 0) || + !oideq(&observed, &ce->oid) || fstat(fd, &after) || + !path_namespace_stat_equal(&held, &after)) { + close(fd); + goto done; + } + verified++; + } + if (fstatat(parent_fd, basename, &named, + AT_SYMLINK_NOFOLLOW) || + !path_namespace_stat_equal(&held, &named)) { + close(fd); + goto done; + } + path_stat_identity_init(&identity, &held); + close(fd); + if (clean_status_sidecar_append_hardlink( + witnesses, ce->name, &identity)) + goto done; + captured++; + } + if (captured != expected || !semantic_verify_root_stable(root)) + goto done; + if (verified) + trace2_data_intmax("status", repo, + "clean-proof/hardlink-content-verified", verified); + ret = 0; + +done: + semantic_verify_path_free(path, &namespace_unstable, NULL); + if (namespace_unstable || (root && !semantic_verify_root_stable(root))) + ret = -1; + semantic_verify_root_clear(root); + if (ret) + strbuf_reset(witnesses); + return ret; +#else + (void)repo; + (void)istate; + (void)witnesses; + return expected ? -1 : 0; +#endif } static int untracked_scan_is_certifiable( @@ -104,9 +202,11 @@ int clean_status_issue_sidecar( struct index_state *istate = repo->index; struct clean_status_index_snapshot index = { .fd = -1 }; struct clean_status_sidecar sidecar = { 0 }; + struct strbuf hardlinks = STRBUF_INIT; struct object_id exclude_digest, head_tree; struct stat scanned_worktree; unsigned char repo_hash[GIT_MAX_RAWSZ]; + uint32_t hardlink_nr = 0; int installed = 0; if (!is_lock_file_locked(index_lock) || @@ -120,7 +220,7 @@ int clean_status_issue_sidecar( goto done; } if (getenv(INDEX_ENVIRONMENT) || - !fsmonitor_state_is_certifiable(repo, istate) || + !fsmonitor_state_is_certifiable(repo, istate, &hardlink_nr) || !untracked_scan_is_certifiable( status, &exclude_digest, &scanned_worktree)) { trace_miss(repo, "issue-scan-or-index-shape"); @@ -139,8 +239,9 @@ int clean_status_issue_sidecar( goto done; } if (repo_get_oid_tree(repo, "HEAD^{tree}", &head_tree) || - !istate->cache_tree || istate->cache_tree->entry_count < 0 || - !oideq(&head_tree, &istate->cache_tree->oid)) { + ((!istate->cache_tree || istate->cache_tree->entry_count < 0) ? + !status->index_tree_verified : + !oideq(&head_tree, &istate->cache_tree->oid))) { trace_miss(repo, "issue-head-cache-tree"); goto done; } @@ -157,6 +258,16 @@ int clean_status_issue_sidecar( oidcpy(&sidecar.proof.exclude_source_digest, &exclude_digest); sidecar.token = (const unsigned char *)istate->fsmonitor_last_update; sidecar.token_len = strlen(istate->fsmonitor_last_update); + if (capture_hardlink_witnesses( + repo, istate, hardlink_nr, &hardlinks)) { + trace_miss(repo, "issue-hardlink-witness"); + goto done; + } + if (hardlink_nr) { + sidecar.hardlinks = (const unsigned char *)hardlinks.buf; + sidecar.hardlinks_len = hardlinks.len; + sidecar.hardlink_nr = hardlink_nr; + } if (clean_status_sidecar_install( repo->index_file, &sidecar, &index, repo->hash_algo)) { @@ -164,10 +275,14 @@ int clean_status_issue_sidecar( goto done; } rollback_lock_file(index_lock); + if (hardlink_nr) + trace2_data_intmax("status", repo, + "clean-proof/hardlink-witnesses", hardlink_nr); trace2_data_intmax("status", repo, "clean-proof/sidecar", 1); installed = 1; done: + strbuf_release(&hardlinks); clean_status_index_snapshot_release(&index); return installed; } diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index f387881a79368a..b40959bcd301ee 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -12,6 +12,7 @@ #include "hash-framing.h" #include "lockfile.h" #include "path.h" +#include "read-cache-ll.h" #include "repository.h" #include "replace-object.h" #include "strbuf.h" @@ -19,7 +20,7 @@ #include "wrapper.h" #define CLEAN_STATUS_SIDECAR_MAGIC "CSTS" -#define CLEAN_STATUS_SIDECAR_MAX_SIZE 8192 +#define CLEAN_STATUS_HARDLINK_PATH_MAX 4096 #define CLEAN_STATUS_FILESYSTEM_ID_SIZE 16 struct clean_status_filesystem_id { @@ -62,6 +63,108 @@ static int proof_valid(const struct clean_status_proof *proof, proof->exclude_source_digest.algo == hash_algo_by_ptr(algo); } +static int hardlink_path_valid(const unsigned char *path, size_t len, + const struct path_stat_identity *identity) +{ + char *name; + int valid; + + if (!path || !len || len > CLEAN_STATUS_HARDLINK_PATH_MAX || + memchr(path, '\0', len) || + identity->fields[2] > UINT32_MAX || + !S_ISREG((mode_t)identity->fields[2]) || + identity->fields[3] <= 1) + return 0; + name = xmemdupz(path, len); + valid = verify_path(name, (unsigned)identity->fields[2]); + free(name); + return valid; +} + +int clean_status_sidecar_append_hardlink( + struct strbuf *out, const char *path, + const struct path_stat_identity *identity) +{ + uint32_t path_len; + uint64_t field; + size_t len; + + if (!out || !path || !identity) + return -1; + len = strlen(path); + if (!hardlink_path_valid((const unsigned char *)path, len, identity) || + out->len > CLEAN_STATUS_SIDECAR_MAX_SIZE - + (sizeof(path_len) + len + CLEAN_STATUS_IDENTITY_SIZE)) + return -1; + put_be32(&path_len, (uint32_t)len); + strbuf_add(out, &path_len, sizeof(path_len)); + strbuf_add(out, path, len); + for (size_t i = 0; i < PATH_STAT_IDENTITY_FIELDS; i++) { + put_be64(&field, identity->fields[i]); + strbuf_add(out, &field, sizeof(field)); + } + return 0; +} + +int clean_status_sidecar_next_hardlink( + const unsigned char **cursor, const unsigned char *end, + const unsigned char **path, size_t *path_len, + struct path_stat_identity *identity) +{ + const unsigned char *p; + size_t len; + + if (!cursor || !*cursor || !end || !path || !path_len || !identity || + *cursor > end || (size_t)(end - *cursor) < sizeof(uint32_t)) + return -1; + p = *cursor; + len = get_be32(p); + p += sizeof(uint32_t); + if (len > (size_t)(end - p) || + (size_t)(end - p) - len < CLEAN_STATUS_IDENTITY_SIZE) + return -1; + *path = p; + *path_len = len; + p += len; + for (size_t i = 0; i < PATH_STAT_IDENTITY_FIELDS; i++) { + identity->fields[i] = get_be64(p); + p += sizeof(uint64_t); + } + if (!hardlink_path_valid(*path, *path_len, identity)) + return -1; + *cursor = p; + return 0; +} + +static int hardlink_block_valid(const unsigned char *block, size_t len, + uint32_t nr) +{ + struct path_stat_identity identity; + const unsigned char *cursor = block, *previous = NULL; + const unsigned char *path; + size_t path_len, previous_len = 0; + + if (!nr || nr > CLEAN_STATUS_HARDLINK_WITNESS_MAX || !block || + len > CLEAN_STATUS_SIDECAR_MAX_SIZE) + return 0; + for (uint32_t i = 0; i < nr; i++) { + if (clean_status_sidecar_next_hardlink( + &cursor, block + len, &path, &path_len, &identity)) + return 0; + if (previous) { + size_t common = previous_len < path_len ? + previous_len : path_len; + int order = memcmp(previous, path, common); + + if (order > 0 || (!order && previous_len >= path_len)) + return 0; + } + previous = path; + previous_len = path_len; + } + return cursor == block + len; +} + int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, const void *data, size_t len, const struct git_hash_algo *algo) @@ -71,15 +174,18 @@ int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, size_t minimum = 4 + 2 * sizeof(uint32_t) + CLEAN_STATUS_IDENTITY_SIZE + 3 * sizeof(uint32_t) + 6 * algo->rawsz + 1; - uint32_t flags, token_len; + uint32_t flags, token_len, version; memset(sidecar, 0, sizeof(*sidecar)); - if (len < minimum || memcmp(p, CLEAN_STATUS_SIDECAR_MAGIC, 4) || + if (len < minimum || len > CLEAN_STATUS_SIDECAR_MAX_SIZE || + memcmp(p, CLEAN_STATUS_SIDECAR_MAGIC, 4) || !checksum_valid(data, len, algo)) return -1; end = p + len - algo->rawsz; p += 4; - if (get_be32(p) != CLEAN_STATUS_SIDECAR_VERSION) + version = get_be32(p); + if (version != CLEAN_STATUS_SIDECAR_VERSION && + version != CLEAN_STATUS_SIDECAR_HARDLINK_VERSION) return -1; p += sizeof(uint32_t); flags = get_be32(p); @@ -105,11 +211,24 @@ int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, token_len = get_be32(p); p += sizeof(uint32_t); if (!proof_valid(&sidecar->proof, algo) || - (size_t)(end - p) != token_len || + (size_t)(end - p) < token_len || !token_valid(p, token_len)) return -1; sidecar->token = p; sidecar->token_len = token_len; + p += token_len; + if (version == CLEAN_STATUS_SIDECAR_VERSION) + return p == end ? 0 : -1; + if ((size_t)(end - p) < sizeof(uint32_t)) + return -1; + sidecar->hardlink_nr = get_be32(p); + p += sizeof(uint32_t); + sidecar->hardlinks = p; + sidecar->hardlinks_len = end - p; + if (!hardlink_block_valid(sidecar->hardlinks, + sidecar->hardlinks_len, + sidecar->hardlink_nr)) + return -1; return 0; } @@ -122,11 +241,18 @@ int clean_status_sidecar_write(struct strbuf *out, strbuf_reset(out); if (!proof_valid(&sidecar->proof, algo) || sidecar->token_len > UINT32_MAX || - !token_valid(sidecar->token, sidecar->token_len)) + !token_valid(sidecar->token, sidecar->token_len) || + (sidecar->hardlink_nr ? + !hardlink_block_valid(sidecar->hardlinks, + sidecar->hardlinks_len, + sidecar->hardlink_nr) : + (sidecar->hardlinks || sidecar->hardlinks_len))) return -1; strbuf_add(out, CLEAN_STATUS_SIDECAR_MAGIC, 4); - put_be32(&value, CLEAN_STATUS_SIDECAR_VERSION); + put_be32(&value, sidecar->hardlink_nr ? + CLEAN_STATUS_SIDECAR_HARDLINK_VERSION : + CLEAN_STATUS_SIDECAR_VERSION); strbuf_add(out, &value, sizeof(value)); put_be32(&value, 0); strbuf_add(out, &value, sizeof(value)); @@ -144,6 +270,15 @@ int clean_status_sidecar_write(struct strbuf *out, put_be32(&value, sidecar->token_len); strbuf_add(out, &value, sizeof(value)); strbuf_add(out, sidecar->token, sidecar->token_len); + if (sidecar->hardlink_nr) { + put_be32(&value, sidecar->hardlink_nr); + strbuf_add(out, &value, sizeof(value)); + strbuf_add(out, sidecar->hardlinks, sidecar->hardlinks_len); + } + if (out->len > CLEAN_STATUS_SIDECAR_MAX_SIZE - algo->rawsz) { + strbuf_reset(out); + return -1; + } hash_append_checksum(out, algo); return 0; } @@ -277,7 +412,7 @@ int clean_status_sidecar_install( index_path, sidecar, snapshot, algo) || clean_status_sidecar_write(&encoded, sidecar, algo)) goto done; - sidecar_fd = hold_lock_file_for_update(&lock, path, 0); + sidecar_fd = hold_lock_file_for_update(&lock, path, LOCK_NO_DEREF); if (sidecar_fd < 0 || (size_t)write_in_full(sidecar_fd, encoded.buf, encoded.len) != encoded.len || diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index 8149acbe5b86bb..a496246c28ddff 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -11,6 +11,9 @@ struct repository; struct stat; #define CLEAN_STATUS_SIDECAR_VERSION 1 +#define CLEAN_STATUS_SIDECAR_HARDLINK_VERSION 2 +#define CLEAN_STATUS_HARDLINK_WITNESS_MAX 4096 +#define CLEAN_STATUS_SIDECAR_MAX_SIZE (1024 * 1024) struct clean_status_proof { uint32_t index_version; @@ -27,6 +30,9 @@ struct clean_status_sidecar { struct clean_status_proof proof; const unsigned char *token; size_t token_len; + const unsigned char *hardlinks; + size_t hardlinks_len; + uint32_t hardlink_nr; }; struct clean_status_sidecar_record { @@ -44,6 +50,13 @@ int clean_status_sidecar_parse(struct clean_status_sidecar *sidecar, int clean_status_sidecar_write(struct strbuf *out, const struct clean_status_sidecar *sidecar, const struct git_hash_algo *algo); +int clean_status_sidecar_append_hardlink( + struct strbuf *out, const char *path, + const struct path_stat_identity *identity); +int clean_status_sidecar_next_hardlink( + const unsigned char **cursor, const unsigned char *end, + const unsigned char **path, size_t *path_len, + struct path_stat_identity *identity); int clean_status_sidecar_load( const char *index_path, const struct git_hash_algo *algo, struct clean_status_sidecar_record *record); diff --git a/clean-status.c b/clean-status.c index 1597f76e64881e..603edf66e524fb 100644 --- a/clean-status.c +++ b/clean-status.c @@ -1,17 +1,22 @@ #include "git-compat-util.h" #include "attr-fingerprint.h" +#include "attr-manifest.h" #include "clean-status.h" #include "clean-status-internal.h" +#include "dir.h" #include "fsmonitor-clean-proof.h" #include "progress.h" #include "read-cache-ll.h" #include "repository.h" +#include "semantic-verify-internal.h" +#include "worktree-attr-source.h" #include "thread-utils.h" #include "trace2.h" static struct repository *configured_repo; static unsigned char configured_hash[GIT_MAX_RAWSZ]; static unsigned char configured_semantic_hash[GIT_MAX_RAWSZ]; +static unsigned char configured_tracked_policy_hash[GIT_MAX_RAWSZ]; static struct repository *external_history_repo; static struct repository *progress_repo; static int configured_hash_valid; @@ -80,6 +85,8 @@ struct clean_status_state *clean_status_get_state(struct index_state *istate) istate->clean_status->source_index_fd = -1; clean_status_manifest_init(&istate->clean_status->manifest); strbuf_init(&istate->clean_status->disk_config_raw, 0); + strbuf_init(&istate->clean_status->authenticated_new_directories, + 0); } return istate->clean_status; } @@ -99,6 +106,8 @@ void clean_status_set_config_digest( memcpy(configured_hash, digest->hash, repo->hash_algo->rawsz); memcpy(configured_semantic_hash, digest->semantic_hash, repo->hash_algo->rawsz); + memcpy(configured_tracked_policy_hash, + digest->tracked_policy_hash, repo->hash_algo->rawsz); } void clean_status_attach_config(struct index_state *istate) @@ -115,8 +124,12 @@ void clean_status_attach_config(struct index_state *istate) istate->repo->hash_algo->rawsz); memcpy(state->current_semantic_hash, configured_semantic_hash, istate->repo->hash_algo->rawsz); + memcpy(state->current_tracked_policy_hash, + configured_tracked_policy_hash, + istate->repo->hash_algo->rawsz); state->current_config_valid = 1; state->current_semantic_valid = 1; + state->current_tracked_policy_valid = 1; state->current_semantic_explicit = configured_semantic_explicit; state->config_enforced = 1; state->filter_configured = configured_filter_configured; @@ -125,6 +138,9 @@ void clean_status_attach_config(struct index_state *istate) istate->repo->hash_algo->rawsz); memcpy(state->current_attr_namespace_hash, attrs.namespace_hash, istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_portable_namespace_hash, + attrs.portable_namespace_hash, + istate->repo->hash_algo->rawsz); state->current_attr_valid = 1; state->current_attr_sources_present = attrs.sources_present; } @@ -166,12 +182,261 @@ void clean_status_invalidate_current_proof(struct index_state *istate) { if (!istate->clean_status) return; + clean_status_clear_authenticated_new_directories(istate); istate->clean_status->config_revalidated = 0; istate->clean_status->initial_coherent = 0; istate->clean_status->filter_scope_valid = 0; istate->clean_status->semantic_baseline_pending = 0; } +static int path_has_no_new_attribute_sources( + const struct index_state *istate, const char *name, + int allow_removed_parent) +{ + const struct clean_status_manifest_state *manifest = + &istate->clean_status->manifest; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + struct strbuf candidate = STRBUF_INIT; + const char *slash = name; + unsigned char hash[GIT_MAX_RAWSZ]; + unsigned int namespace_unstable = 0; + size_t position = 0; + int safe = 0; + + if (!strchr(name, '/')) + return 1; + if (semantic_verify_root_init(istate->repo, &root)) + goto done; + path = semantic_verify_path_new(root); + while ((slash = strchr(slash, '/')) != NULL) { + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + const struct cache_entry *source = NULL; + const char *basename; + unsigned int low = 0, high = istate->cache_nr; + int parent_fd, found, matched = 0, missing_parent = 0; + + strbuf_reset(&candidate); + strbuf_add(&candidate, name, slash - name + 1); + strbuf_addstr(&candidate, ".gitattributes"); + if (semantic_verify_resolve_parent(path, candidate.buf, + position, &parent_fd, + &basename)) { + if (!allow_removed_parent || errno != ENOENT) + goto done; + missing_parent = 1; + found = 0; + } else if (worktree_attr_source_read(path, candidate.buf, + position, + istate->repo->hash_algo, + hash, &found)) { + goto done; + } + position++; + while (low < high) { + unsigned int middle = low + (high - low) / 2; + const struct cache_entry *ce = istate->cache[middle]; + int cmp = strcmp(ce->name, candidate.buf); + + if (!cmp) { + source = ce; + break; + } + if (cmp < 0) + low = middle + 1; + else + high = middle; + } + if (!source && !found && !missing_parent) { + slash++; + continue; + } + if (!manifest->current_valid || manifest->current_invalidated || + (manifest->current_flags & + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) != + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) + goto done; + if (attr_manifest_cursor_init(&cursor, manifest->current.buf, + manifest->current.len, + istate->repo->hash_algo)) + goto done; + if (missing_parent) { + if (source) + goto done; + while (attr_manifest_cursor_next(&cursor, &entry) > 0) + if (entry.path_len == candidate.len && + !memcmp(entry.path, candidate.buf, candidate.len)) + goto done; + slash++; + continue; + } + if (!source || !S_ISREG(source->ce_mode) || ce_stage(source) || + ce_skip_worktree(source) || ce_intent_to_add(source) || + (source->ce_flags & CE_VALID)) + goto done; + while (attr_manifest_cursor_next(&cursor, &entry) > 0) { + if (entry.path_len != candidate.len || + memcmp(entry.path, candidate.buf, candidate.len)) + continue; + matched = found ? + entry.source == ATTR_MANIFEST_WORKTREE && + !memcmp(entry.hash, hash, + istate->repo->hash_algo->rawsz) : + entry.source == ATTR_MANIFEST_INDEX && + !memcmp(entry.hash, source->oid.hash, + istate->repo->hash_algo->rawsz); + break; + } + if (!matched) + goto done; + slash++; + } + semantic_verify_path_free(path, &namespace_unstable, NULL); + path = NULL; + safe = !namespace_unstable && semantic_verify_root_stable(root); + +done: + if (path) + semantic_verify_path_free(path, &namespace_unstable, NULL); + semantic_verify_root_clear(root); + strbuf_release(&candidate); + return safe; +} + +int clean_status_index_entry_is_semantically_safe( + const struct index_state *istate, + const struct cache_entry *old, + const struct cache_entry *new_entry) +{ + const struct clean_status_state *state = istate->clean_status; + const struct cache_entry *entry = old ? old : new_entry; + const char *base; + + if (!state || !state->config_revalidated || + !clean_status_revalidated_token_matches(istate) || + state->filter_configured || istate->split_index || + istate->sparse_index || !entry) + return 0; + if ((old && (!S_ISREG(old->ce_mode) && !S_ISLNK(old->ce_mode))) || + (new_entry && (!S_ISREG(new_entry->ce_mode) && + !S_ISLNK(new_entry->ce_mode)))) + return 0; + if ((old && (ce_stage(old) || ce_skip_worktree(old) || + ce_intent_to_add(old) || (old->ce_flags & CE_VALID))) || + (new_entry && (ce_stage(new_entry) || + ce_skip_worktree(new_entry) || + ce_intent_to_add(new_entry) || + (new_entry->ce_flags & CE_VALID)))) + return 0; + base = strrchr(entry->name, '/'); + base = base ? base + 1 : entry->name; + if (!fspathcmp(base, ".gitattributes") || + !fspathcmp(base, ".gitignore")) + return 0; + if (!old || !new_entry) + return path_has_no_new_attribute_sources(istate, entry->name, + old && !new_entry); + return ce_namelen(old) == ce_namelen(new_entry) && + !memcmp(old->name, new_entry->name, ce_namelen(old)) && + old->ce_mode == new_entry->ce_mode; +} + +void clean_status_clear_authenticated_new_directories( + struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + + if (!state) + return; + strbuf_reset(&state->authenticated_new_directories); + FREE_AND_NULL(state->authenticated_new_directories_token); +} + +static unsigned int clean_status_directory_lower_bound( + const struct index_state *istate, const char *name) +{ + unsigned int low = 0, high = istate->cache_nr; + + while (low < high) { + unsigned int middle = low + (high - low) / 2; + + if (strcmp(istate->cache[middle]->name, name) < 0) + low = middle + 1; + else + high = middle; + } + return low; +} + +void clean_status_set_authenticated_new_directories( + struct index_state *istate, const struct index_state *old_index, + const struct strbuf *paths) +{ + struct clean_status_state *state = istate->clean_status; + const char *path = paths->buf, *end = paths->buf + paths->len; + + clean_status_clear_authenticated_new_directories(istate); + if (!state || !state->manifest.current_valid || + state->manifest.current_invalidated || + (state->manifest.current_flags & + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX)) != + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX) || + !clean_status_revalidated_token_matches(istate)) + return; + while (path < end) { + size_t len = strlen(path); + unsigned int old_pos, new_pos; + + if (!len || path[len - 1] != '/') + goto next; + old_pos = clean_status_directory_lower_bound(old_index, path); + new_pos = clean_status_directory_lower_bound(istate, path); + if ((old_pos < old_index->cache_nr && + starts_with(old_index->cache[old_pos]->name, path)) || + new_pos >= istate->cache_nr || + !starts_with(istate->cache[new_pos]->name, path) || + !clean_status_index_entry_is_semantically_safe( + old_index, NULL, istate->cache[new_pos])) + goto next; + strbuf_add(&state->authenticated_new_directories, path, + len + 1); +next: + path += len + 1; + } + if (state->authenticated_new_directories.len) + state->authenticated_new_directories_token = + xstrdup(istate->fsmonitor_last_update); +} + +int clean_status_directory_event_is_semantically_safe( + const struct index_state *istate, const char *name) +{ + const struct clean_status_state *state = istate->clean_status; + const char *path, *end; + + if (!state || !state->authenticated_new_directories_token || + !clean_status_revalidated_token_matches(istate) || + strcmp(state->authenticated_new_directories_token, + istate->fsmonitor_last_update)) + return 0; + path = state->authenticated_new_directories.buf; + end = path + state->authenticated_new_directories.len; + while (path < end) { + if (!strcmp(path, name)) { + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/authenticated-new-directory", 1); + return 1; + } + path += strlen(path) + 1; + } + return 0; +} + int clean_status_capture_attr_snapshot( struct index_state *istate, struct attr_source_snapshot **snapshot) @@ -206,6 +471,9 @@ int clean_status_capture_attr_snapshot( istate->repo->hash_algo->rawsz); memcpy(state->current_attr_namespace_hash, attrs->namespace_hash, istate->repo->hash_algo->rawsz); + memcpy(state->current_attr_portable_namespace_hash, + attrs->portable_namespace_hash, + istate->repo->hash_algo->rawsz); state->current_attr_valid = 1; state->current_attr_sources_present = attrs->sources_present; } else { @@ -254,6 +522,25 @@ int clean_status_manifest_global_fallback(const struct index_state *istate) istate->clean_status->manifest.global_fallback; } +int clean_status_has_authenticated_worktree_manifest( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->disk_config_valid && + !state->disk_config_invalid && istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && state->disk_config_token && + !strcmp(state->disk_config_token, + istate->fsmonitor_last_update) && + state->manifest.disk_valid && + (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL && + state->manifest.current_valid && state->manifest.checked && + !state->manifest.current_invalidated && + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) == + FSMONITOR_CLEAN_PROOF_ALL; +} + int clean_status_worktree_manifest_needs_refresh( const struct index_state *istate) { @@ -313,7 +600,9 @@ void clean_status_release(struct index_state *istate) close(istate->clean_status->source_index_fd); clean_status_manifest_release(&istate->clean_status->manifest); strbuf_release(&istate->clean_status->disk_config_raw); + strbuf_release(&istate->clean_status->authenticated_new_directories); free(istate->clean_status->disk_config_token); free(istate->clean_status->config_revalidated_token); + free(istate->clean_status->authenticated_new_directories_token); FREE_AND_NULL(istate->clean_status); } diff --git a/clean-status.h b/clean-status.h index 0988e9ba318f32..b6a78bcf8b7b8c 100644 --- a/clean-status.h +++ b/clean-status.h @@ -4,6 +4,7 @@ #include "clean-status-config.h" struct index_state; +struct cache_entry; struct attr_source_snapshot; struct clean_status_progress; struct clean_status_proof_epoch; @@ -58,6 +59,8 @@ void clean_status_release_proof_epoch( int clean_status_fsmonitor_config_mismatch(const struct index_state *istate); int clean_status_fsmonitor_strong_mismatch(const struct index_state *istate); +int clean_status_try_preserve_tracked_config_epoch( + struct index_state *istate); int clean_status_revalidated_token_matches( const struct index_state *istate); @@ -76,6 +79,10 @@ void clean_status_begin_fsmonitor_semantic_baseline( int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); +int clean_status_has_authenticated_worktree_manifest( + const struct index_state *istate); +int clean_status_has_authenticated_bootstrap_manifest( + const struct index_state *istate); int clean_status_worktree_manifest_needs_refresh( const struct index_state *istate); void clean_status_invalidate_current_manifest(struct index_state *istate); @@ -104,6 +111,17 @@ int clean_status_read_fsmonitor_config(struct index_state *istate, void clean_status_prepare_fsmonitor_config(struct index_state *istate); int clean_status_probe_fsmonitor_config(struct index_state *istate); void clean_status_invalidate_current_proof(struct index_state *istate); +int clean_status_index_entry_is_semantically_safe( + const struct index_state *istate, + const struct cache_entry *old, + const struct cache_entry *new_entry); +void clean_status_set_authenticated_new_directories( + struct index_state *istate, const struct index_state *old_index, + const struct strbuf *paths); +void clean_status_clear_authenticated_new_directories( + struct index_state *istate); +int clean_status_directory_event_is_semantically_safe( + const struct index_state *istate, const char *name); void clean_status_advance_fsmonitor_config_token( struct index_state *istate, const char *next_token); int clean_status_should_write_fsmonitor_config( @@ -113,6 +131,12 @@ void clean_status_write_fsmonitor_config(struct strbuf *out, int clean_status_restore_external_history(struct index_state *istate); int clean_status_external_history_was_restored( const struct index_state *istate); +int clean_status_external_history_needs_witness_preservation( + const struct index_state *istate); +int clean_status_has_recovered_tracked_stat( + const struct index_state *istate); +int clean_status_external_history_owns_index( + const struct index_state *istate); void clean_status_capture_external_history_source( struct index_state *istate); int clean_status_save_external_history(struct index_state *istate); @@ -120,6 +144,8 @@ void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src); int clean_status_transfer_current_proof_if_same_index( struct index_state *dst, const struct index_state *src); +int clean_status_transfer_current_proof_if_semantically_same_index( + struct index_state *dst, const struct index_state *src); void clean_status_release(struct index_state *istate); diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index ffd8392262261b..41e47c4ac17d77 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -144,6 +144,20 @@ static int ef_is_hardlink(const FSEventStreamEventFlags ef) kFSEventStreamEventFlagItemIsLastHardlink); } +static int ef_ignore_dir_metadata(const FSEventStreamEventFlags ef) +{ + static const FSEventStreamEventFlags required = + kFSEventStreamEventFlagItemIsDir | + kFSEventStreamEventFlagItemInodeMetaMod; + static const FSEventStreamEventFlags allowed = + kFSEventStreamEventFlagItemIsDir | + kFSEventStreamEventFlagItemInodeMetaMod | + kFSEventStreamEventFlagItemCreated | + kFSEventStreamEventFlagItemXattrMod; + + return (ef & required) == required && !(ef & ~allowed); +} + /* * If an `xattr` change is the only reason we received this event, * then silently ignore it. Git doesn't care about xattr's. We @@ -353,6 +367,12 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, if (trace_pass_fl(&trace_fsmonitor)) log_flags_set(path_k, event_flags[k]); + if (ef_ignore_dir_metadata(event_flags[k])) { + trace_printf_key(&trace_fsmonitor, + "ignore-dir-metadata: '%s', flags=0x%x", + path_k, event_flags[k]); + break; + } /* * Because of the implicit "binning" (the diff --git a/compat/fsmonitor/fsm-listen-linux.c b/compat/fsmonitor/fsm-listen-linux.c index e3dca14b620ee3..6181dcba51472d 100644 --- a/compat/fsmonitor/fsm-listen-linux.c +++ b/compat/fsmonitor/fsm-listen-linux.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "dir.h" +#include "fsmonitor-ipc.h" #include "fsmonitor-ll.h" #include "fsm-listen.h" #include "fsmonitor--daemon.h" @@ -42,6 +43,7 @@ struct rename_entry { struct fsm_listen_data { int fd_inotify; + const char *worktree_identity; enum shutdown_reason shutdown; struct hashmap watches; struct hashmap renames; @@ -102,9 +104,12 @@ static int add_watch(const char *path, struct fsm_listen_data *data) return 0; /* directory was deleted or is not a directory */ if (errno == EEXIST) return 0; /* watch already exists, no action needed */ - if (errno == ENOSPC) + if (errno == ENOSPC) { + fsmonitor_ipc__record_watch_limit_failure( + data->worktree_identity); return error(_("inotify watch limit reached; " "increase fs.inotify.max_user_watches")); + } return error_errno(_("inotify_add_watch('%s') failed"), interned); } @@ -409,6 +414,7 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) state->listen_data = data; state->listen_error_code = -1; data->fd_inotify = -1; + data->worktree_identity = state->worktree_identity.buf; data->shutdown = SHUTDOWN_ERROR; fd = inotify_init1(O_NONBLOCK); @@ -435,6 +441,7 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) } if (!ret) { + fsmonitor_ipc__clear_watch_limit_failure(); state->listen_error_code = 0; data->shutdown = SHUTDOWN_CONTINUE; } @@ -445,11 +452,6 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) void fsm_listen__dtor(struct fsmonitor_daemon_state *state) { struct fsm_listen_data *data; - struct hashmap_iter iter; - struct watch_entry *w; - struct watch_entry **to_remove; - size_t nr_to_remove = 0, alloc_to_remove = 0; - size_t i; int fd; if (!state || !state->listen_data) @@ -459,31 +461,17 @@ void fsm_listen__dtor(struct fsmonitor_daemon_state *state) fd = data->fd_inotify; /* - * Collect all entries first, then remove them. - * We can't modify the hashmap while iterating over it. + * Closing the inotify instance releases every kernel watch at once. + * The forward and reverse maps own separate watch_entry allocations. */ - to_remove = NULL; - hashmap_for_each_entry(&data->watches, &iter, w, ent) { - ALLOC_GROW(to_remove, nr_to_remove + 1, alloc_to_remove); - to_remove[nr_to_remove++] = w; - } - - for (i = 0; i < nr_to_remove; i++) { - to_remove[i]->cookie = 0; /* ignore any pending renames */ - remove_watch(to_remove[i], data); - } - free(to_remove); - - hashmap_clear(&data->watches); - - hashmap_clear(&data->revwatches); /* remove_watch freed the entries */ - + data->fd_inotify = -1; + if (fd >= 0 && close(fd) < 0) + error_errno(_("closing inotify file descriptor failed")); + hashmap_clear_and_free(&data->watches, struct watch_entry, ent); + hashmap_clear_and_free(&data->revwatches, struct watch_entry, ent); hashmap_clear_and_free(&data->renames, struct rename_entry, ent); FREE_AND_NULL(state->listen_data); - - if (fd >= 0 && (close(fd) < 0)) - error_errno(_("closing inotify file descriptor failed")); } void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) diff --git a/compat/simple-ipc/ipc-unix-socket.c b/compat/simple-ipc/ipc-unix-socket.c index 7db3b2a89755c6..d27747bc1d0b63 100644 --- a/compat/simple-ipc/ipc-unix-socket.c +++ b/compat/simple-ipc/ipc-unix-socket.c @@ -189,10 +189,10 @@ void ipc_client_close_connection(struct ipc_client_connection *connection) free(connection); } -int ipc_client_send_command_to_connection( +static int ipc_client_send_command_to_connection_1( struct ipc_client_connection *connection, const char *message, size_t message_len, - struct strbuf *answer) + struct strbuf *answer, int gentle) { int ret = 0; @@ -203,14 +203,14 @@ int ipc_client_send_command_to_connection( if (write_packetized_from_buf_no_flush(message, message_len, connection->fd) < 0 || packet_flush_gently(connection->fd) < 0) { - ret = error(_("could not send IPC command")); + ret = gentle ? -1 : error(_("could not send IPC command")); goto done; } if (read_packetized_to_strbuf( connection->fd, answer, PACKET_READ_GENTLE_ON_EOF | PACKET_READ_GENTLE_ON_READ_ERROR) < 0) { - ret = error(_("could not read IPC response")); + ret = gentle ? -1 : error(_("could not read IPC response")); goto done; } @@ -219,6 +219,24 @@ int ipc_client_send_command_to_connection( return ret; } +int ipc_client_send_command_to_connection( + struct ipc_client_connection *connection, + const char *message, size_t message_len, + struct strbuf *answer) +{ + return ipc_client_send_command_to_connection_1( + connection, message, message_len, answer, 0); +} + +int ipc_client_send_command_to_connection_gently( + struct ipc_client_connection *connection, + const char *message, size_t message_len, + struct strbuf *answer) +{ + return ipc_client_send_command_to_connection_1( + connection, message, message_len, answer, 1); +} + int ipc_client_send_command(const char *path, const struct ipc_client_connect_options *options, const char *message, size_t message_len, diff --git a/compat/simple-ipc/ipc-win32.c b/compat/simple-ipc/ipc-win32.c index 4a3e7df9c739e1..f1b4124d3ae8df 100644 --- a/compat/simple-ipc/ipc-win32.c +++ b/compat/simple-ipc/ipc-win32.c @@ -235,10 +235,10 @@ void ipc_client_close_connection(struct ipc_client_connection *connection) free(connection); } -int ipc_client_send_command_to_connection( +static int ipc_client_send_command_to_connection_1( struct ipc_client_connection *connection, const char *message, size_t message_len, - struct strbuf *answer) + struct strbuf *answer, int gentle) { int ret = 0; @@ -249,7 +249,7 @@ int ipc_client_send_command_to_connection( if (write_packetized_from_buf_no_flush(message, message_len, connection->fd) < 0 || packet_flush_gently(connection->fd) < 0) { - ret = error(_("could not send IPC command")); + ret = gentle ? -1 : error(_("could not send IPC command")); goto done; } @@ -258,7 +258,7 @@ int ipc_client_send_command_to_connection( if (read_packetized_to_strbuf( connection->fd, answer, PACKET_READ_GENTLE_ON_EOF | PACKET_READ_GENTLE_ON_READ_ERROR) < 0) { - ret = error(_("could not read IPC response")); + ret = gentle ? -1 : error(_("could not read IPC response")); goto done; } @@ -267,6 +267,24 @@ int ipc_client_send_command_to_connection( return ret; } +int ipc_client_send_command_to_connection( + struct ipc_client_connection *connection, + const char *message, size_t message_len, + struct strbuf *answer) +{ + return ipc_client_send_command_to_connection_1( + connection, message, message_len, answer, 0); +} + +int ipc_client_send_command_to_connection_gently( + struct ipc_client_connection *connection, + const char *message, size_t message_len, + struct strbuf *answer) +{ + return ipc_client_send_command_to_connection_1( + connection, message, message_len, answer, 1); +} + int ipc_client_send_command(const char *path, const struct ipc_client_connect_options *options, const char *message, size_t message_len, diff --git a/dir.c b/dir.c index 27f13a51569848..927dda6e5b2e9c 100644 --- a/dir.c +++ b/dir.c @@ -108,6 +108,7 @@ struct untracked_cache_preload { struct index_state *istate; struct untracked_cache *uc; struct untracked_cache_dir *root; + const struct pathspec *pathspec; struct untracked_cache_preload_task *tasks; struct object_id *exclude_index_oids; struct untracked_cache_preload_data *data; @@ -135,16 +136,77 @@ static int match_untracked_dir_stat_racy(const struct cache_time *timestamp, const struct stat *st); static void *preload_untracked_cache_thread(void *data); +static const struct pathspec *untracked_cache_preload_pathspec( + const struct pathspec *pathspec) +{ + int i, positive = 0; + + if (!pathspec || !pathspec->nr || + (pathspec->magic & (PATHSPEC_ATTR | PATHSPEC_ICASE))) + return NULL; + + for (i = 0; i < pathspec->nr; i++) { + const struct pathspec_item *item = &pathspec->items[i]; + + if (item->magic & PATHSPEC_EXCLUDE) + continue; + if (!item->nowildcard_len || strstr(item->match, "//") || + starts_with(item->match, "./") || + strstr(item->match, "/./") || + strstr(item->match, "/../")) + return NULL; + positive = 1; + } + return positive ? pathspec : NULL; +} + +static int untracked_cache_preload_pathspec_matches( + const struct pathspec *pathspec, + const char *path, + size_t pathlen) +{ + int i; + + if (!pathspec || !pathlen) + return 1; + + for (i = 0; i < pathspec->nr; i++) { + const struct pathspec_item *item = &pathspec->items[i]; + size_t len = item->nowildcard_len; + int wildcard = len != item->len; + + if (item->magic & PATHSPEC_EXCLUDE) + continue; + if (!wildcard) + while (len && item->match[len - 1] == '/') + len--; + if (!len) + return 1; + if (strncmp(path, item->match, pathlen < len ? pathlen : len)) + continue; + if (pathlen == len || + (pathlen < len && item->match[pathlen] == '/') || + (pathlen > len && (wildcard || path[len] == '/'))) + return 1; + } + return 0; +} + static void collect_untracked_cache_preload_tasks( struct untracked_cache_dir *ucd, struct strbuf *path, struct untracked_cache_preload_task **tasks, size_t *nr, size_t *alloc, - int fsmonitor_excludes_only) + int fsmonitor_excludes_only, + const struct pathspec *pathspec) { size_t i; + if (!untracked_cache_preload_pathspec_matches( + pathspec, path->buf, path->len)) + return; + if (!fsmonitor_excludes_only || !is_null_oid(&ucd->exclude_oid)) { ALLOC_GROW(*tasks, *nr + 1, *alloc); @@ -165,7 +227,8 @@ static void collect_untracked_cache_preload_tasks( strbuf_addch(path, '/'); strbuf_addstr(path, child->name); collect_untracked_cache_preload_tasks(child, path, tasks, nr, - alloc, fsmonitor_excludes_only); + alloc, fsmonitor_excludes_only, + pathspec); strbuf_setlen(path, old_len); } } @@ -342,7 +405,7 @@ static void preload_fsmonitor_excludes_from_index( static struct untracked_cache_preload *untracked_cache_preload_start_1( struct index_state *istate, unsigned int dir_flags, int automatic, - int fsmonitor_excludes_only) + int fsmonitor_excludes_only, const struct pathspec *pathspec) { struct untracked_cache *uc = istate->untracked; struct untracked_cache_preload *preload; @@ -362,13 +425,15 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( preload->istate = istate; preload->uc = uc; preload->root = uc->root; + preload->pathspec = fsmonitor_excludes_only ? + untracked_cache_preload_pathspec(pathspec) : NULL; preload->index_timestamp = istate->timestamp; preload->exclude_per_dir = xstrdup_or_null(uc->exclude_per_dir); preload->dir_flags = dir_flags; preload->fsmonitor_excludes_only = fsmonitor_excludes_only; collect_untracked_cache_preload_tasks( uc->root, &path, &preload->tasks, &preload->nr, &alloc, - fsmonitor_excludes_only); + fsmonitor_excludes_only, preload->pathspec); strbuf_release(&path); if (fsmonitor_excludes_only) { CALLOC_ARRAY(preload->exclude_index_oids, preload->nr); @@ -433,10 +498,11 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( struct untracked_cache_preload * untracked_cache_preload_start_fsmonitor_excludes( - struct index_state *istate, unsigned int dir_flags) + struct index_state *istate, unsigned int dir_flags, + const struct pathspec *pathspec) { return untracked_cache_preload_start_1( - istate, dir_flags, 0, 1); + istate, dir_flags, 0, 1, pathspec); } struct untracked_cache_preload *untracked_cache_preload_start_ordinary( @@ -447,7 +513,7 @@ struct untracked_cache_preload *untracked_cache_preload_start_ordinary( if (!uc || uc->dir_flags != dir_flags || !untracked_cache_auto_preload_worthwhile(uc)) return NULL; - return untracked_cache_preload_start_1(istate, dir_flags, 1, 0); + return untracked_cache_preload_start_1(istate, dir_flags, 1, 0, NULL); } static void *preload_untracked_cache_thread(void *_data) @@ -795,6 +861,26 @@ static int update_preloaded_exclude_index_uptodate( return marked; } +static void invalidate_scoped_preloaded_exclude( + struct untracked_cache_preload *preload, + const struct untracked_cache_preload_task *task) +{ + struct strbuf path = STRBUF_INIT; + + if (!preload->exclude_per_dir) { + preload->root->valid = 0; + preload->root->valid_recursive = 0; + return; + } + if (strcmp(task->path, ".")) { + strbuf_addstr(&path, task->path); + strbuf_addch(&path, '/'); + } + strbuf_addstr(&path, preload->exclude_per_dir); + untracked_cache_invalidate_path(preload->istate, path.buf, 1); + strbuf_release(&path); +} + int untracked_cache_preload_finish(struct untracked_cache_preload *preload, struct index_state *istate, unsigned int dir_flags, @@ -828,6 +914,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, oideq(&task->exclude_oid, &task->ucd->exclude_oid) && task->exclude_matches; + int exclude_invalidated = !exclude_matches; int exclude_revalidated; if (!exclude_matches) @@ -840,8 +927,12 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, update_preloaded_exclude_index_uptodate( preload, task, i, &normalized, &invalidated, &exclude_revalidated); - if (exclude_matches && exclude_revalidated == 0) + if (exclude_matches && exclude_revalidated == 0) { invalidate_gitignore(uc, task->ucd); + exclude_invalidated = 1; + } + if (preload->pathspec && exclude_invalidated) + invalidate_scoped_preloaded_exclude(preload, task); } if (normalized) istate->cache_changed |= UNTRACKED_CHANGED; @@ -864,6 +955,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, trace2_data_intmax( "dir", istate->repo, "preload_untracked_cache/valid", + preload->pathspec ? preload->root->valid_recursive : compute_untracked_cache_fsmonitor_valid_recursive( preload->root)); if (index_invalidated) @@ -3507,7 +3599,7 @@ static int refresh_cached_fsmonitor_files( struct strbuf path = STRBUF_INIT; const char *event, *end; size_t base_len, refreshed = 0; - int valid; + int valid, untracked_changed = 0; if (!uc || !untracked->valid || !untracked->fsmonitor_dirty || !uc->fsmonitor_dirty_paths.len) @@ -3523,6 +3615,7 @@ static int refresh_cached_fsmonitor_files( enum path_treatment state; const char *name; size_t i; + int was_untracked = 0; if (strncmp(event, path.buf, base_len)) goto next; @@ -3538,6 +3631,7 @@ static int refresh_cached_fsmonitor_files( untracked->untracked + i + 1, untracked->untracked_nr - i - 1); untracked->untracked_nr--; + was_untracked = 1; break; } @@ -3552,6 +3646,8 @@ static int refresh_cached_fsmonitor_files( } if (state == path_untracked) add_untracked(untracked, name); + if (was_untracked != (state == path_untracked)) + untracked_changed = 1; refreshed++; next: @@ -3562,6 +3658,10 @@ static int refresh_cached_fsmonitor_files( if (!refreshed || !untracked->valid) return 0; + if (untracked_changed) { + istate->cache_changed |= UNTRACKED_CHANGED; + istate->fsmonitor_untracked_must_persist = 1; + } untracked->fsmonitor_dirty = 0; untracked->has_untracked = !!untracked->untracked_nr; valid = untracked->valid; diff --git a/dir.h b/dir.h index c13d0db2866eaf..bdef37a0c60cf3 100644 --- a/dir.h +++ b/dir.h @@ -639,7 +639,8 @@ void untracked_cache_add_to_index(struct index_state *, const char *); struct untracked_cache_preload; struct untracked_cache_preload * untracked_cache_preload_start_fsmonitor_excludes( - struct index_state *, unsigned int dir_flags); + struct index_state *, unsigned int dir_flags, + const struct pathspec *pathspec); struct untracked_cache_preload *untracked_cache_preload_start_ordinary( struct index_state *, unsigned int dir_flags); int untracked_cache_preload_finish(struct untracked_cache_preload *, diff --git a/fsmonitor-clean-proof.c b/fsmonitor-clean-proof.c index 3c45c014469737..c150f86e208d71 100644 --- a/fsmonitor-clean-proof.c +++ b/fsmonitor-clean-proof.c @@ -22,8 +22,12 @@ int fsmonitor_clean_proof_parse(struct fsmonitor_clean_proof *proof, if (len < FSMONITOR_CLEAN_PROOF_HEADER_WORDS * sizeof(uint32_t) + hashes_len + 1) return -1; - if (get_be32(p) != FSMONITOR_CLEAN_PROOF_VERSION) + parsed.version = get_be32(p); + if (parsed.version != FSMONITOR_CLEAN_PROOF_VERSION_LEGACY && + parsed.version != FSMONITOR_CLEAN_PROOF_VERSION) return -1; + if (parsed.version == FSMONITOR_CLEAN_PROOF_VERSION) + hashes_len += algo->rawsz; p += sizeof(uint32_t); if (get_be32(p) != FSMONITOR_CLEAN_PROOF_MAGIC) return -1; @@ -51,6 +55,10 @@ int fsmonitor_clean_proof_parse(struct fsmonitor_clean_proof *proof, p += algo->rawsz; parsed.attr_hash = p; p += algo->rawsz; + if (parsed.version == FSMONITOR_CLEAN_PROOF_VERSION) { + parsed.tracked_policy_hash = p; + p += algo->rawsz; + } parsed.attr_manifest = p; parsed.attr_manifest_len = manifest_len; p += manifest_len; @@ -82,7 +90,9 @@ int fsmonitor_clean_proof_write(struct strbuf *out, proof->attr_manifest_len, algo)) return -1; - put_be32(&value, FSMONITOR_CLEAN_PROOF_VERSION); + put_be32(&value, proof->tracked_policy_hash ? + FSMONITOR_CLEAN_PROOF_VERSION : + FSMONITOR_CLEAN_PROOF_VERSION_LEGACY); strbuf_add(out, &value, sizeof(value)); put_be32(&value, FSMONITOR_CLEAN_PROOF_MAGIC); strbuf_add(out, &value, sizeof(value)); @@ -96,6 +106,8 @@ int fsmonitor_clean_proof_write(struct strbuf *out, strbuf_add(out, proof->config_hash, algo->rawsz); strbuf_add(out, proof->semantic_hash, algo->rawsz); strbuf_add(out, proof->attr_hash, algo->rawsz); + if (proof->tracked_policy_hash) + strbuf_add(out, proof->tracked_policy_hash, algo->rawsz); strbuf_add(out, proof->attr_manifest, proof->attr_manifest_len); hash_append_checksum(out, algo); return 0; diff --git a/fsmonitor-clean-proof.h b/fsmonitor-clean-proof.h index 0d4da4cd725803..2e2a3c2b252c39 100644 --- a/fsmonitor-clean-proof.h +++ b/fsmonitor-clean-proof.h @@ -5,7 +5,8 @@ struct strbuf; -#define FSMONITOR_CLEAN_PROOF_VERSION 1 +#define FSMONITOR_CLEAN_PROOF_VERSION_LEGACY 1 +#define FSMONITOR_CLEAN_PROOF_VERSION 2 #define FSMONITOR_CLEAN_PROOF_TOKEN_MAX 4096 #define FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE (1u << 0) @@ -19,12 +20,14 @@ struct strbuf; FSMONITOR_CLEAN_PROOF_FULL_INDEX) struct fsmonitor_clean_proof { + uint32_t version; uint32_t flags; const unsigned char *token; size_t token_len; const unsigned char *config_hash; const unsigned char *semantic_hash; const unsigned char *attr_hash; + const unsigned char *tracked_policy_hash; const unsigned char *attr_manifest; size_t attr_manifest_len; }; diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 38f3843bbbb9bb..7c60d7b59193af 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -9,6 +9,7 @@ #include "hash.h" #include "lockfile.h" #include "parse.h" +#include "path.h" #include "simple-ipc.h" #include "fsmonitor-ipc.h" #include "repository.h" @@ -77,6 +78,20 @@ int fsmonitor_ipc__is_supported(void) return 0; } +void fsmonitor_ipc__record_watch_limit_failure( + const char *worktree_identity UNUSED) +{ +} + +void fsmonitor_ipc__clear_watch_limit_failure(void) +{ +} + +int fsmonitor_ipc__watch_limit_backoff(struct repository *r UNUSED) +{ + return 0; +} + const char *fsmonitor_ipc__get_path(struct repository *r UNUSED) { return NULL; @@ -127,6 +142,161 @@ enum ipc_active_state fsmonitor_ipc__get_state(void) #define FSMONITOR_START_TIMEOUT_DEFAULT 60 #define FSMONITOR_RESTART_ATTEMPTS 3 +#if defined(__linux__) || defined(__APPLE__) +#define FSMONITOR_WATCH_LIMIT_MARKER "fsmonitor--daemon.inotify-limit" +#define FSMONITOR_WATCH_LIMIT_MAGIC "inotify-limit-v1\n" +#define FSMONITOR_WATCH_LIMIT_BACKOFF_SECONDS 60 + +static int watch_limit_backoff_enabled(void) +{ +#ifdef __linux__ + return 1; +#else + return git_env_bool("GIT_TEST_FSMONITOR_INOTIFY_BACKOFF", 0); +#endif +} + +static int read_inotify_watch_limit(unsigned long *limit) +{ +#ifdef __linux__ + struct strbuf value = STRBUF_INIT; + int ret = -1; + + if (strbuf_read_file(&value, + "/proc/sys/fs/inotify/max_user_watches", 64) < 0) + goto done; + strbuf_trim(&value); + if (git_parse_ulong(value.buf, limit)) + ret = 0; +done: + strbuf_release(&value); + return ret; +#else + *limit = 0; + return 0; +#endif +} + +void fsmonitor_ipc__record_watch_limit_failure(const char *worktree_identity) +{ + struct lock_file lock = LOCK_INIT; + struct strbuf contents = STRBUF_INIT; + unsigned long limit; + char *path; + int fd; + + if (!watch_limit_backoff_enabled() || !worktree_identity || + strlen(worktree_identity) != FSMONITOR_IPC_WORKTREE_ID_HEX || + read_inotify_watch_limit(&limit)) + return; + path = repo_git_path(the_repository, FSMONITOR_WATCH_LIMIT_MARKER); + fd = hold_lock_file_for_update(&lock, path, LOCK_NO_DEREF); + if (fd < 0) + goto done; + strbuf_addf(&contents, "%s%s\n%lu\n", + FSMONITOR_WATCH_LIMIT_MAGIC, worktree_identity, limit); + if (fchmod(fd, 0600) || + write_in_full(fd, contents.buf, contents.len) != + (ssize_t)contents.len || + commit_lock_file(&lock)) + rollback_lock_file(&lock); +done: + strbuf_release(&contents); + free(path); +} + +void fsmonitor_ipc__clear_watch_limit_failure(void) +{ + char *path; + + if (!watch_limit_backoff_enabled()) + return; + path = repo_git_path(the_repository, FSMONITOR_WATCH_LIMIT_MARKER); + unlink(path); + free(path); +} + +int fsmonitor_ipc__watch_limit_backoff(struct repository *r) +{ + struct strbuf contents = STRBUF_INIT; + struct strbuf identity = STRBUF_INIT; + struct stat st; + const char *recorded_identity, *recorded_limit; + unsigned long limit, current_limit; + time_t now; + char *path, *identity_end, *limit_end; + int fd, ret = 0; + + if (!watch_limit_backoff_enabled()) + return 0; + path = repo_git_path(r, FSMONITOR_WATCH_LIMIT_MARKER); + fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) + goto done; + if (fstat(fd, &st) || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || st.st_nlink != 1 || + (st.st_mode & 077) || st.st_size < 0 || st.st_size > 256) + goto close_fd; + now = time(NULL); + if (now < st.st_mtime || + now - st.st_mtime > FSMONITOR_WATCH_LIMIT_BACKOFF_SECONDS) + goto clear_marker; + if (strbuf_read(&contents, fd, st.st_size) != st.st_size || + !skip_prefix(contents.buf, FSMONITOR_WATCH_LIMIT_MAGIC, + &recorded_identity) || + !(identity_end = strchr(contents.buf + + strlen(FSMONITOR_WATCH_LIMIT_MAGIC), '\n'))) + goto close_fd; + *identity_end = '\0'; + recorded_limit = identity_end + 1; + if (!(limit_end = strchr(identity_end + 1, '\n')) || limit_end[1]) + goto close_fd; + *limit_end = '\0'; + if (!git_parse_ulong(recorded_limit, &limit) || + read_inotify_watch_limit(¤t_limit)) + goto close_fd; + if (limit != current_limit) + goto clear_marker; + if (fsmonitor_ipc__get_worktree_identity(r, &identity)) + goto close_fd; + if (strcmp(recorded_identity, identity.buf)) { +#ifndef __linux__ + if (!git_env_bool("GIT_TEST_FSMONITOR_INOTIFY_BACKOFF", 0) || + strcmp(recorded_identity, "test-worktree")) +#endif + goto close_fd; + } + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) + goto clear_marker; + ret = 1; + goto close_fd; + +clear_marker: + unlink(path); +close_fd: + close(fd); +done: + strbuf_release(&identity); + strbuf_release(&contents); + free(path); + return ret; +} +#else +void fsmonitor_ipc__record_watch_limit_failure( + const char *worktree_identity UNUSED) +{ +} + +void fsmonitor_ipc__clear_watch_limit_failure(void) +{ +} + +int fsmonitor_ipc__watch_limit_backoff(struct repository *r UNUSED) +{ + return 0; +} +#endif + static unsigned int get_start_timeout(void) { const char *value; @@ -194,7 +364,7 @@ static int spawn_daemon(void) } static int try_send_command(const char *command, struct strbuf *answer, - enum ipc_active_state *state_out) + enum ipc_active_state *state_out, int quietly) { struct ipc_client_connection *connection = NULL; struct ipc_client_connect_options options @@ -209,8 +379,12 @@ static int try_send_command(const char *command, struct strbuf *answer, state = ipc_client_try_connect(fsmonitor_ipc__get_path(the_repository), &options, &connection); if (state == IPC_STATE__LISTENING) { - ret = ipc_client_send_command_to_connection( - connection, command, strlen(command), answer); + if (quietly) + ret = ipc_client_send_command_to_connection_gently( + connection, command, strlen(command), answer); + else + ret = ipc_client_send_command_to_connection( + connection, command, strlen(command), answer); ipc_client_close_connection(connection); } @@ -255,12 +429,44 @@ static int server_supports_bound_queries(void) int ret; ret = !try_send_command(FSMONITOR_IPC_CAPABILITY_COMMAND, - &answer, NULL) && + &answer, NULL, 1) && has_capability(&answer, FSMONITOR_IPC_QUERY_VERSION); strbuf_release(&answer); return ret; } +static int server_supports_required_capabilities(void) +{ +#ifdef __APPLE__ + struct strbuf answer = STRBUF_INIT; + int ret; + + ret = !try_send_command(FSMONITOR_IPC_CAPABILITY_COMMAND, + &answer, NULL, 1) && + has_capability(&answer, FSMONITOR_IPC_QUERY_VERSION) && + has_capability(&answer, + FSMONITOR_IPC_DIR_METADATA_CAPABILITY); + strbuf_release(&answer); + return ret; +#else + return server_supports_bound_queries(); +#endif +} + +#ifdef __APPLE__ +static int query_identifies_filtered_daemon(const char *token, + const struct strbuf *answer) +{ + static const char prefix[] = + "builtin:" FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX; + const char *end = memchr(answer->buf, '\0', answer->len); + + return starts_with(token, prefix) && end && + (size_t)(end - answer->buf) >= sizeof(prefix) - 1 && + !memcmp(answer->buf, prefix, sizeof(prefix) - 1); +} +#endif + #if defined(__APPLE__) || defined(__linux__) static int legacy_peer_credentials( struct ipc_client_connection *connection, pid_t *pid) @@ -518,7 +724,7 @@ static int try_send_attested_legacy_query( trace2_data_intmax("fsm_client", NULL, cached ? "query/legacy-peer-cached" : "query/legacy-peer-authenticated", 1); - ret = ipc_client_send_command_to_connection( + ret = ipc_client_send_command_to_connection_gently( connection, token, strlen(token), answer); done: ipc_client_close_connection(connection); @@ -581,7 +787,7 @@ static int restart_incompatible_daemon(void) if (hold_lock_file_for_update_timeout(&restart_lock, lock_path.buf, LOCK_NO_DEREF, lock_timeout_ms) < 0) { - if (server_supports_bound_queries()) + if (server_supports_required_capabilities()) ret = 0; goto done; } @@ -595,18 +801,18 @@ static int restart_incompatible_daemon(void) int wait_result; /* Another client may have replaced the daemon while we waited. */ - if (server_supports_bound_queries()) + if (server_supports_required_capabilities()) goto success; if (!lstat(fsmonitor_ipc__get_path(the_repository), &socket_stat)) original_socket = &socket_stat; - if (try_send_command("quit", &answer, NULL)) { + if (try_send_command("quit", &answer, NULL, 1)) { /* * The failed connection may already have been replaced. * Re-read its state before abandoning the upgrade. */ if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) { - if (server_supports_bound_queries()) + if (server_supports_required_capabilities()) ret = 0; goto done; } @@ -640,6 +846,39 @@ static int restart_incompatible_daemon(void) return ret; } +#ifdef __APPLE__ +static int spawn_daemon_serialized(void) +{ + struct strbuf lock_path = STRBUF_INIT; + struct lock_file restart_lock = LOCK_INIT; + uintmax_t timeout_ms = (uintmax_t)get_start_timeout() * 1000; + long lock_timeout_ms = timeout_ms > LONG_MAX ? + LONG_MAX : (long)timeout_ms; + int have_lock = 0; + int ret = -1; + + strbuf_addf(&lock_path, "%s.restart", + fsmonitor_ipc__get_path(the_repository)); + if (hold_lock_file_for_update_timeout(&restart_lock, lock_path.buf, + LOCK_NO_DEREF, + lock_timeout_ms) < 0) { + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING) + ret = 0; + goto done; + } + have_lock = 1; + if (fsmonitor_ipc__get_state() == IPC_STATE__LISTENING || + !spawn_daemon()) + ret = 0; + +done: + if (have_lock) + rollback_lock_file(&restart_lock); + strbuf_release(&lock_path); + return ret; +} +#endif + int fsmonitor_ipc__send_query(const char *since_token, struct strbuf *answer, int *legacy_worktree_authenticated) @@ -679,13 +918,32 @@ int fsmonitor_ipc__send_query(const char *since_token, switch (state) { case IPC_STATE__LISTENING: - ret = ipc_client_send_command_to_connection( + ret = ipc_client_send_command_to_connection_gently( connection, command.buf, command.len, answer); ipc_client_close_connection(connection); connection = NULL; + if (ret && lifecycle_attempts++ < FSMONITOR_RESTART_ATTEMPTS) { + trace2_data_intmax("fsm_client", NULL, + "query/reconnect-after-failed-send", 1); + /* Let a missing daemon enter normal startup without polling. */ + options.wait_if_not_found = 0; + goto try_again; + } trace2_data_intmax("fsm_client", NULL, "query/response-length", answer->len); +#ifdef __APPLE__ + if (!ret && !query_identifies_filtered_daemon(tok, answer) && + !server_supports_required_capabilities()) { + strbuf_reset(answer); + ret = -1; + if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS || + restart_incompatible_daemon()) + goto done; + options.wait_if_not_found = 1; + goto try_again; + } +#endif if (!ret && is_trivial_response(answer) && !server_supports_bound_queries()) { if (!try_send_attested_legacy_query( @@ -716,7 +974,11 @@ int fsmonitor_ipc__send_query(const char *since_token, if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS) goto done; +#ifdef __APPLE__ + if (spawn_daemon_serialized()) +#else if (spawn_daemon()) +#endif goto done; /* @@ -756,7 +1018,7 @@ int fsmonitor_ipc__send_command(const char *command, { enum ipc_active_state state; const char *c = command ? command : ""; - int ret = try_send_command(c, answer, &state); + int ret = try_send_command(c, answer, &state, 0); if (state != IPC_STATE__LISTENING) { die(_("fsmonitor--daemon is not running")); diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index daddca5b67fc9b..d52fcf8cf96f2f 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -8,12 +8,19 @@ struct repository; #define FSMONITOR_IPC_QUERY_VERSION "query-v1" #define FSMONITOR_IPC_QUERY_PREFIX FSMONITOR_IPC_QUERY_VERSION " " #define FSMONITOR_IPC_CAPABILITY_COMMAND "get-capabilities" +#define FSMONITOR_IPC_DIR_METADATA_CAPABILITY "dir-metadata-filter-v1" +#define FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX "dirmeta-v1." #define FSMONITOR_IPC_WORKTREE_ID_HEX 64 /* Hash the canonical worktree root and its stable filesystem identity. */ int fsmonitor_ipc__get_worktree_identity(struct repository *r, struct strbuf *identity); +/* Remember a bounded, worktree-specific inotify watch-limit failure. */ +void fsmonitor_ipc__record_watch_limit_failure(const char *worktree_identity); +void fsmonitor_ipc__clear_watch_limit_failure(void); +int fsmonitor_ipc__watch_limit_backoff(struct repository *r); + /* * Returns true if built-in file system monitor daemon is defined * for this platform. diff --git a/fsmonitor-settings.c b/fsmonitor-settings.c index a6587a8972b184..a0c12533413013 100644 --- a/fsmonitor-settings.c +++ b/fsmonitor-settings.c @@ -5,6 +5,7 @@ #include "fsmonitor-ipc.h" #include "fsmonitor-settings.h" #include "fsmonitor-path-utils.h" +#include "trace2.h" /* * We keep this structure definition private and have getters @@ -119,7 +120,11 @@ static void lookup_fsmonitor_settings(struct repository *r) switch (repo_config_get_maybe_bool(r, "core.fsmonitor", &bool_value)) { case 0: /* config value was set to */ - if (bool_value) + if (bool_value && fsmonitor_ipc__watch_limit_backoff(r)) { + trace2_data_intmax("fsm_client", r, + "settings/inotify-watch-limit-backoff", 1); + fsm_settings__set_disabled(r); + } else if (bool_value) fsm_settings__set_ipc(r); else fsm_settings__set_disabled(r); diff --git a/fsmonitor.c b/fsmonitor.c index 795109d1169c3e..ed4c7d6324ae78 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -4,6 +4,7 @@ #include "git-compat-util.h" #include "attr.h" #include "clean-status.h" +#include "clean-status-manifest.h" #include "config.h" #include "dir.h" #include "environment.h" @@ -647,7 +648,8 @@ static size_t handle_path_with_trailing_slash( nr_in_cone++; } - if (nr_in_cone) { + if (nr_in_cone && + !clean_status_directory_event_is_semantically_safe(istate, name)) { /* * A matched directory event may stand in for a nested * attribute-file change. @@ -665,6 +667,7 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) int len = strlen(name); int pos; int attributes_may_have_changed; + int directory_is_semantically_safe; size_t nr_in_cone; trace_printf_key(&trace_fsmonitor, @@ -685,14 +688,30 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) return; } pos = index_name_pos(istate, name, len); - attributes_may_have_changed = - fsmonitor_invalidate_attributes_path(istate, name); + if (pos >= 0 && + clean_status_manifest_reconcile_deleted_attribute(istate, name)) { + attributes_may_have_changed = 0; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/attribute-source-reused", 1); + } else if (pos >= 0 && + clean_status_manifest_accept_current_display_only_attribute( + istate, name)) { + git_attr_invalidate_all(); + attributes_may_have_changed = 0; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/nonconversion-attribute-replayed", 1); + } else { + attributes_may_have_changed = + fsmonitor_invalidate_attributes_path(istate, name); + } + directory_is_semantically_safe = name[len - 1] == '/' && + clean_status_directory_event_is_semantically_safe(istate, name); if (name[len - 1] == '/') nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); else nr_in_cone = handle_path_without_trailing_slash(istate, name, pos); - if (pos < 0 && nr_in_cone) + if (pos < 0 && nr_in_cone && !directory_is_semantically_safe) attributes_may_have_changed = 1; /* @@ -1029,7 +1048,7 @@ void fsmonitor_invalidate_semantics(struct index_state *istate) static void invalidate_fsmonitor_for_bootstrap( struct index_state *istate, enum fsmonitor_mode mode, int semantic_adoption_needed, int semantic_baseline_needed, - int physical_history_unavailable) + int physical_history_unavailable, int provider_query_success) { int manifest_refresh_failed; @@ -1046,13 +1065,29 @@ static void invalidate_fsmonitor_for_bootstrap( "semantic/legacy-stat-fallback", 1); return; } - clean_status_refresh_worktree_manifest(istate); - fsmonitor_invalidate_semantics(istate); + manifest_refresh_failed = + !clean_status_has_authenticated_bootstrap_manifest(istate) && + clean_status_refresh_worktree_manifest(istate) < 0; + if (provider_query_success && !manifest_refresh_failed && + !clean_status_manifest_global_fallback(istate) && + !clean_status_fsmonitor_strong_mismatch(istate) && + !clean_status_filter_scope_needs_validation(istate) && + istate->repo->config_values_private_.trust_ctime && + istate->repo->config_values_private_.check_stat) { + /* Strong stat identity survives a lost provider boundary. */ + clean_status_begin_fsmonitor_semantic_baseline(istate); + invalidate_all_fsmonitor_for_baseline(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/token-reset-stat-baseline", 1); + } else { + fsmonitor_invalidate_semantics(istate); + } untracked_cache_invalidate_all(istate); return; } manifest_refresh_failed = + !clean_status_has_authenticated_worktree_manifest(istate) && clean_status_refresh_worktree_manifest(istate) < 0; if (manifest_refresh_failed || clean_status_manifest_global_fallback(istate) || @@ -1276,8 +1311,15 @@ void refresh_fsmonitor(struct index_state *istate) */ if (fstat_is_reliable() && !istate->split_index && fsm_mode == FSMONITOR_MODE_IPC && - clean_status_fsmonitor_config_mismatch(istate)) - tracked_requires_bootstrap = 1; + clean_status_fsmonitor_config_mismatch(istate)) { + if (clean_status_try_preserve_tracked_config_epoch(istate)) { + tracked_requires_bootstrap = 0; + trace2_data_intmax("fsmonitor", istate->repo, + "config/tracked-epoch-preserved", 1); + } else { + tracked_requires_bootstrap = 1; + } + } if (tracked_requires_bootstrap) { /* @@ -1298,7 +1340,7 @@ void refresh_fsmonitor(struct index_state *istate) invalidate_fsmonitor_for_bootstrap( istate, fsm_mode, semantic_adoption_needed, semantic_baseline_needed, - !istate->fsmonitor_token_valid); + !istate->fsmonitor_token_valid, query_success); } /* Now mark the untracked cache for fsmonitor usage */ @@ -1324,7 +1366,7 @@ void refresh_fsmonitor(struct index_state *istate) */ invalidate_fsmonitor_for_bootstrap( istate, fsm_mode, semantic_adoption_needed, - semantic_baseline_needed, 1); + semantic_baseline_needed, 1, query_success); } trace2_region_leave("fsmonitor", "apply_results", istate->repo); diff --git a/preload-index-bulk-index.c b/preload-index-bulk-index.c index d4691d07678bd4..3ff1b4879204a4 100644 --- a/preload-index-bulk-index.c +++ b/preload-index-bulk-index.c @@ -1,4 +1,6 @@ #include "git-compat-util.h" +#include "clean-status.h" +#include "fsmonitor.h" #include "name-hash.h" #include "object.h" #include "preload-index-bulk.h" @@ -176,6 +178,13 @@ void preload_bulk_record_tracked( if (!tracked_entry_is_eligible(ce)) return; + if (clean_status_fsmonitor_semantic_baseline_pending(scan->istate) && + !fsmonitor_stat_can_be_valid(st)) { + if (record_tracked_state(worker, pos, + PRELOAD_BULK_TRACKED_CONTENT_CHECK)) + fsmonitor_invalidate_cache_entry(ce); + return; + } changed = ie_match_stat( scan->istate, ce, (struct stat *)st, CE_MATCH_RACY_IS_DIRTY | CE_MATCH_IGNORE_FSMONITOR); diff --git a/preload-index.c b/preload-index.c index 1bea4a5d3f3b66..95ebfff1d46787 100644 --- a/preload-index.c +++ b/preload-index.c @@ -7,6 +7,7 @@ #include "git-compat-util.h" #include "pathspec.h" #include "dir.h" +#include "clean-status.h" #include "environment.h" #include "fsmonitor.h" #include "gettext.h" @@ -113,6 +114,12 @@ static void *preload_thread(void *_data) p->t2_nr_lstat++; if (lstat(ce->name, &st)) continue; + if (clean_status_fsmonitor_semantic_baseline_pending(index) && + !fsmonitor_stat_can_be_valid(&st)) { + /* An unwatched hard-link alias can evade coarse stat identity. */ + fsmonitor_invalidate_cache_entry(ce); + continue; + } if (ie_match_stat(index, ce, &st, CE_MATCH_RACY_IS_DIRTY|CE_MATCH_IGNORE_FSMONITOR)) continue; ce_mark_uptodate(ce); diff --git a/read-cache-ll.h b/read-cache-ll.h index 2051bbd974e442..8d637a558cb717 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -191,6 +191,7 @@ struct index_state { fsmonitor_token_valid : 1, fsmonitor_extension_seen : 1, fsmonitor_untracked_valid : 1, + fsmonitor_untracked_must_persist : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, fsmonitor_legacy_untracked_adopted : 1, diff --git a/read-cache.c b/read-cache.c index 91575f07b5da69..985cadedce6f25 100644 --- a/read-cache.c +++ b/read-cache.c @@ -777,7 +777,7 @@ void set_object_name_for_intent_to_add_entry(struct cache_entry *ce) int add_to_index(struct index_state *istate, const char *path, struct stat *st, int flags) { - int namelen, was_same, logical_same; + int namelen, was_same, logical_same, semantic_same; int cache_nr = istate->cache_nr; mode_t st_mode = st->st_mode; struct cache_entry *ce, *alias = NULL; @@ -864,9 +864,11 @@ int add_to_index(struct index_state *istate, const char *path, struct stat *st, oideq(&alias->oid, &ce->oid) && ce->ce_mode == alias->ce_mode); logical_same = same_persistent_add_entry(alias, ce); + semantic_same = clean_status_index_entry_is_semantically_safe( + istate, alias, ce); if (!pretend && (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && - !logical_same) + !logical_same && !semantic_same) clean_status_invalidate_current_proof(istate); if (pretend) @@ -877,7 +879,7 @@ int add_to_index(struct index_state *istate, const char *path, struct stat *st, return error(_("unable to add '%s' to index"), path); } if ((flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && - cache_nr != istate->cache_nr) + cache_nr != istate->cache_nr && !semantic_same) clean_status_invalidate_current_proof(istate); } if (verbose && !was_same) @@ -1471,6 +1473,10 @@ static struct cache_entry *refresh_cache_ent(struct index_state *istate, *err = errno; return NULL; } + if (clean_status_fsmonitor_semantic_baseline_pending(istate) && + !fsmonitor_stat_can_be_valid(&st) && + !(ce->ce_flags & CE_CONTENT_CHECK_REQUIRED)) + fsmonitor_invalidate_cache_entry(ce); changed = ie_match_stat(istate, ce, &st, options); if (changed_ret) @@ -4287,7 +4293,14 @@ static void update_callback(struct diff_queue_struct *q, if (data->flags & ADD_CACHE_IGNORE_REMOVAL) break; if (!(data->flags & ADD_CACHE_PRETEND)) { - if (data->flags & ADD_CACHE_TRACK_CLEAN_HISTORY) + int pos = index_name_pos(data->index, path, + strlen(path)); + + if ((data->flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && + !clean_status_index_entry_is_semantically_safe( + data->index, + pos >= 0 ? data->index->cache[pos] : NULL, + NULL)) clean_status_invalidate_current_proof( data->index); remove_file_from_index(data->index, path); diff --git a/simple-ipc.h b/simple-ipc.h index 701e005cb8e5f3..cc7471c97624eb 100644 --- a/simple-ipc.h +++ b/simple-ipc.h @@ -106,6 +106,15 @@ int ipc_client_send_command_to_connection( const char *message, size_t message_len, struct strbuf *answer); +/* + * Like ipc_client_send_command_to_connection(), but suppress the generic IPC + * transport diagnostic so callers can recover from a disappearing server. + */ +int ipc_client_send_command_to_connection_gently( + struct ipc_client_connection *connection, + const char *message, size_t message_len, + struct strbuf *answer); + /* * Used by the client to synchronously connect and send and receive a * message to the server listening at the given path. diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index 3be92e4fbd01ca..d06b94b01d41a4 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -161,6 +161,8 @@ static int app__sendbytes_command(const char *received, size_t received_len, static int my_app_data = 42; static int fsmonitor_legacy; static int fsmonitor_capability_superset; +static int fsmonitor_pre_dir_metadata; +static int fsmonitor_disconnect_first; static ipc_server_application_cb test_app_cb; @@ -170,7 +172,12 @@ static int app__fsmonitor_capability_superset( struct ipc_server_reply_data *reply_data) { static const char capability_command[] = "get-capabilities"; - static const char capabilities[] = "query-v1\nquery-v2\n"; + static const char capabilities[] = "query-v1\nquery-v2\n" +#ifdef __APPLE__ + "dir-metadata-filter-v1\n" +#endif + ; + static const char pre_dir_metadata_capabilities[] = "query-v1\n"; static const char query_prefix[] = "query-v1 "; static const char token[] = "builtin:test-capable:0"; const char *query; @@ -178,9 +185,14 @@ static int app__fsmonitor_capability_superset( int ret; if (command_len == sizeof(capability_command) - 1 && - !memcmp(command, capability_command, command_len)) + !memcmp(command, capability_command, command_len)) { + if (fsmonitor_pre_dir_metadata) + return reply_cb(reply_data, + pre_dir_metadata_capabilities, + sizeof(pre_dir_metadata_capabilities) - 1); return reply_cb(reply_data, capabilities, sizeof(capabilities) - 1); + } query = memchr(command, '\n', command_len); query_len = query ? command_len - (query + 1 - command) : 0; @@ -212,6 +224,10 @@ static int test_app_cb(void *application_data, if (application_data != (void*)&my_app_data) BUG("application_cb: application_data pointer wrong"); + /* Exit before the server can flush a response to this bound query. */ + if (fsmonitor_disconnect_first && starts_with(command, "query-v1 ")) + _exit(0); + if (command_len == 4 && !strncmp(command, "quit", 4)) { /* * The client sent a "quit" command. This is an async @@ -232,7 +248,7 @@ static int test_app_cb(void *application_data, return SIMPLE_IPC_QUIT; } - if (fsmonitor_capability_superset) + if (fsmonitor_capability_superset || fsmonitor_pre_dir_metadata) return app__fsmonitor_capability_superset( command, command_len, reply_cb, reply_data); @@ -359,6 +375,10 @@ static int daemon__start_server(void) strvec_push(&cp.args, "--fsmonitor-legacy"); if (fsmonitor_capability_superset) strvec_push(&cp.args, "--fsmonitor-capability-superset"); + if (fsmonitor_pre_dir_metadata) + strvec_push(&cp.args, "--fsmonitor-pre-dir-metadata"); + if (fsmonitor_disconnect_first) + strvec_push(&cp.args, "--fsmonitor-disconnect-first"); cp.no_stdin = 1; cp.no_stdout = 1; @@ -656,6 +676,12 @@ int cmd__simple_ipc(int argc, const char **argv) OPT_BOOL(0, "fsmonitor-capability-superset", &fsmonitor_capability_superset, N_("advertise multiple fsmonitor query versions")), + OPT_BOOL(0, "fsmonitor-pre-dir-metadata", + &fsmonitor_pre_dir_metadata, + N_("emulate a daemon without directory metadata filtering")), + OPT_BOOL(0, "fsmonitor-disconnect-first", + &fsmonitor_disconnect_first, + N_("disconnect while handling the first fsmonitor query")), /* * The "byte" string here is not marked for translation and diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index e6eb39f5c93b6b..187c70e2ddd135 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -196,6 +196,7 @@ test_expect_success UNTRACKED_CACHE \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime-fsmonitor && test_must_be_empty .git/prime-fsmonitor && @@ -246,6 +247,7 @@ test_expect_success UNTRACKED_CACHE,HARDLINKS \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime-fsmonitor && test_must_be_empty .git/prime-fsmonitor && @@ -398,6 +400,7 @@ test_expect_success UNTRACKED_CACHE \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain >.git/prime-fsmonitor && test_must_be_empty .git/prime-fsmonitor && @@ -407,6 +410,7 @@ test_expect_success UNTRACKED_CACHE \ .git/settle && GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ git status >.git/clean && @@ -621,9 +625,9 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TRACE2_EVENT="$PWD/.git/exact.trace" \ git status --porcelain=v2 >.git/exact && test_must_be_empty .git/exact && - ! test_trace2_data status fsmonitor/tracked-clean 1 \ + test_trace2_data status fsmonitor/tracked-clean 1 \ <.git/exact.trace && - test_grep \ + test_grep ! \ "\"category\":\"index\",\"label\":\"refresh\"" \ .git/exact.trace && @@ -679,6 +683,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep UNTR .git/index && test_grep ! FSUC .git/index && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && @@ -748,6 +753,7 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ( cd builtin-initial-trivial && sane_unset GIT_TEST_SPLIT_INDEX && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TC \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 \ @@ -818,14 +824,14 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ cd builtin-closure-error && sane_unset GIT_TEST_SPLIT_INDEX && test_write_lines visible >visible && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCE \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CE \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && test_grep "^? visible$" .git/actual && test_grep \ "\"event\":\"region_enter\".*\"category\":\"dir\",\"label\":\"read_directory\"" \ .git/status.trace >.git/read-directory && - test_line_count = 2 .git/read-directory && + test_line_count -ge 1 .git/read-directory && test_trace2_data fsmonitor token_closure/rejected 1 \ <.git/status.trace && ! test_trace2_data fsmonitor token_closure/accepted 1 \ @@ -1552,7 +1558,7 @@ test_expect_success HARDLINKS,!MINGW,!CYGWIN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'second closing-query change reprimes untracked cache' ' + 'second closing-query change preserves verified sibling subtrees' ' test_when_finished "rm -rf second-query-changed" && test_create_repo second-query-changed && ( @@ -1563,14 +1569,24 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_write_lines "*.ignored" >cached/.gitignore && printf "aaaa\n" >cached/tracked && test_write_lines ignored >cached/junk.ignored && - git add .gitignore cached/.gitignore cached/tracked && + for sibling in $(test_seq 1 12) + do + mkdir "sibling-$sibling" && + test-tool genrandom "sibling-$sibling" 4096 \ + >"sibling-$sibling/tracked" && + test_write_lines ignored \ + >"sibling-$sibling/retained.ignored" || return 1 + done && + git add .gitignore cached/.gitignore cached/tracked sibling-* && git commit -m base && git config core.trustctime false && git config core.checkStat minimal && git config core.untrackedCache true && + test_write_lines visible >sibling-1/visible && git -c core.fsmonitor=false status --porcelain=v2 \ >.git/prime && - test_must_be_empty .git/prime && + test_line_count = 1 .git/prime && + test_grep "^? sibling-1/visible$" .git/prime && test-tool chmtime =-60 cached/tracked && git update-index --refresh && mtime=$(test-tool chmtime --get cached/tracked) && @@ -1582,13 +1598,20 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor-valid cached/tracked && test_grep ! FSCF .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.trustctime=true -c core.checkStat=default \ + status --porcelain=v2 >.git/expect && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ GIT_TEST_FSMONITOR_QUERY_PATH=cached/tracked \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + GIT_TRACE2_PERF="$PWD/.git/status.perf" \ git status --porcelain=v2 >.git/actual && - test_line_count = 1 .git/actual && + test_cmp .git/expect .git/actual && + test_line_count = 2 .git/actual && test_grep "^1 \.M .* cached/tracked$" .git/actual && + test_grep "^? sibling-1/visible$" .git/actual && test_trace2_data status fsmonitor_token/semantic-closed 1 \ <.git/status.trace && test_trace2_data status \ @@ -1596,6 +1619,37 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/status.trace && test_trace2_data fsmonitor token_closure/apply_count 1 \ <.git/status.trace && + test_trace2_data status \ + fsmonitor_token/reused-semantic-subtrees 1 \ + <.git/status.trace && + test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/status.trace >.git/strong-invalidations && + test_line_count = 1 .git/strong-invalidations && + sed -n \ + "s/.*directories-visited:\\([0-9][0-9]*\\).*/\\1/p" \ + .git/status.perf >.git/visited && + test_line_count = 2 .git/visited && + initial_visited=$(sed -n 1p .git/visited) && + retry_visited=$(sed -n 2p .git/visited) && + test "$initial_visited" -gt 8 && + test "$retry_visited" -lt 4 && + sed -n \ + "s/.*paths-visited:\\([0-9][0-9]*\\).*/\\1/p" \ + .git/status.perf >.git/visited-paths && + test_line_count = 2 .git/visited-paths && + initial_paths=$(sed -n 1p .git/visited-paths) && + retry_paths=$(sed -n 2p .git/visited-paths) && + test "$retry_paths" -lt "$initial_paths" && + sed -n "s/.*opendir:\\([0-9][0-9]*\\).*/\\1/p" \ + .git/status.perf >.git/opened && + test_line_count = 2 .git/opened && + initial_opened=$(sed -n 1p .git/opened) && + retry_opened=$(sed -n 2p .git/opened) && + test "$initial_opened" -gt 8 && + test $((retry_opened - initial_opened)) -gt 0 && + test $((retry_opened - initial_opened)) -le 2 && + test_trace2_data index refresh/sum_lstat "[0-2]" \ + <.git/status.trace && test_trace2_data status \ fsmonitor_token/untracked-after-retry 1 \ <.git/status.trace && @@ -1606,6 +1660,472 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success MACOS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'plumbing diffs restore clean history lost by a foreign index writer' ' + test_when_finished "rm -rf plumbing-diff-history" && + test_create_repo plumbing-diff-history && + ( + cd plumbing-diff-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for prime in first second third + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime || return 1 + done && + test_must_be_empty .git/prime && + test_path_is_file .git/index.csts && + find .git -maxdepth 1 -type f -name "index.csh1.*" \ + >.git/checkpoints && + test_line_count = 1 .git/checkpoints && + + rm .git/index && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + read-tree HEAD && + test_grep ! FSMN .git/index && + cp .git/index .git/index.before && + + for diff_case in files index cached describe + do + case "$diff_case" in + files) set -- diff-files ;; + index) set -- diff-index HEAD -- ;; + cached) set -- diff-index --cached HEAD -- ;; + describe) set -- describe --dirty --tags ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$diff_case.trace" \ + git "$@" >".git/$diff_case.actual" && + if test "$diff_case" = describe + then + test_grep "^base$" ".git/$diff_case.actual" && + test_trace2_data index refresh/sum_lstat 0 \ + <".git/$diff_case.trace" + else + test_must_be_empty ".git/$diff_case.actual" + fi && + test_cmp_bin .git/index.before .git/index && + test_trace2_data fsmonitor history/external-restored 1 \ + <".git/$diff_case.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <".git/$diff_case.trace" && + test_grep ! "\"label\":\"do_write_index\"" \ + ".git/$diff_case.trace" || return 1 + done && + + test_write_lines changed >tracked && + for diff_case in files index describe + do + case "$diff_case" in + files) set -- diff-files -p ;; + index) set -- diff-index -p HEAD -- ;; + describe) set -- describe --dirty --tags ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/$diff_case-dirty.trace" \ + git "$@" >".git/$diff_case-dirty.actual" && + if test "$diff_case" = describe + then + test_grep "^base-dirty$" \ + ".git/$diff_case-dirty.actual" + else + test_grep "^+changed$" \ + ".git/$diff_case-dirty.actual" + fi && + test_trace2_data fsmonitor history/external-restored 1 \ + <".git/$diff_case-dirty.trace" && + test_cmp_bin .git/index.before .git/index || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'global second closing-query change rejects verified subtree reuse' ' + test_when_finished "rm -rf second-query-global" && + test_create_repo second-query-global && + ( + cd second-query-global && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached untouched && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + printf "aaaa\n" >cached/tracked && + printf "untouched\n" >untouched/tracked && + test_write_lines ignored >cached/junk.ignored && + git add .gitignore cached/.gitignore cached/tracked \ + untouched/tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + test-tool chmtime =-60 cached/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get cached/tracked) && + printf "bbbb\n" >cached/tracked && + test-tool chmtime =$mtime cached/tracked && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid cached/tracked && + test_grep ! FSCF .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.trustctime=true -c core.checkStat=default \ + status --porcelain=v2 >.git/expect && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_line_count = 1 .git/actual && + test_grep "^1 \.M .* cached/tracked$" .git/actual && + test_trace2_data fsmonitor apply/global-invalidation 1 \ + <.git/status.trace && + test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/status.trace >.git/strong-invalidations && + test_line_count = 2 .git/strong-invalidations && + ! test_trace2_data status \ + fsmonitor_token/reused-semantic-subtrees 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index + ) +' + +test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'foreign index writers preserve unchanged worktree semantics' ' + test_when_finished "rm -rf foreign-semantic-history" && + test_when_finished \ + "git -C foreign-semantic-history fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo foreign-semantic-history && + ( + cd foreign-semantic-history && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir existing && + test_commit base existing/tracked && + test_commit retained existing/retained && + test-tool chmtime -120 existing/tracked existing/retained && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git update-index --fsmonitor && + for prime in first second third + do + git status --porcelain=v2 >.git/prime || return 1 + done && + find .git -maxdepth 1 -type f -name "index.cswi.*" \ + >.git/witnesses && + test_line_count = 1 .git/witnesses && + cat >.git/foreign-writer.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $name = $ARGV[0]; + my $rawsz = $name eq "sha256" ? 32 : 20; + for my $extension ("FSUC", "FSCF") { + my $offset = index($index, $extension); + next if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, + $name eq "sha256" ? sha256($payload) : sha1($payload); + EOF + + test_write_lines changed >existing/tracked && + git update-index --no-fsmonitor-valid existing/retained && + git update-index --add existing/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + test_grep ! FSCF .git/index && + git -c core.fsmonitor=false --no-optional-locks \ + status --porcelain=v2 >.git/existing.expect && + test_grep "^1 M\. .* existing/tracked$" .git/existing.expect && + GIT_TRACE2_EVENT="$PWD/.git/existing.trace" \ + git status --porcelain=v2 >.git/existing && + test_grep "^1 M\. .* existing/tracked$" .git/existing && + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 <.git/existing.trace && + test_trace2_data fsmonitor \ + history/external-untracked-restored 1 <.git/existing.trace && + test_trace2_data fsmonitor \ + history/external-tracked-restored 1 <.git/existing.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/existing.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/existing.trace && + + mkdir newdir && + test_write_lines new >newdir/tracked && + git update-index --add newdir/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + test_grep ! FSCF .git/index && + GIT_TRACE2_EVENT="$PWD/.git/newdir.trace" \ + git status --porcelain=v2 >.git/newdir && + test_grep "^1 A\. .* newdir/tracked$" .git/newdir && + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 <.git/newdir.trace && + test_trace2_data fsmonitor \ + history/external-untracked-restored 1 <.git/newdir.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/newdir.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/newdir.trace && + + mkdir existing/retired-directory && + test_write_lines transient >existing/retired-directory/file && + rm existing/retired-directory/file && + rmdir existing/retired-directory && + test_write_lines changed-again >existing/tracked && + git update-index --add existing/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + GIT_TRACE2_EVENT="$PWD/.git/retired.trace" \ + git status --porcelain=v2 >.git/retired && + test_grep "^1 M\. .* existing/tracked$" .git/retired && + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 <.git/retired.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <.git/retired.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/retired.trace && + + test_write_lines "* text" >existing/.gitattributes && + git update-index --add existing/.gitattributes && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git status --porcelain=v2 >.git/attributes && + test_grep "^1 A\. .* existing/.gitattributes$" \ + .git/attributes && + ! test_trace2_data fsmonitor \ + history/external-semantic-restored <.git/attributes.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/attributes.trace && + + mkdir guarded && + test_write_lines "* text" >guarded/.gitattributes && + test_write_lines guarded >guarded/tracked && + git update-index --add guarded/tracked && + perl .git/foreign-writer.pl "$(test_oid algo)" \ + <.git/index >.git/index.foreign && + mv .git/index.foreign .git/index && + GIT_TRACE2_EVENT="$PWD/.git/guarded.trace" \ + git status --porcelain=v2 >.git/guarded && + test_grep "^1 A\. .* guarded/tracked$" .git/guarded && + ! test_trace2_data fsmonitor \ + history/external-semantic-restored <.git/guarded.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/guarded.trace + ) +' + +test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'branch switches preserve existing authenticated index proofs' ' + test_when_finished "rm -rf switch-authenticated-history" && + test_when_finished \ + "git -C switch-authenticated-history fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo switch-authenticated-history && + ( + cd switch-authenticated-history && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p api/existing && + test_write_lines "*.txt text" >api/.gitattributes && + test_write_lines base >api/existing/tracked && + for sibling in $(test_seq 1 24) + do + mkdir "api/sibling-$sibling" && + test_write_lines retained \ + >"api/sibling-$sibling/tracked" || return 1 + done && + git add api && + git commit -m base && + initial_branch=$(git symbolic-ref --short HEAD) && + git switch -c replace-only && + test_write_lines replacement >api/existing/tracked && + git add api/existing/tracked && + git commit -m replacement && + git switch "$initial_branch" && + git switch -c alternate && + mkdir api/new-directory && + mkdir api/new-directory/__pycache__ && + test_write_lines existing >api/existing/added.txt && + test_write_lines new >api/new-directory/added.txt && + test_write_lines ignored \ + >api/new-directory/__pycache__/hidden.pyc && + git config core.excludesFile "$PWD/.git/test-excludes" && + test_write_lines "__pycache__/" >.git/test-excludes && + git add api/existing/added.txt api/new-directory/added.txt && + git commit -m alternate && + git switch "$initial_branch" && + test-tool chmtime -120 api/.gitattributes api/existing/tracked \ + api/sibling-*/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config index.recordEndOfIndexEntries false && + git config core.untrackedCache true && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git update-index --fsmonitor && + for prime in first second third + do + git status --porcelain=v2 >.git/prime || return 1 + done && + test_must_be_empty .git/prime && + find .git -maxdepth 1 -type f -name "index.cswi.*" \ + >.git/witnesses && + test_line_count = 1 .git/witnesses && + for branch in replace-only "$initial_branch" \ + alternate "$initial_branch" + do + GIT_TRACE2_EVENT="$PWD/.git/switch-$branch.trace" \ + GIT_TRACE2_EVENT_NESTING=10 \ + git switch "$branch" && + test_trace2_data fsmonitor history/semantic-transferred 1 \ + <".git/switch-$branch.trace" && + if test "$branch" = alternate + then + test_trace2_data fsmonitor \ + history/untracked-paired-new-directory-deferred 1 \ + <".git/switch-$branch.trace" && + test_grep ! FSUC .git/index + else + test_trace2_data fsmonitor \ + history/untracked-paired-transfer 1 \ + <".git/switch-$branch.trace" && + test_grep FSUC .git/index + fi && + git -c core.fsmonitor=false --no-optional-locks \ + status --porcelain=v2 >.git/expect && + GIT_TRACE2_EVENT="$PWD/.git/status-$branch.trace" \ + GIT_TRACE2_EVENT_NESTING=10 \ + GIT_TRACE2_PERF="$PWD/.git/status-$branch.perf" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <".git/status-$branch.trace" && + ! test_trace2_data fsmonitor config/invalid-extension 1 \ + <".git/status-$branch.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + <".git/status-$branch.trace" && + ! test_trace2_data index preload/bulk_useful \ + <".git/status-$branch.trace" && + if test "$branch" = replace-only + then + test_trace2_data fsmonitor \ + checkout/untracked-replacement-targeted 1 \ + <".git/switch-$branch.trace" && + visited_dirs=$(sed -n \ + "s/.*directories-visited:\\([0-9][0-9]*\\).*/\\1/p" \ + ".git/status-$branch.perf") && + test "$visited_dirs" -lt 12 + elif test "$branch" = alternate + then + test_trace2_data fsmonitor \ + history/external-untracked-restored 1 \ + <".git/status-$branch.trace" && + visited_dirs=$(sed -n \ + "s/.*directories-visited:\\([0-9][0-9]*\\).*/\\1/p" \ + ".git/status-$branch.perf") && + test "$visited_dirs" -lt 12 + fi || return 1 + done && + + cat >.git/duplicate-fscf.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $name = $ARGV[0]; + my $rawsz = $name eq "sha256" ? 32 : 20; + my $payload = substr($index, 0, -$rawsz); + my $offset = index($payload, "FSCF"); + die "index has no FSCF extension\n" if $offset < 0; + my $size = unpack("N", substr($payload, $offset + 4, 4)); + $payload .= substr($payload, $offset, 8 + $size); + print $payload, + $name eq "sha256" ? sha256($payload) : sha1($payload); + EOF + perl .git/duplicate-fscf.pl "$(test_oid algo)" \ + <.git/index >.git/index.duplicate && + mv .git/index.duplicate .git/index && + GIT_TRACE2_EVENT="$PWD/.git/duplicate.trace" \ + git status --porcelain=v2 >.git/duplicate && + test_cmp .git/expect .git/duplicate && + test_trace2_data fsmonitor config/invalid-extension 1 \ + <.git/duplicate.trace && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <.git/duplicate.trace && + ! test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/duplicate.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'branch switches preserve unchanged worktree semantics' ' + test_when_finished "rm -rf switch-semantic-history" && + test_create_repo switch-semantic-history && + ( + cd switch-semantic-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines changed >tracked && + git add tracked && + git commit -m changed && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/switch.trace" \ + git switch --detach HEAD^ && + test_trace2_data fsmonitor history/semantic-transferred 1 \ + <.git/switch.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace + ) +' + test_expect_success PTHREADS,UNTRACKED_CACHE,SHA1 'load cache-tree and untracked-cache extensions in parallel' ' test_create_repo parallel-extensions && ( diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index c68f14443f3cdb..5a9359283867a4 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -59,6 +59,15 @@ test_lazy_prereq HARDLINKS ' ln hardlink-a hardlink-b ' +test_lazy_prereq FOREIGN_FSMONITOR_GIT ' + test -x /opt/homebrew/bin/git && + /opt/homebrew/bin/git version +' + +test_lazy_prereq LEGACY_PREVIEW_FSMONITOR_GIT ' + test -x /opt/homebrew/Cellar/og-preview/2026-08-11T2321Z/libexec/openai-git/bin/git +' + if ! test_have_prereq FSMONITOR_WORKS then skip_all="filesystem does not deliver fsmonitor events (container/overlayfs?)" @@ -1022,16 +1031,18 @@ stop_git () { } stop_watchdog () { - while kill -0 $watchdog_pid + while test -n "$watchdog_pid" && + kill -0 "$watchdog_pid" 2>/dev/null do - kill $watchdog_pid + kill "$watchdog_pid" 2>/dev/null sleep 1 done + watchdog_pid= } test_expect_success !MINGW "submodule implicitly starts daemon by pull" ' test_atexit "stop_watchdog" && - test_when_finished "set +m; stop_git; rm -rf cloned super sub" && + test_when_finished "stop_watchdog; set +m; stop_git; rm -rf cloned super sub" && create_super super && create_sub sub && @@ -1301,6 +1312,7 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' # marked clean. git -C file_case_wrong config core.fsmonitor true && git -C file_case_wrong update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/file_case_wrong/.git/index" \ git -C file_case_wrong status && # Make some files dirty so that FSMonitor gets FSEvents for @@ -1333,6 +1345,7 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' # token (so the next invocation will not see data for these # events). + GIT_INDEX_FILE="$PWD/file_case_wrong/.git/index" \ GIT_TRACE_FSMONITOR="$PWD/file_case_wrong-try1.log" \ git -C file_case_wrong status --short \ >"$PWD/file_case_wrong-try1.out" && @@ -1436,6 +1449,96 @@ test_expect_success MACOS,HARDLINKS 'hardlink events invalidate all tracked path ) ' +test_expect_success MACOS,UNTRACKED_CACHE \ + 'directory timestamp events preserve clean fsmonitor proofs' ' + test_when_finished \ + "git -C directory-metadata fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo directory-metadata && + ( + cd directory-metadata && + mkdir -p api/nested other && + test_write_lines api >api/nested/tracked && + test_write_lines other >other/tracked && + git add api/nested/tracked other/tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + start_daemon --tf "$PWD/.git/daemon.trace" && + git status --porcelain=2 >.git/warm-one && + git status --porcelain=2 >.git/warm-two && + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + for mode in root nested xattr + do + case "$mode" in + root) directory=api ;; + nested) directory=api/nested ;; + xattr) + directory=api && + xattr -w com.git.fsmonitor.test ignored "$directory" + ;; + esac && + if test -f .git/index.csts + then + expect_sidecar_hit=t + else + expect_sidecar_hit= + fi && + touch "$directory" && + GIT_TRACE2_EVENT="$PWD/.git/touch.trace" \ + git status --porcelain=v2 -- "$directory" \ + >.git/touch && + test_must_be_empty .git/touch && + if test -n "$expect_sidecar_hit" + then + test_trace2_data status clean-proof/hit 1 \ + <.git/touch.trace && + test_grep ! "\"label\":\"do_read_index\"" \ + .git/touch.trace + fi && + test_grep ! "\"key\":\"semantic/attributes-cone\"" \ + .git/touch.trace && + test_grep ! "\"key\":\"semantic/manifest-scan-count\"" \ + .git/touch.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/touch.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/touch.trace && + test_grep ! "\"key\":\"preload/bulk_" \ + .git/touch.trace && + rm .git/touch.trace || return 1 + done && + test_grep "ignore-dir-metadata:.*api" .git/daemon.trace && + + mkdir api/created && + test_write_lines child >api/created/child && + git status --porcelain=v2 -- api >.git/created && + test_grep "^? api/created/$" .git/created && + rm api/created/child && + rmdir api/created && + git status --porcelain=v2 -- api >.git/removed && + test_must_be_empty .git/removed && + + test_write_lines "* text" >api/.gitattributes && + git status --porcelain=v2 -- api >.git/attributes && + test_grep "^? api/.gitattributes$" .git/attributes && + rm api/.gitattributes && + git status --porcelain=v2 -- api >.git/attributes-removed && + test_must_be_empty .git/attributes-removed && + + chmod 750 api && + git status --porcelain=v2 -- api >.git/chmod && + test_grep "fsevent:.*api.*ItemChangeOwner" \ + .git/daemon.trace && + chmod 755 api && + mv api/nested api/renamed && + git status --porcelain=v2 -- api >.git/renamed && + test_grep "^1 \\.D .*api/nested/tracked$" .git/renamed && + test_grep "^? api/renamed/$" .git/renamed + ) +' + test_expect_success MACOS 'implicit daemon reuses the invoking Git executable' ' test_create_repo same-executable-spawn && mkdir fake-exec-path && @@ -1546,6 +1649,360 @@ test_expect_success 'bound query replaces a legacy daemon' ' ) ' +test_expect_success MACOS 'bound query upgrades stale directory event daemon' ' + test_when_finished \ + "stop_daemon_delete_repo directory-daemon-upgrade" && + test_create_repo directory-daemon-upgrade && + ( + cd directory-daemon-upgrade && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >.git/before && + test_must_be_empty .git/before && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 \ + --fsmonitor-pre-dir-metadata && + + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TRACE2_EVENT="$PWD/.git/upgrade.trace" \ + git status --porcelain=v2 >.git/upgrade && + test_must_be_empty .git/upgrade && + test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/upgrade.trace && + test_grep \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/upgrade.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "fsmonitor last update builtin:dirmeta-v1\\." \ + .git/fsmonitor && + + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git status --porcelain=v2 >.git/repeat && + test_cmp .git/upgrade .git/repeat && + ! test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/repeat.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/repeat.trace + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE \ + 'inotify watch-limit backoff preserves ordinary status without retries' ' + test_when_finished "stop_daemon_delete_repo inotify-watch-backoff" && + test_create_repo inotify-watch-backoff && + ( + cd inotify-watch-backoff && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + printf "inotify-limit-v1\\ntest-worktree\\n0\\n" \ + >.git/fsmonitor--daemon.inotify-limit && + chmod 600 .git/fsmonitor--daemon.inotify-limit && + test_write_lines changed >tracked && + test_write_lines visible >blep && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + + for attempt in first second + do + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/$attempt.trace" \ + git status --porcelain=v2 >.git/$attempt.actual && + test_cmp .git/expect .git/$attempt.actual && + test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/$attempt.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/$attempt.trace && + test_grep ! \ + "\\\"event\\\":\\\"child_start\\\".*\\\"fsmonitor--daemon\\\"" \ + .git/$attempt.trace || return 1 + done && + test_grep "^1 \\.M .* tracked$" .git/first.actual && + test_grep "^? blep$" .git/first.actual + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE \ + 'expired watch-limit backoff allows an authenticated daemon to recover' ' + test_when_finished "stop_daemon_delete_repo inotify-watch-expired" && + test_create_repo inotify-watch-expired && + ( + cd inotify-watch-expired && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + printf "inotify-limit-v1\\ntest-worktree\\n0\\n" \ + >.git/fsmonitor--daemon.inotify-limit && + chmod 600 .git/fsmonitor--daemon.inotify-limit && + test-tool chmtime -120 .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/expired.trace" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/expired.trace && + test_grep \ + "\\\"event\\\":\\\"child_start\\\".*\\\"fsmonitor--daemon\\\"" \ + .git/expired.trace && + test_path_is_missing .git/fsmonitor--daemon.inotify-limit && + git fsmonitor--daemon status + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE \ + 'a running or explicitly started daemon overrides watch-limit backoff' ' + test_when_finished "stop_daemon_delete_repo inotify-watch-live" && + test_create_repo inotify-watch-live && + ( + cd inotify-watch-live && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + git fsmonitor--daemon start && + printf "inotify-limit-v1\\ntest-worktree\\n0\\n" \ + >.git/fsmonitor--daemon.inotify-limit && + chmod 600 .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/live.trace" \ + git status --porcelain=v2 >.git/live && + test_must_be_empty .git/live && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 <.git/live.trace && + git fsmonitor--daemon stop && + printf "inotify-limit-v1\\ntest-worktree\\n0\\n" \ + >.git/fsmonitor--daemon.inotify-limit && + chmod 600 .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + git fsmonitor--daemon start && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + git status --porcelain=v2 >.git/recovered && + test_must_be_empty .git/recovered && + test_path_is_missing .git/fsmonitor--daemon.inotify-limit + ) +' + +test_expect_success MACOS,SYMLINKS,UNTRACKED_CACHE \ + 'foreign and symlinked watch-limit markers never disable a worktree' ' + test_when_finished "stop_daemon_delete_repo inotify-watch-foreign" && + test_create_repo inotify-watch-foreign && + ( + cd inotify-watch-foreign && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + printf "inotify-limit-v1\\nforeign-worktree\\n0\\n" \ + >.git/fsmonitor--daemon.inotify-limit && + chmod 600 .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/foreign.trace" \ + git status --porcelain=v2 >.git/foreign && + test_must_be_empty .git/foreign && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/foreign.trace && + git fsmonitor--daemon stop && + rm -f .git/fsmonitor--daemon.inotify-limit && + printf "inotify-limit-v1\\ntest-worktree\\n0\\n" \ + >.git/inotify-marker-target && + chmod 600 .git/inotify-marker-target && + ln -s inotify-marker-target .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/symlink.trace" \ + git status --porcelain=v2 >.git/symlink && + test_must_be_empty .git/symlink && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/symlink.trace && + test_path_is_file .git/inotify-marker-target && + test_grep test-worktree .git/inotify-marker-target + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE \ + 'watch-limit backoff does not leak into a linked worktree' ' + test_when_finished " + git -C inotify-watch-linked fsmonitor--daemon stop \ + 2>/dev/null || : + git -C inotify-watch-main fsmonitor--daemon stop \ + 2>/dev/null || : + git -C inotify-watch-main -c core.fsmonitor=false \ + worktree remove --force ../inotify-watch-linked \ + 2>/dev/null || : + " && + test_create_repo inotify-watch-main && + ( + cd inotify-watch-main && + test_commit base tracked && + git worktree add ../inotify-watch-linked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + printf "inotify-limit-v1\\ntest-worktree\\n0\\n" \ + >.git/fsmonitor--daemon.inotify-limit && + chmod 600 .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/main.trace" \ + git status --porcelain=v2 >.git/main && + test_must_be_empty .git/main && + test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 <.git/main.trace && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/linked.trace" \ + git -C ../inotify-watch-linked status --porcelain=v2 \ + >.git/linked && + test_must_be_empty .git/linked && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/linked.trace && + test_grep \ + "\\\"event\\\":\\\"child_start\\\".*\\\"fsmonitor--daemon\\\"" \ + .git/linked.trace + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE \ + 'failed bound query reconnects to an authenticated replacement daemon' ' + test_when_finished \ + "stop_daemon_delete_repo disconnected-directory-daemon" && + test_create_repo disconnected-directory-daemon && + ( + cd disconnected-directory-daemon && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines visible >blep && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >.git/expect && + test_grep "^? blep$" .git/expect && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 \ + --fsmonitor-disconnect-first && + + GIT_TRACE2_EVENT="$PWD/.git/reconnect.trace" \ + git status --porcelain=v2 \ + >.git/actual 2>.git/reconnect.error && + test_cmp .git/expect .git/actual && + test_must_be_empty .git/reconnect.error && + test_trace2_data fsm_client query/reconnect-after-failed-send 1 \ + <.git/reconnect.trace && + test_grep \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/reconnect.trace && + ! test_trace2_data fsm_client query/worktree-mismatch 1 \ + <.git/reconnect.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "fsmonitor last update builtin:dirmeta-v1\\." \ + .git/fsmonitor && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git status --porcelain=v2 >.git/repeat && + test_cmp .git/expect .git/repeat && + ! test_trace2_data fsm_client query/reconnect-after-failed-send 1 \ + <.git/repeat.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/repeat.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/repeat.trace + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE \ + 'concurrent clients share one stale directory daemon upgrade' ' + test_when_finished \ + "stop_daemon_delete_repo concurrent-directory-daemon-upgrade" && + test_create_repo concurrent-directory-daemon-upgrade && + ( + cd concurrent-directory-daemon-upgrade && + sane_unset GIT_TEST_SPLIT_INDEX && + for sibling in $(test_seq 1 16) + do + mkdir "sibling-$sibling" && + test_write_lines "base-$sibling" \ + >"sibling-$sibling/tracked" || return 1 + done && + git add sibling-* && + git commit -m base && + test_write_lines visible >blep && + git config core.preloadIndex false && + git config core.untrackedCache true && + git status --porcelain=v2 >.git/expect && + test_grep "^? blep$" .git/expect && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=8 \ + --fsmonitor-pre-dir-metadata && + + pids= && + for client in $(test_seq 1 8) + do + GIT_TRACE2_EVENT="$PWD/.git/client-$client.trace" \ + git status --porcelain=v2 \ + >.git/client-$client.actual \ + 2>.git/client-$client.error & + pids="$pids $!" || return 1 + done && + failed= && + for pid in $pids + do + wait "$pid" || failed=1 || return 1 + done && + test -z "$failed" && + + for client in $(test_seq 1 8) + do + test_cmp .git/expect .git/client-$client.actual && + test_must_be_empty .git/client-$client.error && + ! test_trace2_data fsm_client query/worktree-mismatch 1 \ + <.git/client-$client.trace || return 1 + done && + grep -h \ + "\"event\":\"child_start\".*\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/client-*.trace >.git/daemon-spawns && + test_line_count = 1 .git/daemon-spawns && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "fsmonitor last update builtin:dirmeta-v1\\." \ + .git/fsmonitor && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git status --porcelain=v2 >.git/repeat && + test_cmp .git/expect .git/repeat && + ! test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/repeat.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/repeat.trace && + test_grep ! \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/repeat.trace + ) +' + test_expect_success 'bound daemon also serves legacy token queries' ' test_when_finished "stop_daemon_delete_repo legacy-client-query" && test_create_repo legacy-client-query && @@ -1556,6 +2013,7 @@ test_expect_success 'bound daemon also serves legacy token queries' ' git config core.preloadIndex false && git config core.untrackedCache true && git config core.fsmonitor true && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TRACE2_EVENT="$PWD/.git/daemon.trace" \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1597,7 +2055,8 @@ test_expect_success MACOS 'daemon token reset closes a skipHash index' ' start_daemon && git update-index --force-write-index && - git status --porcelain=v2 >.git/prime.out && + GIT_INDEX_FILE="$PWD/.git/index" \ + git status --porcelain=v2 >.git/prime.out && test_must_be_empty .git/prime.out && test_grep FSMN .git/index && test_grep FSCF .git/index && @@ -1611,6 +2070,7 @@ test_expect_success MACOS 'daemon token reset closes a skipHash index' ' git fsmonitor--daemon stop && start_daemon && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ git status --porcelain=v2 --untracked-files=normal >.git/reset.out && test_must_be_empty .git/reset.out && @@ -1640,6 +2100,7 @@ test_expect_success MACOS 'daemon token reset closes a skipHash index' ' echo changed >>tracked && rm removed && start_daemon && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TRACE2_EVENT="$PWD/.git/dirty-reset.trace" \ git status --porcelain=v2 >.git/dirty-reset.out && test_line_count = 2 .git/dirty-reset.out && @@ -1741,6 +2202,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1776,7 +2238,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/attributes.trace && test_trace2_data fsmonitor apply_count 1 \ <.git/attributes.trace && - ! test_trace2_data fsmonitor config/token-advanced 1 \ + test_trace2_data fsmonitor semantic/manifest-reconciled 1 \ + <.git/attributes.trace && + test_trace2_data fsmonitor config/token-advanced 1 \ <.git/attributes.trace ) ' @@ -1794,6 +2258,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1831,6 +2296,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1857,7 +2323,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'source-tree checkout drops history after an index change' ' + 'source-tree checkout preserves history after a nonsemantic index change' ' test_when_finished "rm -rf checkout-source-changed" && test_create_repo checkout-source-changed && ( @@ -1871,6 +2337,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1882,7 +2349,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && test_grep "^1 M\." .git/actual && - test_trace2_data fsmonitor config/coherent 0 \ + test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace ) ' @@ -1899,6 +2366,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1931,6 +2399,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1963,6 +2432,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -1995,6 +2465,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2016,7 +2487,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'ordinary add drops history after a logical index change' ' + 'ordinary add preserves history after a nonsemantic index change' ' test_when_finished "rm -rf add-ordinary-changed" && test_create_repo add-ordinary-changed && ( @@ -2027,6 +2498,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2040,17 +2512,17 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && test_grep "^1 M\." .git/actual && - test_trace2_data fsmonitor config/coherent 0 \ + test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace ) ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'ordinary add drops history after ITA resolution' ' - test_when_finished "rm -rf add-ordinary-ita" && - test_create_repo add-ordinary-ita && + 'ordinary staged paths preserve closed semantic history' ' + test_when_finished "rm -rf staged-semantic-history" && + test_create_repo staged-semantic-history && ( - cd add-ordinary-ita && + cd staged-semantic-history && sane_unset GIT_TEST_SPLIT_INDEX && test_commit base tracked && git config core.untrackedCache true && @@ -2060,93 +2532,1459 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && - test_grep FSCF .git/index && - touch empty && + test_write_lines changed >tracked && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ - GIT_TEST_FSMONITOR_QUERY_PATH=empty \ - git add -N empty && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ - GIT_TEST_FSMONITOR_QUERY_PATH=empty \ - git status --porcelain=v2 >.git/ita && - test_grep FSCF .git/index && + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/staged.trace" \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 M\\..* tracked$" .git/staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/staged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/staged.trace && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ - git add empty && + git restore --staged tracked && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ - GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ - git status --porcelain=v2 >.git/actual && - test_grep "^1 A\\." .git/actual && + GIT_TRACE2_EVENT="$PWD/.git/unstaged.trace" \ + git status --porcelain=v2 >.git/unstaged && + test_grep "^1 \\.M.* tracked$" .git/unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/unstaged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/unstaged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git reset HEAD -- tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git status --porcelain=v2 >.git/reset && + test_grep "^1 \\.M.* tracked$" .git/reset && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/reset.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/reset.trace && + + test_write_lines new >new-root-file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=new-root-file \ + git add new-root-file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/new-staged.trace" \ + git status --porcelain=v2 >.git/new-staged && + test_grep "^1 A\\..* new-root-file$" .git/new-staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/new-staged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/new-staged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged new-root-file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/new-unstaged.trace" \ + git status --porcelain=v2 >.git/new-unstaged && + test_grep "^? new-root-file$" .git/new-unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/new-unstaged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/new-unstaged.trace && + + mkdir -p brand-new/deeper && + test_write_lines nested >brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=brand-new/deeper/staged \ + git add brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-staged.trace" \ + git status --porcelain=v2 >.git/nested-staged && + test_grep "^1 A\\..* brand-new/deeper/staged$" \ + .git/nested-staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/nested-staged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/nested-staged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-unstaged.trace" \ + git status --porcelain=v2 >.git/nested-unstaged && + test_grep "^? brand-new/$" .git/nested-unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/nested-unstaged.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/nested-unstaged.trace && + + test_write_lines "* text" >brand-new/.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=brand-new/deeper/staged \ + git add brand-new/deeper/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/attributes-fallback.trace" \ + git status --porcelain=v2 \ + >.git/attributes-fallback && test_trace2_data fsmonitor config/coherent 0 \ - <.git/status.trace + <.git/attributes-fallback.trace ) ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'describe --dirty preserves closed semantic history' ' - test_when_finished "rm -rf describe-dirty-history" && - test_create_repo describe-dirty-history && + 'command-scoped transport config preserves staged worktree proofs' ' + test_when_finished "rm -rf command-transport-history" && + test_create_repo command-transport-history && ( - cd describe-dirty-history && + cd command-transport-history && sane_unset GIT_TEST_SPLIT_INDEX && - test_commit base tracked && + for sibling in $(test_seq 1 32) + do + mkdir "sibling-$sibling" && + test_write_lines "base-$sibling" \ + >"sibling-$sibling/tracked" || return 1 + done && + git add sibling-* && + git commit -m base && + git branch transport-alternate && + git config core.autocrlf false && git config core.untrackedCache true && git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && test_grep FSCF .git/index && + test_grep FSUC .git/index && - test-tool chmtime +1 tracked && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ - GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ - git describe --always --dirty >.git/describe && - test_grep ! dirty .git/describe && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ - GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ - git status >.git/actual && - test_grep "nothing to commit, working tree clean" .git/actual && + for cycle in first second + do + test_write_lines "changed-$cycle" >sibling-1/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=sibling-1/tracked \ + git add sibling-1/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 M\\..* sibling-1/tracked$" .git/staged && + test_grep FSUC .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$cycle.restore.trace" \ + git \ + -c "url.https://proxy.example/github/.insteadOf=https://github.com/" \ + -c "url.https://proxy.example/github/.insteadOf=https://proxy.example/github/" \ + -c "url.https://proxy.example/github/.insteadOf=https://proxy.example/" \ + -c "credential.https://github.com.helper=" \ + -c "credential.https://proxy.example.helper=" \ + -c "credential.https://proxy.example.helper=!og github-proxy credential-helper" \ + restore --staged sibling-1/tracked && + test_trace2_data fsmonitor config/coherent 1 \ + <".git/$cycle.restore.trace" && + test_trace2_data fsmonitor apply_count 0 \ + <".git/$cycle.restore.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <".git/$cycle.restore.trace" && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$cycle.status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.M.* sibling-1/tracked$" .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <".git/$cycle.status.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <".git/$cycle.status.trace" && + test_trace2_data index refresh/sum_lstat 1 \ + <".git/$cycle.status.trace" && + test_trace2_data read_directory directories-visited 2 \ + <".git/$cycle.status.trace" && + test_grep FSCF .git/index && + test_grep FSUC .git/index || return 1 + done && + + test_write_lines "new root file" >blep && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=blep \ + git add blep && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/root-staged && + test_grep "^1 A\\..* blep$" .git/root-staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/root.restore.trace" \ + git \ + -c "url.https://proxy.example/github/.insteadOf=https://github.com/" \ + -c "url.https://proxy.example/github/.insteadOf=https://proxy.example/github/" \ + -c "url.https://proxy.example/github/.insteadOf=https://proxy.example/" \ + -c "credential.https://github.com.helper=" \ + -c "credential.https://proxy.example.helper=" \ + -c "credential.https://proxy.example.helper=!og github-proxy credential-helper" \ + restore --staged blep && test_trace2_data fsmonitor config/coherent 1 \ - <.git/status.trace && - ! test_trace2_data status semantic_verify/prepared 1 \ - <.git/status.trace + <.git/root.restore.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/root.restore.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/root.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/root.status.trace" \ + git status --porcelain=v2 >.git/root.actual && + test_cmp .git/root.expect .git/root.actual && + test_grep "^? blep$" .git/root.actual && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/root.status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/root.status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/switch.trace" \ + git \ + -c "url.https://proxy.example/github/.insteadOf=https://github.com/" \ + -c "url.https://proxy.example/github/.insteadOf=https://proxy.example/github/" \ + -c "url.https://proxy.example/github/.insteadOf=https://proxy.example/" \ + -c "credential.https://github.com.helper=" \ + -c "credential.https://proxy.example.helper=" \ + -c "credential.https://proxy.example.helper=!og github-proxy credential-helper" \ + switch transport-alternate && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/switch.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/switch.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/switch.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/switch-status.trace" \ + git status --porcelain=v2 >.git/switch.actual && + test_cmp .git/switch.expect .git/switch.actual && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/switch-status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/switch-status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index ) ' -test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'clean stash push preserves closed semantic history' ' - test_when_finished "rm -rf stash-clean-history" && - test_create_repo stash-clean-history && + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'nonsemantic configuration changes reuse the authenticated manifest' ' + test_when_finished "rm -rf command-nonsemantic-history" && + test_create_repo command-nonsemantic-history && ( - cd stash-clean-history && + cd command-nonsemantic-history && sane_unset GIT_TEST_SPLIT_INDEX && - test_commit base tracked && + mkdir sibling && + test_commit base sibling/tracked && + git config core.autocrlf false && git config core.untrackedCache true && git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && + test_grep FSMN .git/index && test_grep FSCF .git/index && - - cp .git/index .git/index.before && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ - GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ - git stash push >.git/stash && - test_grep "No local changes to save" .git/stash && - test_cmp .git/index.before .git/index && - test_grep ! "\"label\":\"do_write_index\"" .git/stash.trace && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + test_grep FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 git -c user.name=Alternate \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ - git status >.git/actual && - test_grep "nothing to commit, working tree clean" .git/actual && - test_trace2_data fsmonitor config/coherent 1 \ + git -c user.name=Alternate status --porcelain=v2 \ + >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ <.git/status.trace && - ! test_trace2_data status semantic_verify/prepared 1 \ - <.git/status.trace + test_trace2_data fsmonitor semantic/initial-mismatch 0 \ + <.git/status.trace && + test_trace2_data fsmonitor semantic/manifest-reused 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/status.trace && + test_trace2_data fsmonitor config/revalidated 1 \ + <.git/status.trace && + + test_write_lines hidden >sibling/hidden && + test_write_lines sibling/hidden >.git/excludes && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.excludesFile="$PWD/.git/excludes" \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/excludes.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=sibling/hidden \ + GIT_TRACE2_EVENT="$PWD/.git/excludes.trace" \ + git -c core.excludesFile="$PWD/.git/excludes" \ + status --porcelain=v2 >.git/excludes.actual && + test_cmp .git/excludes.expect .git/excludes.actual && + test_must_be_empty .git/excludes.actual && + test_trace2_data fsmonitor semantic/manifest-reused 1 \ + <.git/excludes.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/excludes.trace && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.excludesFile=/dev/null \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/visible.trace" \ + git -c core.excludesFile=/dev/null \ + status --porcelain=v2 >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? sibling/hidden$" .git/visible.actual && + test_trace2_data fsmonitor semantic/manifest-reused 1 \ + <.git/visible.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/visible.trace + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'harmless configuration drift preserves authenticated tracked state' ' + test_when_finished "rm -rf command-tracked-config-history" && + test_create_repo command-tracked-config-history && + ( + cd command-tracked-config-history && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir entries && + for tracked_entry in $(test_seq 1 257) + do + test_write_lines "$tracked_entry" \ + >"entries/tracked-$tracked_entry" || return 1 + done && + git add entries && + git commit -qm base && + test-tool chmtime -120 entries/tracked-* && + git -c core.fsmonitor=false update-index --refresh && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.preloadIndexBulk true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c advice.statusHints=false \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=entries/tracked-1 \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git -c advice.statusHints=false \ + status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/status.trace && + test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/status.trace && + test_trace2_data fsmonitor apply_count 1 \ + <.git/status.trace && + ! test_trace2_data index preload/bulk_useful \ + "[2-9][0-9]*" <.git/status.trace && + ! test_trace2_data index preload/sum_lstat \ + "[2-9][0-9]*" <.git/status.trace && + ! test_trace2_data index refresh/sum_lstat \ + "[2-9][0-9]*" <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace && + test_region index do_write_index .git/status.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/issue.trace" \ + git -c advice.statusHints=false \ + status --porcelain=v2 >.git/issue && + test_cmp .git/expect .git/issue && + test_path_is_file .git/index.csts && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git -c advice.statusHints=false \ + status --porcelain=v2 >.git/repeat && + test_cmp .git/expect .git/repeat && + test_trace2_data status clean-proof/hit 1 \ + <.git/repeat.trace + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE,PERL_TEST_HELPERS,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'executable policy changes invalidate prior tracked cleanliness' ' + test_when_finished "rm -rf command-tracked-filemode-history" && + test_create_repo command-tracked-filemode-history && + ( + cd command-tracked-filemode-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines tracked >tracked && + git add tracked && + git commit -qm base && + git config core.filemode false && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.preloadIndexBulk true && + git config core.fsmonitor true && + chmod +x tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + cp .git/index .git/false.before && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.filemode=true \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + test_grep "^1 .M .* tracked$" .git/expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/command.trace" \ + git -c core.filemode=true \ + status --porcelain=v2 >.git/command && + test_cmp .git/expect .git/command && + ! test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/command.trace && + cp .git/false.before .git/index && + rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && + git config core.filemode true && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/persistent.expect && + test_grep "^1 .M .* tracked$" .git/persistent.expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/persistent.trace" \ + git status --porcelain=v2 >.git/persistent && + test_cmp .git/persistent.expect .git/persistent && + ! test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/persistent.trace && + cp .git/false.before .git/index && + rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -c core.filemode=false \ + status --porcelain=v2 >.git/old-command.prime && + test_must_be_empty .git/old-command.prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/old-command.trace" \ + git status --porcelain=v2 >.git/old-command && + test_cmp .git/persistent.expect .git/old-command && + ! test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/old-command.trace && + + cp .git/false.before .git/index && + rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && + git config core.filemode false && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git update-index --index-version 2 && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/tampered.prime && + test_must_be_empty .git/tampered.prime && + cat >.git/tamper-tracked-policy.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $algorithm = shift; + my $rawsz = $algorithm eq "sha256" ? 32 : 20; + my $digest = sub { + return $algorithm eq "sha256" ? + sha256($_[0]) : sha1($_[0]); + }; + my $payload = substr($index, 0, -$rawsz); + die "invalid index checksum\n" + unless substr($index, -$rawsz) eq $digest->($payload); + die "not a version 2 index\n" + unless substr($payload, 0, 4) eq "DIRC" && + unpack("N", substr($payload, 4, 4)) == 2; + my $entries = unpack("N", substr($payload, 8, 4)); + my $offset = 12; + for (1 .. $entries) { + my $name_offset = $offset + 40 + $rawsz + 2; + my $end = index($payload, "\0", $name_offset); + die "unterminated index entry\n" if $end < 0; + $offset += (($end + 1 - $offset + 7) & ~7); + } + my $found = 0; + while ($offset < length($payload)) { + die "truncated index extension\n" + if length($payload) - $offset < 8; + my $name = substr($payload, $offset, 4); + my $size = unpack("N", substr($payload, $offset + 4, 4)); + $offset += 8; + die "index extension exceeds payload\n" + if $size > length($payload) - $offset; + if ($name eq "FSCF") { + die "duplicate or truncated semantic proof\n" + if $found++ || $size < 20 + 5 * $rawsz; + my $extension = substr($payload, $offset, $size); + my ($version, $magic, $flags, $token, $manifest) = + unpack("NNNNN", substr($extension, 0, 20)); + die "incomplete version 2 semantic proof\n" + unless $version == 2 && + $magic == 0x46534331 && + $flags == 15 && $token && + $size == 20 + $token + + 5 * $rawsz + $manifest; + die "invalid semantic proof checksum\n" + unless substr($extension, -$rawsz) eq + $digest->(substr($extension, 0, -$rawsz)); + my $policy_offset = 20 + $token + 3 * $rawsz; + substr($extension, $policy_offset, 1, + chr(ord(substr($extension, + $policy_offset, 1)) ^ 1)); + substr($extension, -$rawsz, $rawsz, + $digest->(substr($extension, 0, -$rawsz))); + substr($payload, $offset, $size, $extension); + } + $offset += $size; + } + die "missing version 2 semantic proof\n" unless $found == 1; + print $payload, $digest->($payload); + EOF + perl .git/tamper-tracked-policy.pl "$(test_oid algo)" \ + <.git/index >.git/index.policy-tampered && + cp .git/index.policy-tampered .git/index && + rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/policy-tampered.trace" \ + git status --porcelain=v2 >.git/policy-tampered && + test_must_be_empty .git/policy-tampered && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/policy-tampered.trace && + ! test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/policy-tampered.trace && + + cp .git/index.policy-tampered .git/index && + rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && + git config core.filemode true && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/policy-tampered-dirty.trace" \ + git status --porcelain=v2 >.git/policy-tampered-dirty && + test_cmp .git/persistent.expect .git/policy-tampered-dirty && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/policy-tampered-dirty.trace && + ! test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/policy-tampered-dirty.trace + ) +' + +test_expect_success MACOS,UNTRACKED_CACHE,PERL_TEST_HELPERS,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'legacy empty attribute fingerprints retain authenticated manifests' ' + test_when_finished "rm -rf legacy-empty-attribute-history" && + test_create_repo legacy-empty-attribute-history && + ( + cd legacy-empty-attribute-history && + sane_unset GIT_TEST_SPLIT_INDEX && + sane_unset GIT_ATTR_NOSYSTEM && + sane_unset GIT_CONFIG_NOSYSTEM && + GIT_CONFIG_SYSTEM="$PWD/.git/published-system.gitconfig" && + export GIT_CONFIG_SYSTEM && + test_write_lines "[advice]" " statusHints = false" \ + >"$GIT_CONFIG_SYSTEM" && + git config --show-scope --get advice.statusHints \ + >.git/system-scope && + test_grep "^system[[:space:]]" .git/system-scope && + legacy_global=$(git var GIT_ATTR_GLOBAL) && + legacy_info=$(git rev-parse --git-path info/attributes) && + test_path_is_missing "$(git var GIT_ATTR_SYSTEM)" && + test_path_is_missing //etc/gitattributes && + test_path_is_missing "$legacy_global" && + test_path_is_missing "$legacy_info" && + test_write_lines "*.txt -text" >.gitattributes && + test_write_lines stable >tracked.txt && + for legacy_dir in $(test_seq 1 128) + do + mkdir "nested-$legacy_dir" && + test_write_lines "*.txt -text" \ + >"nested-$legacy_dir/.gitattributes" && + test_write_lines "$legacy_dir" \ + >"nested-$legacy_dir/tracked.txt" || return 1 + done && + git add .gitattributes tracked.txt nested-* && + git commit -qm base && + test-tool chmtime -120 .gitattributes tracked.txt \ + nested-*/*.txt nested-*/.gitattributes && + git -c core.fsmonitor=false update-index --refresh && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.preloadIndexBulk true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git update-index --index-version 2 && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + test-tool dump-fsmonitor >.git/current-token && + cat >.git/legacy-empty-attributes.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my ($algorithm, $system, $global, $info, $invalid) = @ARGV; + my $rawsz = $algorithm eq "sha256" ? 32 : 20; + my $digest = sub { + return $algorithm eq "sha256" ? + sha256($_[0]) : sha1($_[0]); + }; + my $frame = sub { + return pack("N", length($_[0])) . $_[0]; + }; + my $legacy = $frame->("attribute-source-content-v1") . + $frame->(pack("N", 3)); + for my $path ($system, $global, $info) { + $legacy .= $frame->($path) . + $frame->(pack("N", 1)) . + $frame->(pack("N", 0)); + } + my $legacy_hash = $digest->($legacy); + if ($invalid) { + substr($legacy_hash, 0, 1, + chr(ord(substr($legacy_hash, 0, 1)) ^ 1)); + } + my $payload = substr($index, 0, -$rawsz); + die "invalid index checksum\n" + unless substr($index, -$rawsz) eq $digest->($payload); + die "not a version 2 index\n" + unless substr($payload, 0, 4) eq "DIRC" && + unpack("N", substr($payload, 4, 4)) == 2; + my $entries = unpack("N", substr($payload, 8, 4)); + my $offset = 12; + for (1 .. $entries) { + my $name_offset = $offset + 40 + $rawsz + 2; + my $end = index($payload, "\0", $name_offset); + die "unterminated index entry\n" if $end < 0; + $offset += (($end + 1 - $offset + 7) & ~7); + } + my $rewritten = substr($payload, 0, $offset); + my $found_proof = 0; + my $found_token = 0; + my $removed_untracked = 0; + while ($offset < length($payload)) { + die "truncated index extension\n" + if length($payload) - $offset < 8; + my $name = substr($payload, $offset, 4); + my $size = unpack("N", substr($payload, $offset + 4, 4)); + $offset += 8; + die "index extension exceeds payload\n" + if $size > length($payload) - $offset; + my $extension = substr($payload, $offset, $size); + $offset += $size; + if ($name eq "FSUC") { + $removed_untracked++; + next; + } + $found_token++ if $name eq "FSMN"; + if ($name eq "FSCF") { + die "duplicate or truncated semantic proof\n" + if $found_proof++ || $size < 20 + 4 * $rawsz; + my ($version, $magic, $flags, $token, $manifest) = + unpack("NNNNN", substr($extension, 0, 20)); + my $hashes = $version == 2 ? 5 : 4; + die "incomplete semantic proof\n" + unless ($version == 1 || $version == 2) && + $magic == 0x46534331 && + $flags == 15 && $token && + $size == 20 + $token + + $hashes * $rawsz + $manifest; + die "invalid semantic proof checksum\n" + unless substr($extension, -$rawsz) eq + $digest->(substr($extension, 0, -$rawsz)); + my $attribute_offset = 20 + $token + 2 * $rawsz; + if ($version == 2) { + substr($extension, + $attribute_offset + $rawsz, $rawsz, ""); + substr($extension, 0, 4, pack("N", 1)); + } + substr($extension, $attribute_offset, $rawsz, + $legacy_hash); + substr($extension, -$rawsz, $rawsz, + $digest->(substr($extension, 0, -$rawsz))); + } + $rewritten .= $name . pack("N", length($extension)) . + $extension; + } + die "missing complete semantic proof, token, or untracked proof\n" + unless $found_proof == 1 && $found_token == 1 && + $removed_untracked == 1; + print $rewritten, $digest->($rewritten); + EOF + perl .git/legacy-empty-attributes.pl "$(test_oid algo)" \ + //etc/gitattributes "$legacy_global" "$legacy_info" 0 \ + <.git/index >.git/index.legacy && + perl .git/legacy-empty-attributes.pl "$(test_oid algo)" \ + //etc/gitattributes "$legacy_global" "$legacy_info" 1 \ + <.git/index >.git/index.invalid && + cp .git/index.legacy .git/index && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_grep ! FSUC .git/index && + test-tool dump-fsmonitor >.git/legacy-token && + test_cmp .git/current-token .git/legacy-token && + GIT_OPTIONAL_LOCKS=0 git -c user.name=Legacy \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 \ + >.git/legacy.expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked.txt \ + GIT_TRACE2_EVENT="$PWD/.git/legacy.trace" \ + git -c user.name=Legacy status --porcelain=v2 \ + >.git/legacy.actual && + test_cmp .git/legacy.expect .git/legacy.actual && + test_must_be_empty .git/legacy.actual && + test_trace2_data fsmonitor semantic/legacy-empty-attributes 1 \ + <.git/legacy.trace && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/legacy.trace && + test_trace2_data fsmonitor semantic/initial-mismatch 0 \ + <.git/legacy.trace && + test_trace2_data fsmonitor semantic/manifest-reused 1 \ + <.git/legacy.trace && + test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/legacy.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/legacy.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/legacy.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/legacy.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/legacy.trace && + ! test_trace2_data index preload/sum_lstat \ + "[2-9][0-9]*" <.git/legacy.trace && + ! test_trace2_data index refresh/sum_lstat \ + "[2-9][0-9]*" <.git/legacy.trace && + test_region index do_write_index .git/legacy.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/issue.trace" \ + git -c user.name=Legacy status --porcelain=v2 \ + >.git/issue.actual && + test_cmp .git/legacy.expect .git/issue.actual && + test_path_is_file .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git -c user.name=Legacy status --porcelain=v2 \ + >.git/repeat.actual && + test_cmp .git/legacy.expect .git/repeat.actual && + test_trace2_data status clean-proof/hit 1 \ + <.git/repeat.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/repeat.trace && + + cp .git/index.legacy .git/index && + rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && + GIT_OPTIONAL_LOCKS=0 git -c advice.statusHints=true \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/system-advice.expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked.txt \ + GIT_TRACE2_EVENT="$PWD/.git/system-advice.trace" \ + git -c advice.statusHints=true status --porcelain=v2 \ + >.git/system-advice.actual && + test_cmp .git/system-advice.expect .git/system-advice.actual && + test_trace2_data fsmonitor semantic/legacy-empty-attributes 1 \ + <.git/system-advice.trace && + test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <.git/system-advice.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/system-advice.trace && + ! test_trace2_data index preload/sum_lstat \ + "[2-9][0-9]*" <.git/system-advice.trace && + + for boundary in invalid external semantic filter + do + rm -f .git/index.csts .git/index.csh1.* \ + .git/index.cswi.* "$legacy_info" && + if test "$boundary" = invalid + then + cp .git/index.invalid .git/index + else + cp .git/index.legacy .git/index + fi && + case "$boundary" in + external) + test_write_lines "*.txt text eol=crlf" \ + >"$legacy_info" && + legacy_config= + ;; + semantic) + legacy_config="-c core.autocrlf=true" + ;; + filter) + legacy_config="-c filter.legacy.clean=cat" + ;; + *) + legacy_config= + ;; + esac && + GIT_OPTIONAL_LOCKS=0 git $legacy_config \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >".git/$boundary.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$boundary.trace" \ + git $legacy_config status --porcelain=v2 \ + >".git/$boundary.actual" && + test_cmp ".git/$boundary.expect" \ + ".git/$boundary.actual" && + ! test_trace2_data fsmonitor \ + semantic/legacy-empty-attributes 1 \ + <".git/$boundary.trace" && + test_trace2_data fsmonitor semantic/initial-mismatch 1 \ + <".git/$boundary.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <".git/$boundary.trace" && + test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <".git/$boundary.trace" || return 1 + done && + + for system_boundary in newer symlink missing + do + rm -f .git/index.csts .git/index.csh1.* \ + .git/index.cswi.* "$legacy_info" && + cp .git/index.legacy .git/index && + case "$system_boundary" in + newer) + test_write_lines "[advice]" \ + " statusHints = true" \ + >"$GIT_CONFIG_SYSTEM" + ;; + symlink) + mv "$GIT_CONFIG_SYSTEM" .git/system-config.real && + ln -s system-config.real "$GIT_CONFIG_SYSTEM" + ;; + missing) + rm -f "$GIT_CONFIG_SYSTEM" + ;; + esac && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >".git/system-$system_boundary.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/system-$system_boundary.trace" \ + git status --porcelain=v2 \ + >".git/system-$system_boundary.actual" && + test_cmp ".git/system-$system_boundary.expect" \ + ".git/system-$system_boundary.actual" && + ! test_trace2_data fsmonitor \ + config/tracked-epoch-preserved 1 \ + <".git/system-$system_boundary.trace" || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'command-scoped conversion config still invalidates worktree proofs' ' + test_when_finished "rm -rf command-semantic-history" && + test_create_repo command-semantic-history && + ( + cd command-semantic-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 M\\..* tracked$" .git/staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/restore.trace" \ + git -c core.autocrlf=true restore --staged tracked && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/restore.trace && + test_trace2_data fsmonitor semantic/initial-mismatch 1 \ + <.git/restore.trace && + test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/restore.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/restore.trace && + test_grep ! FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.M.* tracked$" .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/status.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary staged paths reuse unchanged tracked ancestor attributes' ' + test_when_finished "rm -rf staged-tracked-ancestor-attributes" && + test_create_repo staged-tracked-ancestor-attributes && + ( + cd staged-tracked-ancestor-attributes && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p api/existing api/brand-new/deeper && + test_write_lines "*.txt text" >api/.gitattributes && + test_write_lines existing >api/existing/tracked && + git add api/.gitattributes api/existing/tracked && + git commit -m base && + initial_branch=$(git symbolic-ref --short HEAD) && + git switch -c changed-tree && + mkdir -p api/branch-only/deeper && + test_write_lines alternate >api/existing/alternate.txt && + test_write_lines alternate >api/branch-only/deeper/alternate.txt && + git add api/existing/alternate.txt \ + api/branch-only/deeper/alternate.txt && + git commit -m alternate && + git switch "$initial_branch" && + test-tool chmtime -120 api/.gitattributes api/existing/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + for location in api/existing/added.txt api/brand-new/deeper/added.txt + do + test_write_lines added >"$location" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH="$location" \ + git add "$location" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-add.trace" \ + git status --porcelain=v2 >.git/ancestor-add && + test_grep "^1 A\\..* $location$" .git/ancestor-add && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/ancestor-add.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/ancestor-add.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged "$location" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-remove.trace" \ + git status --porcelain=v2 >.git/ancestor-remove && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/ancestor-remove.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/ancestor-remove.trace && + rm "$location" .git/ancestor-add.trace \ + .git/ancestor-remove.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH="$location" \ + git status --porcelain=v2 >.git/ancestor-deleted && + test_must_be_empty .git/ancestor-deleted || return 1 + done && + + rmdir api/brand-new/deeper api/brand-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/brand-new/ \ + git status --porcelain=v2 >.git/before-switch && + test_must_be_empty .git/before-switch && + + for branch in changed-tree "$initial_branch" + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git switch "$branch" && + if test "$branch" = changed-tree + then + test_path_is_file api/branch-only/deeper/alternate.txt + else + test_path_is_missing api/branch-only + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-switch.trace" \ + git status --porcelain=v2 >.git/ancestor-switch && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/ancestor-switch.expect && + test_cmp .git/ancestor-switch.expect .git/ancestor-switch && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/ancestor-switch.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/ancestor-switch.trace && + ! test_trace2_data index preload/bulk_useful \ + <.git/ancestor-switch.trace && + rm .git/ancestor-switch.trace || return 1 + done && + + cp api/.gitattributes .git/attributes.saved && + rm api/.gitattributes && + test_write_lines missing >api/existing/missing.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/existing/missing.txt \ + git add api/existing/missing.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-missing.trace" \ + git status --porcelain=v2 >.git/ancestor-missing && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/ancestor-missing.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git restore --staged api/existing/missing.txt && + rm api/existing/missing.txt && + cp .git/attributes.saved api/.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/.gitattributes \ + git status --porcelain=v2 >.git/repaired && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/repaired-repeat && + + test_write_lines "*.txt -text" >api/.gitattributes && + test_write_lines changed >api/existing/changed.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=api/existing/changed.txt \ + git add api/existing/changed.txt && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/ancestor-changed.trace" \ + git status --porcelain=v2 >.git/ancestor-changed && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/ancestor-changed.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary add drops history after ITA resolution' ' + test_when_finished "rm -rf add-ordinary-ita" && + test_create_repo add-ordinary-ita && + ( + cd add-ordinary-ita && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + + touch empty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=empty \ + git add -N empty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=empty \ + git status --porcelain=v2 >.git/ita && + test_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git add empty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 A\\." .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a racy restored checkpoint advances the named provider token' ' + test_when_finished "rm -rf restored-racy-token" && + test_create_repo restored-racy-token && + ( + cd restored-racy-token && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/restored-racy-baseline.trace" \ + git status --short >.git/baseline && + test_must_be_empty .git/baseline && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$TRASH_DIRECTORY/restored-racy-baseline.trace" && + cp .git/index .git/owned.before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/restored-racy-checkpoint.trace" \ + git status --short >.git/checkpoint && + test_must_be_empty .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$TRASH_DIRECTORY/restored-racy-checkpoint.trace" && + cp .git/owned.before .git/index && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + test-tool chmtime -180 .git/index && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --short \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$TRASH_DIRECTORY/restored-racy-first.trace" \ + git status --short >.git/first && + test_cmp .git/expect .git/first && + test_trace2_data fsmonitor history/external-restored 1 \ + <"$TRASH_DIRECTORY/restored-racy-first.trace" && + test_trace2_data fsmonitor history/external-fsmn-recovered 1 \ + <"$TRASH_DIRECTORY/restored-racy-first.trace" && + test_trace2_data fsmonitor history/external-save-reject \ + racy-index <"$TRASH_DIRECTORY/restored-racy-first.trace" && + test_trace2_data fsmonitor \ + history/external-racy-index-persisted 1 \ + <"$TRASH_DIRECTORY/restored-racy-first.trace" && + test_grep "\"label\":\"do_write_index\"" \ + "$TRASH_DIRECTORY/restored-racy-first.trace" && + test-tool dump-fsmonitor >.git/first-token && + first_token=$(sed -n "s/^fsmonitor last update //p" \ + .git/first-token) && + test -n "$first_token" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git status --short >.git/repeat && + test_cmp .git/expect .git/repeat && + test_trace2_data index extension/fsmn/read/token "$first_token" \ + <.git/repeat.trace && + ! test_trace2_data fsmonitor apply_count 1 \ + <.git/repeat.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/repeat.trace + ) +' + +test_expect_success MACOS,LEGACY_PREVIEW_FSMONITOR_GIT,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a legacy writer preserves clean siblings when staging a new directory' ' + test_when_finished "stop_daemon_delete_repo foreign-staged-directory" && + test_create_repo foreign-staged-directory && + ( + cd foreign-staged-directory && + sane_unset GIT_TEST_SPLIT_INDEX && + for legacy_entry in $(test_seq 1 129) + do + legacy_dir="existing-$((legacy_entry % 24))" && + mkdir -p "$legacy_dir" && + test_write_lines "$legacy_entry" \ + >"$legacy_dir/tracked-$legacy_entry" || return 1 + done && + git add existing-* && + git commit -qm base && + test-tool chmtime -120 existing-*/* && + git update-index --refresh && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.preloadIndexBulk true && + test_write_lines "*.forced" >.git/info/exclude && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --porcelain=v2 >.git/checkpoint && + test_must_be_empty .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + + mkdir brand-new && + test_write_lines staged >brand-new/first && + /opt/homebrew/Cellar/og-preview/2026-08-11T2321Z/libexec/openai-git/bin/git \ + add brand-new/first && + cp .git/index .git/foreign-before.index && + test_grep FSMN .git/index && + test_grep ! FSCF .git/index && + test_grep ! FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + test_grep "^1 A\\..* brand-new/first$" .git/expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$PWD/.git/foreign-stage.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/foreign-stage.trace && + test_trace2_data fsmonitor history/external-untracked-restored 1 \ + <.git/foreign-stage.trace && + test_trace2_data fsmonitor history/external-tracked-restored \ + "[1-9][0-9]*" <.git/foreign-stage.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/foreign-stage.trace && + ! test_trace2_data index preload/bulk_dirs \ + "[1-9][0-9]*" <.git/foreign-stage.trace && + + /opt/homebrew/Cellar/og-preview/2026-08-11T2321Z/libexec/openai-git/bin/git \ + restore --staged brand-new/first && + cp .git/index .git/foreign-unstaged-before.index && + test_grep FSMN .git/index && + test_grep ! FSCF .git/index && + test_grep ! FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/unstaged.expect && + test_grep "^? brand-new/$" .git/unstaged.expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$PWD/.git/foreign-unstaged.trace" \ + git status --porcelain=v2 >.git/unstaged.actual && + test_cmp .git/unstaged.expect .git/unstaged.actual && + test_trace2_data fsmonitor history/external-restored 1 \ + <.git/foreign-unstaged.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/foreign-unstaged.trace && + ! test_trace2_data index preload/bulk_dirs \ + "[1-9][0-9]*" <.git/foreign-unstaged.trace && + + test_write_lines ignored >existing-0/ignored.forced && + /opt/homebrew/Cellar/og-preview/2026-08-11T2321Z/libexec/openai-git/bin/git \ + add --force existing-0/ignored.forced && + cp .git/index .git/foreign-forced-before.index && + test_grep FSMN .git/index && + test_grep ! FSCF .git/index && + test_grep ! FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/forced.expect && + test_grep "^1 A\\..* existing-0/ignored.forced$" \ + .git/forced.expect && + test_grep "^? brand-new/$" .git/forced.expect && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$PWD/.git/foreign-forced.trace" \ + git status --porcelain=v2 >.git/forced.actual && + test_cmp .git/forced.expect .git/forced.actual && + test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/foreign-forced.trace && + test_trace2_data fsmonitor history/external-untracked-restored 1 \ + <.git/foreign-forced.trace && + test_trace2_data fsmonitor history/external-tracked-restored \ + "[1-9][0-9]*" <.git/foreign-forced.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/foreign-forced.trace && + ! test_trace2_data index preload/bulk_dirs \ + "[1-9][0-9]*" <.git/foreign-forced.trace + ) +' + +test_expect_success FOREIGN_FSMONITOR_GIT,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a foreign index writer does not strand a racy provider token' ' + test_when_finished "stop_daemon_delete_repo foreign-racy-token" && + test_create_repo foreign-racy-token && + ( + cd foreign-racy-token && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit peer racy-peer && + test-tool chmtime -120 tracked racy-peer && + git update-index --refresh && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + git fsmonitor--daemon start --start-timeout=10 && + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --short >.git/checkpoint && + test_must_be_empty .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + + /opt/homebrew/bin/git update-index --force-write-index && + test_grep FSMN .git/index && + test_grep ! FSCF .git/index && + test-tool dump-fsmonitor >.git/homebrew-token && + homebrew_token=$(sed -n "s/^fsmonitor last update //p" \ + .git/homebrew-token) && + test -n "$homebrew_token" && + test-tool chmtime -120 tracked && + test-tool fsmonitor-client query \ + --token "$homebrew_token" >.git/observed && + nul_to_q <.git/observed >.git/observed.paths && + test_grep tracked .git/observed.paths && + test-tool chmtime -180 .git/index && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --short \ + >.git/expect && + GIT_TRACE2_EVENT="$PWD/.git/first.trace" \ + git status --short >.git/first && + test_cmp .git/expect .git/first && + test_trace2_data fsmonitor history/external-restored 1 \ + <.git/first.trace && + test_trace2_data fsmonitor history/external-save-reject \ + racy-index <.git/first.trace && + test_trace2_data fsmonitor \ + history/external-racy-index-persisted 1 \ + <.git/first.trace && + test_grep "\"label\":\"do_write_index\"" .git/first.trace && + test-tool dump-fsmonitor >.git/first-token && + first_token=$(sed -n "s/^fsmonitor last update //p" \ + .git/first-token) && + test -n "$first_token" && + test "$homebrew_token" != "$first_token" && + GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ + git status --short >.git/repeat && + test_cmp .git/expect .git/repeat && + test_trace2_data index extension/fsmn/read/token "$first_token" \ + <.git/repeat.trace && + ! test_trace2_data fsmonitor apply_count 1 \ + <.git/repeat.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/repeat.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'describe --dirty preserves closed semantic history' ' + test_when_finished "rm -rf describe-dirty-history" && + test_create_repo describe-dirty-history && + ( + cd describe-dirty-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + + test-tool chmtime +1 tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git describe --always --dirty >.git/describe && + test_grep ! dirty .git/describe && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/actual && + test_grep "nothing to commit, working tree clean" .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'clean stash push preserves closed semantic history' ' + test_when_finished "rm -rf stash-clean-history" && + test_create_repo stash-clean-history && + ( + cd stash-clean-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + + cp .git/index .git/index.before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ + git stash push >.git/stash && + test_grep "No local changes to save" .git/stash && + test_cmp .git/index.before .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/stash.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/actual && + test_grep "nothing to commit, working tree clean" .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace ) ' @@ -2162,6 +4000,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2265,6 +4104,682 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'read-only exact status skips a proof it cannot publish' ' + test_when_finished "rm -rf read-only-exact-true read-only-exact-false" && + for use_untracked_cache in true false + do + test_create_repo read-only-exact-$use_untracked_cache && + ( + cd read-only-exact-$use_untracked_cache && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache $use_untracked_cache && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=2 >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=2 >.git/prime-repeat && + test_path_is_missing .git/index.csts && + cp .git/index .git/before && + for label in first repeat + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace || return 1 + done && + test_cmp .git/before .git/index && + test_path_is_missing .git/index.csts && + if test_have_prereq MACOS + then + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/writable.trace" \ + git status --porcelain=v2 >.git/writable && + test_trace2_data status clean-proof/sidecar 1 \ + <.git/writable.trace && + test_path_is_file .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/hit.trace" \ + git status --porcelain=v2 >.git/hit && + test_trace2_data status clean-proof/hit 1 \ + <.git/hit.trace && + test_grep ! "\"label\":\"do_read_index\"" .git/hit.trace + fi + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-only status preserves an existing untracked proof' ' + test_when_finished "rm -rf tracked-only-untracked-proof" && + test_create_repo tracked-only-untracked-proof && + ( + cd tracked-only-untracked-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + test_grep "^? outside-new$" .git/prime-repeat && + test_grep FSUC .git/index && + git config status.showUntrackedFiles no && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/configured-first.trace" \ + git status --porcelain=v2 >.git/configured-first && + test_must_be_empty .git/configured-first && + test_grep FSUC .git/index && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/configured-first.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/configured-first.trace && + for label in exact plain short + do + case "$label" in + exact) set -- --porcelain=v2 ;; + plain) set -- ;; + short) set -- --short ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status "$@" >.git/$label && + test_grep ! "outside-new" .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace || return 1 + done && + test_write_lines selected >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT="$PWD/.git/hidden-new.trace" \ + git status --porcelain=v2 >.git/hidden-new && + test_trace2_data fsmonitor apply_count 1 \ + <.git/hidden-new.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/hidden-new-repeat.trace" \ + git status --porcelain=v2 >.git/hidden-new-repeat && + for label in hidden-new hidden-new-repeat + do + test_must_be_empty .git/$label && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + if test "$label" = hidden-new + then + test_grep "\"label\":\"do_write_index\"" \ + .git/$label.trace + else + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace + fi && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + --untracked-files=normal >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/visible.trace" \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual && + test_write_lines changed >tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/changed.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain=v2 >.git/changed.actual && + test_cmp .git/changed.expect .git/changed.actual && + test_grep "^1 \\.M .* tracked$" .git/changed.actual && + test_grep ! "outside-new" .git/changed.actual && + test_grep "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/changed.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ignored status preserves an existing untracked proof' ' + test_when_finished "rm -rf ignored-untracked-proof" && + test_create_repo ignored-untracked-proof && + ( + cd ignored-untracked-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test_write_lines "*.ignored" >.gitignore && + git add .gitignore && + git commit -m ignore && + test-tool chmtime -120 tracked scoped/tracked .gitignore && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines outside >outside-new && + test_write_lines ignored >skip.ignored && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + test_grep FSUC .git/index && + git config status.showUntrackedFiles normal && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + --ignored >.git/ignored.expect && + for label in first repeat + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 --ignored >.git/$label && + test_cmp .git/ignored.expect .git/$label && + test_grep FSUC .git/index && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace && + if test "$label" = repeat + then + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace + fi || return 1 + done && + test_grep "^? outside-new$" .git/repeat && + test_grep "^! skip\\.ignored$" .git/repeat && + test_write_lines selected >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain=v2 --ignored >.git/changed && + test_trace2_data fsmonitor apply_count 1 \ + <.git/changed.trace && + test_grep "^? scoped/new$" .git/changed && + test_grep "^! skip\\.ignored$" .git/changed && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual + ) +' + +test_expect_success UNTRACKED_CACHE,HARDLINKS,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'status modes revalidate cached exclude contents' ' + test_when_finished "rm -rf all-untracked-excludes" && + test_when_finished "rm -f all-untracked-exclude-alias" && + test_create_repo all-untracked-excludes && + ( + cd all-untracked-excludes && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines ignored >cached/.gitignore && + test_write_lines hidden >cached/ignored && + git add cached/.gitignore && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_must_be_empty .git/prime && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-fsmonitor && + test_must_be_empty .git/prime-fsmonitor && + ln cached/.gitignore ../all-untracked-exclude-alias && + mtime=$(test-tool chmtime --get cached/.gitignore) && + test_write_lines visible >../all-untracked-exclude-alias && + test-tool chmtime =$mtime cached/.gitignore && + cp .git/index .git/index.before && + for label in all-root all-directory all-exclude \ + normal-directory normal-exclude \ + tracked-root tracked-directory tracked-exclude \ + ignored-root ignored-directory ignored-exclude + do + cp .git/index.before .git/index && + case "$label" in + all-root) set -- --untracked-files=all ;; + all-directory) set -- --untracked-files=all -- cached ;; + all-exclude) \ + set -- --untracked-files=all -- cached/.gitignore ;; + normal-directory) set -- -- cached ;; + normal-exclude) set -- -- cached/.gitignore ;; + tracked-root) set -- --untracked-files=no ;; + tracked-directory) set -- --untracked-files=no -- cached ;; + tracked-exclude) \ + set -- --untracked-files=no -- cached/.gitignore ;; + ignored-root) set -- --ignored ;; + ignored-directory) set -- --ignored -- cached ;; + ignored-exclude) set -- --ignored -- cached/.gitignore ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 "$@" \ + >.git/$label.actual && + test_grep "^1 \\.M .* cached/.gitignore$" \ + .git/$label.actual && + test_trace2_data status \ + fsmonitor/exclude-index-invalidated 1 \ + <.git/$label.trace && + case "$label" in + all-root|all-directory|normal-directory|ignored-root|ignored-directory) + test_grep "^? cached/ignored$" \ + .git/$label.actual ;; + *) : ;; + esac || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked pathspecs reuse scoped fsmonitor proofs' ' + test_when_finished "rm -rf scoped-fsmonitor-proof" && + test_create_repo scoped-fsmonitor-proof && + ( + cd scoped-fsmonitor-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit root-prefix track && + test_commit root-longer tracked-extra && + mkdir scoped other && + test_commit selected scoped/tracked && + mkdir scoped/deep && + test_commit excluded scoped/deep/tracked && + test_commit unrelated other/tracked && + test-tool chmtime -120 track tracked tracked-extra scoped/tracked \ + scoped/deep/tracked other/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + git config status.showUntrackedFiles all && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/all-prime && + test_must_be_empty .git/all-prime && + test_write_lines nested >scoped/new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT="$PWD/.git/first.trace" \ + git status --porcelain=v2 -- tracked >.git/first && + test_must_be_empty .git/first && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + -- scoped/tracked/ >.git/trailing-first.expect \ + 2>.git/trailing-first.expect.err && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/trailing-first.trace" \ + git status --porcelain=v2 -- scoped/tracked/ \ + >.git/trailing-first.actual \ + 2>.git/trailing-first.actual.err && + test_cmp .git/trailing-first.expect \ + .git/trailing-first.actual && + test_cmp .git/trailing-first.expect.err \ + .git/trailing-first.actual.err && + test_grep "could not open directory" \ + .git/trailing-first.actual.err && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/trailing-first.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/trailing-first.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/trailing-first.trace && + for label in repeat repeated + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- tracked >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 -- scoped \ + ":(exclude)scoped/deep" >.git/directory-prime && + test_grep "^? scoped/new$" .git/directory-prime && + for label in directory directory-repeat mixed \ + excluded-glob excluded-icase + do + case "$label" in + directory|directory-repeat) \ + set -- scoped ":(exclude)scoped/deep" ;; + mixed) set -- tracked scoped ":(exclude)scoped/deep" ;; + excluded-glob) \ + set -- scoped ":(exclude,glob)scoped/deep/**" ;; + excluded-icase) \ + set -- scoped ":(exclude,icase)SCOPED/DEEP" ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" >.git/$label && + test_grep "^? scoped/new$" .git/$label && + test_line_count = 1 .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + test_write_lines deep >scoped/deep/new && + for label in excluded-self excluded-all-files + do + query_sequence=CCCC && + case "$label" in + excluded-self) query_sequence=DDCCC && + set -- scoped ":(exclude)scoped" ;; + excluded-all-files) \ + set -- scoped/deep ":(exclude)scoped/deep/tracked" ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=$query_sequence \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/deep/new \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" >.git/$label && + if test "$label" = excluded-self + then + test_must_be_empty .git/$label && + test_trace2_data fsmonitor apply_count 1 \ + <.git/$label.trace + else + test_grep "^? scoped/deep/new$" .git/$label && + test_line_count = 1 .git/$label + fi && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 -- scoped/deep \ + >.git/deep-prime && + test_grep "^? scoped/deep/new$" .git/deep-prime && + for label in nested-directory nested-cwd + do + status_dir=. && + case "$label" in + nested-directory) set -- scoped/deep/ ;; + nested-cwd) status_dir=scoped/deep && set -- . ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git -C "$status_dir" status --porcelain=v2 \ + -- "$@" >.git/$label && + if test "$label" = nested-cwd + then + test_grep "^? new$" .git/$label + else + test_grep "^? scoped/deep/new$" .git/$label + fi && + test_line_count = 1 .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 -- "track*" \ + >.git/root-wildcard-prime && + for label in wildcard wildcard-glob wildcard-deep \ + wildcard-mixed wildcard-excluded \ + wildcard-root wildcard-root-glob wildcard-root-question \ + wildcard-root-excluded untracked-exact untracked-missing \ + untracked-wildcard untracked-mixed nested-trailing + do + case "$label" in + wildcard) set -- "scoped/*" ;; + wildcard-glob) set -- ":(glob)scoped/*" ;; + wildcard-deep) set -- "scoped/deep/trac*" ;; + wildcard-mixed) set -- tracked "scoped/*" ;; + wildcard-excluded) \ + set -- "scoped/*" ":(exclude)scoped/deep" ;; + wildcard-root) set -- "track*" ;; + wildcard-root-glob) set -- ":(glob)track*" ;; + wildcard-root-question) set -- "track?*" ;; + wildcard-root-excluded) \ + set -- "track*" ":(exclude)tracked-extra" ;; + untracked-exact) set -- scoped/new ;; + untracked-missing) set -- missing ;; + untracked-wildcard) set -- "missing-*" ;; + untracked-mixed) set -- tracked scoped/new ;; + nested-trailing) set -- scoped/tracked/ ;; + esac && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 -- "$@" >.git/$label.expect \ + 2>.git/$label.expect.err && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" \ + >.git/$label.actual 2>.git/$label.actual.err && + test_cmp .git/$label.expect .git/$label.actual && + test_cmp .git/$label.expect.err .git/$label.actual.err && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label.trace && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'explicit all-untracked status retains configured normal history' ' + test_when_finished "rm -rf explicit-all-untracked-history" && + test_create_repo explicit-all-untracked-history && + ( + cd explicit-all-untracked-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit nested scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for label in first second third + do + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --untracked-files=all \ + >.git/$label.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 --untracked-files=all \ + >.git/$label.actual && + test_cmp .git/$label.expect .git/$label.actual && + if test "$label" != first + then + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/$label.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label.trace + fi || return 1 + done && + test_write_lines outside >outside-new && + test_write_lines nested >scoped/new && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --untracked-files=all \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + git status --porcelain=v2 --untracked-files=all \ + >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual && + test_grep "^? outside-new$" .git/visible.actual && + test_grep "^? scoped/new$" .git/visible.actual + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ignored submodule pathspecs avoid needless tracked refresh' ' + test_when_finished "rm -rf ignored-submodule-proof" && + test_create_repo ignored-submodule-proof && + ( + cd ignored-submodule-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit root tracked && + git init -q child && + git -C child config user.name "Submodule Fixture" && + git -C child config user.email fixture@example.invalid && + test_write_lines original >child/tracked && + git -C child add tracked && + git -C child commit -qm base && + git add child && + git commit -qm "add child gitlink" && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for label in prime repeat + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --ignore-submodules=all -- child \ + >.git/$label || return 1 + done && + for label in clean dirty committed staged + do + case "$label" in + dirty) test_write_lines modified >child/tracked ;; + committed) + git -C child add tracked && + git -C child commit -qm changed ;; + staged) + git add child && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --ignore-submodules=all -- child \ + >.git/staged-prime ;; + esac && + for shape in scoped root + do + if test "$shape" = scoped + then + set -- -- child + else + set -- + fi && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --ignore-submodules=all "$@" \ + >.git/$label-$shape.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label-$shape.trace" \ + git status --porcelain=v2 \ + --ignore-submodules=all "$@" \ + >.git/$label-$shape.actual && + test_cmp .git/$label-$shape.expect \ + .git/$label-$shape.actual && + if test "$label" != staged || test "$shape" != root + then + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label-$shape.trace && + test_grep ! \ + "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/$label-$shape.trace && + test_grep ! \ + "\"category\":\"index\",\"label\":\"preload\"" \ + .git/$label-$shape.trace + fi || return 1 + done || return 1 + done && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status \ + --porcelain=v2 --ignore-submodules=none -- child \ + >.git/visible.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --ignore-submodules=none -- child \ + >.git/visible.actual && + test_cmp .git/visible.expect .git/visible.actual + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'tracked-directory pathspec reads a closed untracked-cache subtree' ' test_when_finished "rm -rf pathspec-cached-subtree" && @@ -2273,9 +4788,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ cd pathspec-cached-subtree && sane_unset GIT_TEST_SPLIT_INDEX && test_commit base tracked && - mkdir scoped && + mkdir scoped scope scoped-extra aaa zzz && test_commit selected scoped/tracked && - test-tool chmtime -120 tracked scoped/tracked && + test_commit prefix scope/tracked && + test_commit extended scoped-extra/tracked && + test_commit before aaa/tracked && + test_commit after zzz/tracked && + test-tool chmtime -120 tracked scoped/tracked scope/tracked \ + scoped-extra/tracked aaa/tracked zzz/tracked && git update-index --refresh && git config core.untrackedCache true && git config core.fsmonitor true && @@ -2304,7 +4824,354 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git -C scoped status --porcelain=v2 -- . >.git/nested && test_grep "^? new$" .git/nested && test_trace2_data status untracked/pathspec-cache 1 \ - <.git/nested.trace + <.git/nested.trace && + for label in tracked-file nested-file tracked-files nested-files \ + root-trailing root-trailing-glob root-trailing-nested \ + root-trailing-top root-trailing-top-nested \ + excluded excluded-self excluded-first \ + glob glob-nested glob-excluded \ + excluded-wildcard excluded-icase excluded-attr \ + all ignored ignored-matching + do + status_dir=. && + case "$label" in + tracked-file) set -- -- scoped/tracked ;; + nested-file) status_dir=scoped && set -- -- tracked ;; + tracked-files) set -- -- tracked scoped/tracked ;; + nested-files) status_dir=scoped && \ + set -- -- tracked ../tracked ;; + root-trailing) set -- -- tracked/ ;; + root-trailing-glob) set -- -- ":(glob)tracked/" ;; + root-trailing-nested) status_dir=scoped && \ + set -- -- ../tracked/ ;; + root-trailing-top) set -- -- ":(top,literal)tracked//" ;; + root-trailing-top-nested) status_dir=scoped && \ + set -- -- ":(top)tracked//" ;; + excluded) set -- -- tracked ":(exclude)scoped/tracked" ;; + excluded-self) set -- -- tracked ":(exclude)tracked" ;; + excluded-first) set -- -- ":(exclude)scoped/tracked" tracked ;; + glob) set -- -- ":(glob)tracked" ;; + glob-nested) set -- -- ":(glob)scoped/tracked" ;; + glob-excluded) set -- -- ":(glob)tracked" \ + ":(exclude,glob)scoped/tracked" ;; + excluded-wildcard) set -- -- tracked \ + ":(exclude,glob)scoped/*" ;; + excluded-icase) set -- -- tracked \ + ":(exclude,icase)SCOPED/TRACKED" ;; + excluded-attr) set -- -- tracked \ + ":(exclude,attr:proof)scoped/tracked" ;; + all) set -- --untracked-files=all -- tracked scoped/tracked ;; + ignored) set -- --ignored -- tracked scoped/tracked ;; + ignored-matching) set -- --ignored=matching -- \ + tracked scoped/tracked ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git -C "$status_dir" status --porcelain=v2 \ + "$@" >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/$label.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + for label in excluded-only glob-wildcard positive-icase \ + mixed-ignored mixed-untracked mixed-directory + do + expect_untracked=outside-new && + case "$label" in + excluded-only) set -- -- ":(exclude)scoped/tracked" ;; + glob-wildcard) set -- -- ":(glob)*" ;; + positive-icase) set -- -- ":(icase)OUTSIDE-NEW" ;; + mixed-ignored) expect_untracked=scoped/new && \ + set -- --ignored -- tracked scoped ;; + mixed-untracked) set -- -- tracked outside-new ;; + mixed-directory) expect_untracked=scoped/new && \ + set -- -- tracked scoped ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 "$@" >.git/$label && + test_grep "^? $expect_untracked$" .git/$label && + test_grep "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-trailing.trace" \ + git status --porcelain=v2 -- scoped/tracked/ \ + >.git/nested-trailing \ + 2>.git/nested-trailing.err && + test_must_be_empty .git/nested-trailing && + test_grep "could not open directory" \ + .git/nested-trailing.err && + test_grep "\"label\":\"read_directory\"" \ + .git/nested-trailing.trace && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-repeated.trace" \ + test_must_fail git status --porcelain=v2 -- \ + ":(top)scoped//tracked" \ + >.git/nested-repeated 2>.git/nested-repeated.err && + test_grep "fatal: oops in prep_exclude" \ + .git/nested-repeated.err && + rm scoped/tracked && + mkdir scoped/tracked && + test_write_lines child >scoped/tracked/new && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + -- scoped/tracked >.git/tracked-file-dirty.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/tracked-file-dirty.trace" \ + git status --porcelain=v2 -- scoped/tracked \ + >.git/tracked-file-dirty.actual && + test_cmp .git/tracked-file-dirty.expect \ + .git/tracked-file-dirty.actual && + test_grep "scoped/tracked" .git/tracked-file-dirty.actual && + test_grep "\"label\":\"read_directory\"" \ + .git/tracked-file-dirty.trace + ) +' + +assert_clean_tracked_status () { + label=$1 && + directory=$2 && + shift 2 && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -C "$directory" status "$@" >".git/$label.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git -C "$directory" status "$@" >".git/$label.actual" && + test_cmp_bin ".git/$label.expect" ".git/$label.actual" && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <".git/$label.trace" && + test_trace2_data status index/cache-tree-match 1 \ + <".git/$label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + ".git/$label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + ".git/$label.trace" +} + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked file pathspecs avoid traversal without an untracked cache' ' + test_when_finished "rm -rf pathspec-no-untracked-cache" && + test_create_repo pathspec-no-untracked-cache && + ( + cd pathspec-no-untracked-cache && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped && + test_commit selected scoped/tracked && + test-tool chmtime -120 tracked scoped/tracked && + git update-index --refresh && + git config core.untrackedCache false && + git config core.fsmonitor true && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor --no-untracked-cache && + test_grep ! UNTR .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 --untracked-files=normal \ + >.git/prime-repeat && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/exact.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/exact.trace" \ + git status --porcelain=v2 >.git/exact.actual && + test_cmp .git/exact.expect .git/exact.actual && + test_grep "^? outside-new$" .git/exact.actual && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/exact.trace && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/exact.trace && + test_grep ! "\"category\":\"index\",\"label\":\"preload\"" \ + .git/exact.trace && + for label in one multiple excluded glob glob-excluded \ + excluded-special root-trailing root-trailing-top + do + case "$label" in + one) set -- scoped/tracked ;; + multiple) set -- tracked scoped/tracked ;; + excluded) set -- tracked ":(exclude)scoped/tracked" ;; + glob) set -- ":(glob)scoped/tracked" ;; + glob-excluded) set -- ":(glob)tracked" \ + ":(exclude,glob)scoped/tracked" ;; + excluded-special) set -- tracked \ + ":(exclude,glob)scoped/*" \ + ":(exclude,icase)SCOPED/TRACKED" \ + ":(exclude,attr:proof)scoped/tracked" ;; + root-trailing) set -- tracked/ ;; + root-trailing-top) set -- ":(top,literal)tracked//" ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 -- "$@" >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/$label.trace && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/$label.trace && + test_grep ! "\"label\":\"read_directory\"" \ + .git/$label.trace || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/mixed.trace" \ + git status --porcelain=v2 -- tracked outside-new \ + >.git/mixed && + test_grep "^? outside-new$" .git/mixed && + test_grep "\"label\":\"read_directory\"" .git/mixed.trace && + test_write_lines changed >scoped/tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/closing-dirty.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/closing-dirty.trace" \ + git status --porcelain=v2 >.git/closing-dirty.actual && + test_cmp .git/closing-dirty.expect .git/closing-dirty.actual && + test_grep "^1 \\.M .* scoped/tracked$" \ + .git/closing-dirty.actual && + test_grep "^? outside-new$" .git/closing-dirty.actual && + test_grep ! "\"key\":\"fsmonitor/tracked-clean\"" \ + .git/closing-dirty.trace && + test_grep "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/closing-dirty.trace && + test_write_lines selected >scoped/tracked && + test-tool chmtime -120 scoped/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + git update-index --refresh && + rm outside-new && + git config core.preloadIndex false && + for label in prime prime-repeat + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ + --untracked-files=normal >.git/clean-$label && + test_must_be_empty .git/clean-$label || return 1 + done && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean-issue.trace" \ + git status --porcelain=v2 >.git/clean-issue && + test_must_be_empty .git/clean-issue && + if test_have_prereq MACOS + then + test_trace2_data status clean-proof/sidecar 1 \ + <.git/clean-issue.trace && + test_path_is_file .git/index.csts && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean-hit.trace" \ + git status --porcelain=v2 >.git/clean-hit && + test_must_be_empty .git/clean-hit && + test_trace2_data status clean-proof/hit 1 \ + <.git/clean-hit.trace && + test_grep ! "\"label\":\"do_read_index\"" \ + .git/clean-hit.trace + fi + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'clean tracked entries avoid refresh across dirty status shapes' ' + test_when_finished "rm -rf tracked-clean-status-shapes" && + test_create_repo tracked-clean-status-shapes && + ( + cd tracked-clean-status-shapes && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir scoped scoped-extra sibling && + test_commit selected scoped/tracked && + test_commit colliding scoped-extra/tracked && + test_commit other sibling/tracked && + test-tool chmtime -120 \ + tracked scoped/tracked scoped-extra/tracked sibling/tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines selected >scoped/new && + test_write_lines sibling >sibling/new && + test_write_lines outside >outside-new && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_grep "^? scoped/new$" .git/prime && + test_grep "^? sibling/new$" .git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime-repeat && + test_cmp .git/prime .git/prime-repeat && + test_path_is_missing .git/index.csts && + + assert_clean_tracked_status root-long . && + assert_clean_tracked_status root-short . --short && + assert_clean_tracked_status root-porcelain . --porcelain && + assert_clean_tracked_status root-v2-exact . --porcelain=v2 && + assert_clean_tracked_status root-v2 . \ + --porcelain=v2 --untracked-files=normal && + assert_clean_tracked_status root-daemon . \ + --porcelain=v2 -z --branch --show-stash \ + --no-ahead-behind --untracked-files=normal \ + --ignore-submodules=all && + assert_clean_tracked_status scoped-long . -- scoped && + assert_clean_tracked_status scoped-v2 . \ + --porcelain=v2 -- scoped && + assert_clean_tracked_status sibling-v2 . \ + --porcelain=v2 -- sibling && + assert_clean_tracked_status nested-root scoped \ + --porcelain=v2 --untracked-files=normal && + assert_clean_tracked_status nested-scoped scoped \ + --porcelain=v2 -- . && + assert_clean_tracked_status multiple-v2 . \ + --porcelain=v2 -- scoped sibling && + test_grep "^? scoped/new$" .git/scoped-v2.actual && + test_grep ! "sibling/new\|outside-new" \ + .git/scoped-v2.actual && + test_grep "^? sibling/new$" .git/sibling-v2.actual && + test_grep ! "scoped/new\|outside-new" \ + .git/sibling-v2.actual && + test_grep "^? new$" .git/nested-scoped.actual && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/scoped-v2.trace && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/sibling-v2.trace && + test_trace2_data status untracked/pathspec-cache 1 \ + <.git/nested-scoped.trace && + + test_write_lines changed >scoped/tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/closing-dirty.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/closing-dirty.trace" \ + git status --porcelain=v2 >.git/closing-dirty.actual && + test_cmp .git/closing-dirty.expect .git/closing-dirty.actual && + test_grep "^1 \\.M .* scoped/tracked$" .git/closing-dirty.actual && + test_grep ! "\"key\":\"fsmonitor/tracked-clean\"" \ + .git/closing-dirty.trace && + + test_write_lines changed >scoped/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/dirty-tracked.trace" \ + git status --porcelain=v2 -- scoped \ + >.git/dirty-tracked.actual && + test_grep "^1 \\.M .* scoped/tracked$" \ + .git/dirty-tracked.actual && + test_grep "^? scoped/new$" .git/dirty-tracked.actual && + test_grep ! "sibling/new\|outside-new" \ + .git/dirty-tracked.actual && + ! test_trace2_data status fsmonitor/tracked-clean 1 \ + <.git/dirty-tracked.trace ) ' @@ -2323,6 +5190,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_write_lines "ignored-dir/" >scoped/.gitignore && git add .gitignore scoped/.gitignore && git commit -qm "add tracked ignore files" && + test-tool chmtime -120 tracked scoped/tracked outside/tracked \ + .gitignore scoped/.gitignore && + git update-index --refresh && git config core.untrackedCache true && git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ @@ -2348,6 +5218,17 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/created.trace && test_grep ! "\"label\":\"read_directory\"" \ .git/created.trace && + test_grep "\"label\":\"do_write_index\"" \ + .git/created.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=scoped/new \ + GIT_TRACE2_EVENT_NESTING=5 \ + GIT_TRACE2_EVENT="$PWD/.git/created-repeat.trace" \ + git status --porcelain=v2 -- scoped >.git/created-repeat && + test_cmp .git/created .git/created-repeat && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/created-repeat.trace && rm scoped/new && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ @@ -2366,6 +5247,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/removed.trace && test_grep ! "\"label\":\"read_directory\"" \ .git/removed.trace && + test_grep "\"label\":\"do_write_index\"" \ + .git/removed.trace && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT_NESTING=5 \ @@ -2390,6 +5273,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2422,6 +5306,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2458,6 +5343,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2497,6 +5383,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2526,6 +5413,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2563,6 +5451,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -2588,6 +5477,245 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,HARDLINKS,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a lost fsmonitor token reuses an authenticated external checkpoint' ' + test_when_finished "rm -rf missing-fsmonitor-token-checkpoint" && + test_create_repo missing-fsmonitor-token-checkpoint && + ( + cd missing-fsmonitor-token-checkpoint && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "/ignored/" >.gitignore && + printf "aaaa\\n" >tracked && + git add .gitignore tracked && + git commit -m base && + mkdir ignored && + ln tracked ignored/alias && + test-tool chmtime -120 tracked .gitignore && + git update-index --refresh && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/materialized && + test_must_be_empty .git/materialized && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --porcelain=v2 >.git/checkpoint && + test_must_be_empty .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + cp .git/index .git/missing.index && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/clean.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status --porcelain=v2 >.git/clean.actual && + test_cmp .git/clean.expect .git/clean.actual && + test_trace2_data fsmonitor history/external-fsmn-recovered 1 \ + <.git/clean.trace && + test_trace2_data fsmonitor history/external-restored 1 \ + <.git/clean.trace && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/clean.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/clean.trace && + ! test_trace2_data fsmonitor semantic/token-reset-stat-baseline 1 \ + <.git/clean.trace && + + mtime=$(test-tool chmtime --get tracked) && + printf "bbbb\\n" >ignored/alias && + test-tool chmtime =$mtime ignored/alias && + test "$(git hash-object tracked)" != \ + "$(git rev-parse HEAD:tracked)" && + cp .git/missing.index .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=ignored/alias \ + GIT_TRACE2_EVENT="$PWD/.git/dirty.trace" \ + git status --porcelain=v2 >.git/dirty.actual && + test_grep "^1 \\.M .* tracked$" .git/dirty.actual + ) +' + +test_expect_success UNTRACKED_CACHE,HARDLINKS,PERL,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a lost token never trusts an unwatched tracked hardlink' ' + test_when_finished \ + "rm -rf missing-fsmonitor-token-hardlink missing-fsmonitor-token.alias" && + test_create_repo missing-fsmonitor-token-hardlink && + ( + cd missing-fsmonitor-token-hardlink && + sane_unset GIT_TEST_SPLIT_INDEX && + printf "aaaa\\n" >tracked && + test_write_lines stable >sibling && + git add tracked sibling && + git commit -m base && + ln tracked ../missing-fsmonitor-token.alias && + test-tool chmtime -120 tracked sibling && + git update-index --refresh && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + rm -f .git/index.csh1.* .git/index.cswi.* .git/index.csts && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + mtime=$(test-tool chmtime --get tracked) && + for attempt in 1 2 3 4 5 + do + printf "aaaa\\n" \ + >../missing-fsmonitor-token.alias && + test-tool chmtime =$mtime \ + ../missing-fsmonitor-token.alias && + git -c core.fsmonitor=false update-index \ + --refresh --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + ctime=$(perl -e "print((stat(shift))[10])" tracked) && + printf "bbbb\\n" \ + >../missing-fsmonitor-token.alias && + test-tool chmtime =$mtime \ + ../missing-fsmonitor-token.alias && + if test "$(perl -e "print((stat(shift))[10])" tracked)" = \ + "$ctime" + then + break + fi || return 1 + done && + test "$(perl -e "print((stat(shift))[10])" tracked)" = \ + "$ctime" && + test "$(git hash-object tracked)" != \ + "$(git rev-parse HEAD:tracked)" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \\.M .* tracked$" .git/actual && + test_line_count = 1 .git/actual && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor semantic/token-reset-stat-baseline 1 \ + <.git/recovery.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/recovery.trace + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a missing fsmonitor token reuses strong tracked-file stat identities' ' + test_when_finished "rm -rf missing-fsmonitor-token-strong" && + test_create_repo missing-fsmonitor-token-strong && + ( + cd missing-fsmonitor-token-strong && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + rm -f .git/index.csh1.* .git/index.cswi.* .git/index.csts && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor semantic/token-reset-stat-baseline 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/recovery.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/recovery.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/recovery.trace + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a missing fsmonitor token cannot trust weak tracked-file identities' ' + test_when_finished "rm -rf missing-fsmonitor-token-weak" && + test_create_repo missing-fsmonitor-token-weak && + ( + cd missing-fsmonitor-token-weak && + sane_unset GIT_TEST_SPLIT_INDEX && + printf "aaaa\\n" >tracked && + git add tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + test-tool chmtime =-60 tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked) && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + rm -f .git/index.csh1.* .git/index.cswi.* .git/index.csts && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + printf "bbbb\\n" >tracked && + test-tool chmtime =$mtime tracked && + test "$(git hash-object tracked)" != \ + "$(git rev-parse HEAD:tracked)" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \\.M .* tracked$" .git/actual && + ! test_trace2_data fsmonitor semantic/token-reset-stat-baseline 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/recovery.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'missing semantic history seeds a forward baseline' ' test_when_finished \ @@ -2604,6 +5732,7 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor-valid tracked && test_grep ! FSCF .git/index && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status --porcelain=v2 >.git/actual && @@ -2663,6 +5792,7 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && @@ -3046,6 +6176,751 @@ test_expect_success UNTRACKED_CACHE,!MINGW,!CYGWIN \ ) ' +prepare_deleted_attribute_repo () { + test_create_repo "$1" && + ( + cd "$1" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir scoped sibling && + test_write_lines "*.txt -text" >.gitattributes && + test_write_lines "*.txt -text" >scoped/.gitattributes && + test_write_lines root >tracked.txt && + test_write_lines scoped >scoped/tracked.txt && + test_write_lines sibling >sibling/tracked.txt && + git add .gitattributes scoped sibling tracked.txt && + git commit -qm base && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index + ) +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'deleting identical tracked attributes preserves root and nested proofs' ' + test_when_finished "rm -rf deleted-attributes-root deleted-attributes-nested" && + for scope in root nested + do + repo=deleted-attributes-$scope && + prepare_deleted_attribute_repo "$repo" && + ( + cd "$repo" && + if test "$scope" = root + then + path=.gitattributes + else + path=scoped/.gitattributes + fi && + rm "$path" && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH="$path" \ + GIT_TRACE2_EVENT="$PWD/.git/deleted.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_line_count = 1 .git/actual && + test_grep "^1 \\.D .* $path$" .git/actual && + test_trace2_data fsmonitor \ + semantic/manifest-reconciled 1 <.git/deleted.trace && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-count <.git/deleted.trace && + ! have_t2_data_event fsmonitor \ + semantic/attributes-scope <.git/deleted.trace && + ! have_t2_data_event fsmonitor \ + semantic/attributes-cone <.git/deleted.trace && + ! test_trace2_data fsmonitor \ + semantic/strong-invalidation 1 <.git/deleted.trace && + ! test_trace2_data index \ + preload/sum_lstat "[2-9][0-9]*" \ + <.git/deleted.trace && + ! test_trace2_data index \ + preload/sum_lstat "1[0-9][0-9]*" \ + <.git/deleted.trace && + ! test_trace2_data index \ + refresh/sum_lstat "[2-9][0-9]*" \ + <.git/deleted.trace && + ! test_trace2_data index \ + refresh/sum_lstat "1[0-9][0-9]*" \ + <.git/deleted.trace && + git show "HEAD:$path" >"$path" && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/restored.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH="$path" \ + GIT_TRACE2_EVENT="$PWD/.git/restored.trace" \ + git status --porcelain=v2 >.git/restored.actual && + test_cmp .git/restored.expect .git/restored.actual && + test_must_be_empty .git/restored.actual && + test_trace2_data fsmonitor \ + semantic/manifest-reconciled 1 \ + <.git/restored.trace && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-count <.git/restored.trace && + ! have_t2_data_event fsmonitor \ + semantic/attributes-scope <.git/restored.trace && + ! test_trace2_data index \ + refresh/sum_lstat "[2-9][0-9]*" \ + <.git/restored.trace && + ! test_trace2_data index \ + refresh/sum_lstat "1[0-9][0-9]*" \ + <.git/restored.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/repeated.trace" \ + git status --porcelain=v2 >.git/repeated.actual && + test_must_be_empty .git/repeated.actual && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-count <.git/repeated.trace && + ! have_t2_data_event fsmonitor \ + semantic/attributes-scope <.git/repeated.trace + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'attribute deletion reuses a checkpoint when the index lost its token' ' + test_when_finished \ + "rm -rf deleted-checkpoint-root deleted-checkpoint-nested" && + for scope in root nested + do + repo=deleted-checkpoint-$scope && + prepare_deleted_attribute_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + test-tool chmtime -120 .gitattributes \ + scoped/.gitattributes tracked.txt \ + scoped/tracked.txt sibling/tracked.txt && + git -c core.fsmonitor=false update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/materialized && + test_must_be_empty .git/materialized && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --porcelain=v2 >.git/checkpoint && + test_must_be_empty .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + if test "$scope" = root + then + path=.gitattributes + else + path=scoped/.gitattributes + fi && + rm "$path" && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH="$path" \ + GIT_TRACE2_EVENT="$PWD/.git/deleted.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_line_count = 1 .git/actual && + test_grep "^1 \\.D .* $path$" .git/actual && + test_trace2_data fsmonitor \ + history/external-fsmn-recovered 1 \ + <.git/deleted.trace && + test_trace2_data fsmonitor \ + semantic/manifest-reconciled 1 \ + <.git/deleted.trace && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-count <.git/deleted.trace && + ! have_t2_data_event fsmonitor \ + semantic/attributes-scope <.git/deleted.trace && + ! test_trace2_data fsmonitor \ + history/external-proof-invalidated 1 \ + <.git/deleted.trace && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + git show "HEAD:$path" >"$path" && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/restored.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH="$path" \ + GIT_TRACE2_EVENT="$PWD/.git/restored.trace" \ + git status --porcelain=v2 >.git/restored.actual && + test_cmp .git/restored.expect .git/restored.actual && + test_must_be_empty .git/restored.actual && + test_trace2_data fsmonitor \ + history/external-fsmn-recovered 1 \ + <.git/restored.trace && + test_trace2_data fsmonitor \ + semantic/manifest-reconciled 1 \ + <.git/restored.trace && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-count <.git/restored.trace && + ! have_t2_data_event fsmonitor \ + semantic/attributes-scope <.git/restored.trace && + ! test_trace2_data fsmonitor \ + history/external-proof-invalidated 1 \ + <.git/restored.trace + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a changed attribute fallback cannot resurrect a lost-token checkpoint' ' + test_when_finished "rm -rf deleted-checkpoint-changed" && + prepare_deleted_attribute_repo deleted-checkpoint-changed && + ( + cd deleted-checkpoint-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + test-tool chmtime -120 .gitattributes \ + scoped/.gitattributes tracked.txt scoped/tracked.txt \ + sibling/tracked.txt && + git -c core.fsmonitor=false update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + test_write_lines "*.txt text eol=crlf" >.gitattributes && + test-tool chmtime -120 .gitattributes && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git status --porcelain=v2 >.git/materialized && + test_grep "^1 \\.M .* .gitattributes$" .git/materialized && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --porcelain=v2 >.git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + rm .gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/deleted.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.D .* .gitattributes$" .git/actual && + test_trace2_data fsmonitor history/external-proof-invalidated 1 \ + <.git/deleted.trace && + test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/deleted.trace && + ! test_trace2_data fsmonitor \ + history/external-fsmn-recovered 1 <.git/deleted.trace && + ! test_trace2_data fsmonitor semantic/manifest-reconciled 1 \ + <.git/deleted.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'deleting changed tracked attributes still invalidates their scope' ' + test_when_finished "rm -rf deleted-attributes-changed" && + prepare_deleted_attribute_repo deleted-attributes-changed && + ( + cd deleted-attributes-changed && + test_write_lines "*.txt text eol=crlf" >.gitattributes && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git status --porcelain=v2 >.git/changed && + test_grep "^1 \\.M .* .gitattributes$" .git/changed && + test_grep FSCF .git/index && + rm .gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/deleted.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.D .* .gitattributes$" .git/actual && + test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/deleted.trace && + ! test_trace2_data fsmonitor semantic/manifest-reconciled 1 \ + <.git/deleted.trace + ) +' + +test_expect_success SYMLINKS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'an attribute symlink cannot impersonate an indexed fallback' ' + test_when_finished "rm -rf deleted-attributes-symlink" && + prepare_deleted_attribute_repo deleted-attributes-symlink && + ( + cd deleted-attributes-symlink && + rm .gitattributes && + ln -s tracked.txt .gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/symlink.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.T .* .gitattributes$" .git/actual && + test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/symlink.trace && + ! test_trace2_data fsmonitor semantic/manifest-reconciled 1 \ + <.git/symlink.trace + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'diff skips content verification for display-only root attributes' ' + test_when_finished "rm -rf display-only-root-attributes" && + test_create_repo display-only-root-attributes && + ( + cd display-only-root-attributes && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "*.txt -text" >.gitattributes && + test_write_lines alpha >tracked.txt && + test_write_lines beta >sibling.txt && + for attribute_dir in $(test_seq 1 128) + do + mkdir "nested-$attribute_dir" && + test_write_lines "*.txt -text" \ + >"nested-$attribute_dir/.gitattributes" && + test_write_lines "$attribute_dir" \ + >"nested-$attribute_dir/tracked.txt" || return 1 + done && + git add .gitattributes tracked.txt sibling.txt nested-* && + git commit -m base && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + test_write_lines "*.gen linguist-generated" \ + >>.gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >.git/display.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCC \ + GIT_TRACE2_EVENT="$PWD/.git/display.trace" \ + git diff >.git/display.actual && + test_cmp .git/display.expect .git/display.actual && + test_grep "^+\\*.gen linguist-generated$" \ + .git/display.actual && + test_trace2_data fsmonitor semantic/nonconversion-attributes 1 \ + <.git/display.trace && + test_trace2_data fsmonitor semantic/manifest-changed 1 \ + <.git/display.trace && + ! test_trace2_data fsmonitor apply/global-invalidation 1 \ + <.git/display.trace && + ! test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/display.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/display.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/display.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/display.trace && + + # Preserve an authenticated checkpoint from the unchanged old + # index before an unstaged, presentation-only root change. + git show HEAD:.gitattributes >.gitattributes && + test-tool chmtime -120 .gitattributes tracked.txt sibling.txt \ + nested-*/*.txt nested-*/.gitattributes && + git -c core.fsmonitor=false update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/old-display-materialized && + test_must_be_empty .git/old-display-materialized && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/old-display-checkpoint.trace" \ + git status --porcelain=v2 >.git/old-display-checkpoint && + test_must_be_empty .git/old-display-checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/old-display-checkpoint.trace && + old_display_attributes=$(git rev-parse :.gitattributes) && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + test_write_lines "*.gen linguist-generated" \ + >>.gitattributes && + test "$old_display_attributes" = \ + "$(git rev-parse :.gitattributes)" && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >.git/old-display.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDDCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/old-display.trace" \ + git diff >.git/old-display.actual && + test_cmp .git/old-display.expect .git/old-display.actual && + test_grep "^+\\*.gen linguist-generated$" \ + .git/old-display.actual && + test_trace2_data fsmonitor history/external-restored 1 \ + <.git/old-display.trace && + test_trace2_data fsmonitor \ + history/external-fsmn-recovered 1 \ + <.git/old-display.trace && + test_trace2_data fsmonitor semantic/nonconversion-attributes 1 \ + <.git/old-display.trace && + test_trace2_data fsmonitor \ + semantic/nonconversion-attribute-replayed 1 \ + <.git/old-display.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/old-display.trace && + ! test_trace2_data fsmonitor semantic/manifest-candidates 129 \ + <.git/old-display.trace && + ! test_trace2_data fsmonitor apply/global-invalidation 1 \ + <.git/old-display.trace && + ! test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/old-display.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/old-display.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/old-display.trace && + + for boundary in conversion macro + do + if test "$boundary" = conversion + then + test_write_lines "*.txt text eol=crlf" \ + >.gitattributes + else + test_write_lines \ + "[attr]linguist-generated filter=custom" \ + "*.gen linguist-generated" \ + >.gitattributes + fi && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >".git/$boundary.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDDCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/$boundary.trace" \ + git diff >".git/$boundary.actual" && + test_cmp ".git/$boundary.expect" \ + ".git/$boundary.actual" && + test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <".git/$boundary.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <".git/$boundary.trace" && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <".git/$boundary.trace" && + ! test_trace2_data fsmonitor \ + semantic/nonconversion-attribute-replayed 1 \ + <".git/$boundary.trace" && + ! test_trace2_data fsmonitor \ + semantic/nonconversion-attributes 1 \ + <".git/$boundary.trace" || return 1 + done && + + test_write_lines "*.txt text eol=crlf" >.gitattributes && + test_must_fail env \ + GIT_TRACE2_EVENT="$PWD/.git/scoped-attributes.trace" \ + git update-index --refresh -- .gitattributes && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <.git/scoped-attributes.trace && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >.git/scoped-attributes.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCC \ + GIT_TRACE2_EVENT="$PWD/.git/scoped-attributes-diff.trace" \ + git diff >.git/scoped-attributes.actual && + test_cmp .git/scoped-attributes.expect \ + .git/scoped-attributes.actual && + test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/scoped-attributes-diff.trace && + ! test_trace2_data fsmonitor \ + semantic/nonconversion-attributes 1 \ + <.git/scoped-attributes-diff.trace && + + git show HEAD:.gitattributes >.gitattributes && + cp .git/index .git/old-index && + test_write_lines \ + "*.one linguist-generated" \ + "*.two linguist-generated" \ + "*.three linguist-generated" \ + "*.four linguist-generated" \ + >>.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git add .gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git commit -qm generated && + new_attributes=$(git rev-parse HEAD:.gitattributes) && + history_base=$(git rev-parse HEAD) && + { + for unrelated in $(test_seq 1 1038) + do + printf "commit refs/heads/linguist-history\\n" && + printf "mark :%s\\n" "$unrelated" && + printf "committer Test 1112911993 +0000\\n" && + printf "data 9\\nunrelated\\n" && + if test "$unrelated" = 1 + then + printf "from %s\\n\\n" "$history_base" + else + previous=$((unrelated - 1)) && + printf "from :%s\\n\\n" "$previous" + fi || return 1 + done + } >.git/history.stream && + git fast-import --quiet <.git/history.stream && + git update-ref "$(git symbolic-ref HEAD)" \ + "$(git rev-parse refs/heads/linguist-history)" && + git commit-graph write --reachable --changed-paths && + test "$(git rev-parse HEAD:.gitattributes)" = \ + "$new_attributes" && + test "$(git rev-parse HEAD^:.gitattributes)" = \ + "$new_attributes" && + test "$(git rev-parse HEAD~1038:.gitattributes)" = \ + "$new_attributes" && + test "$(git rev-parse HEAD~1039:.gitattributes)" != \ + "$new_attributes" && + cp .git/old-index .git/index && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor \ + --cacheinfo "100644,$new_attributes,.gitattributes" \ + --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff --cached \ + >.git/committed-index && + test_must_be_empty .git/committed-index && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >.git/committed.expect && + test_must_be_empty .git/committed.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCC \ + GIT_TRACE2_EVENT="$PWD/.git/committed.trace" \ + git diff >.git/committed.actual && + test_cmp .git/committed.expect .git/committed.actual && + test_trace2_data fsmonitor semantic/nonconversion-attributes 1 \ + <.git/committed.trace && + test_trace2_data fsmonitor \ + semantic/attribute-history-commits 1039 \ + <.git/committed.trace && + test_trace2_data fsmonitor \ + semantic/attribute-history-bloom-skips 1038 \ + <.git/committed.trace && + test_trace2_data fsmonitor semantic/manifest-changed 1 \ + <.git/committed.trace && + ! test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/committed.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/committed.trace && + + # Keep the same logical index but checkpoint the old worktree + # attributes, as an earlier dirty command would have done. + test-tool chmtime -120 .gitattributes tracked.txt sibling.txt \ + nested-*/*.txt nested-*/.gitattributes && + git -c core.fsmonitor=false update-index --refresh && + git show HEAD~1039:.gitattributes >.gitattributes && + test-tool chmtime -120 .gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git status --porcelain=v2 >.git/old-materialized && + test_grep "^1 \\.M .* .gitattributes$" \ + .git/old-materialized && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/old-checkpoint.trace" \ + git status --porcelain=v2 >.git/old-checkpoint && + test_cmp .git/old-materialized .git/old-checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/old-checkpoint.trace && + find .git -maxdepth 1 -type f -name "index.csh1.*" \ + >.git/old-checkpoints && + test_line_count = 1 .git/old-checkpoints && + rm -f .git/index.csts && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor --force-write-index && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + git show HEAD:.gitattributes >.gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >.git/checkpoint.expect && + test_must_be_empty .git/checkpoint.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDDCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git diff >.git/checkpoint.actual && + test_cmp .git/checkpoint.expect .git/checkpoint.actual && + test_trace2_data fsmonitor history/external-restored 1 \ + <.git/checkpoint.trace && + test_trace2_data fsmonitor \ + history/external-fsmn-recovered 1 \ + <.git/checkpoint.trace && + test_trace2_data fsmonitor semantic/nonconversion-attributes 1 \ + <.git/checkpoint.trace && + test_trace2_data fsmonitor \ + semantic/attribute-history-commits 1039 \ + <.git/checkpoint.trace && + test_trace2_data fsmonitor \ + semantic/attribute-history-bloom-skips 1038 \ + <.git/checkpoint.trace && + ! test_trace2_data fsmonitor semantic/attributes-scope 0 \ + <.git/checkpoint.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/checkpoint.trace && + + # A writer can also advance the staged attribute blob after the + # checkpoint. Its token and tracked bitmap no longer authenticate + # the named index, but the old attribute manifest remains useful. + if test_have_prereq MACOS + then + test-tool chmtime -120 \ + .gitattributes tracked.txt sibling.txt \ + nested-*/*.txt nested-*/.gitattributes && + git -c core.fsmonitor=false update-index --refresh && + git show HEAD~1039:.gitattributes >.gitattributes && + old_attributes=$(git rev-parse HEAD~1039:.gitattributes) && + test-tool chmtime -120 .gitattributes && + git -c core.fsmonitor=false update-index \ + --cacheinfo "100644,$old_attributes,.gitattributes" \ + --force-write-index && + test "$(git rev-parse :.gitattributes)" = \ + "$old_attributes" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git status --porcelain=v2 \ + >.git/advanced-materialized && + test_grep "^1 M\\. .* .gitattributes$" \ + .git/advanced-materialized && + test_grep FSCF .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/advanced-checkpoint.trace" \ + git status --porcelain=v2 \ + >.git/advanced-checkpoint && + test_cmp .git/advanced-materialized \ + .git/advanced-checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/advanced-checkpoint.trace && + rm -f .git/index.csts && + git -c core.fsmonitor=false update-index \ + --no-fsmonitor \ + --cacheinfo \ + "100644,$new_attributes,.gitattributes" \ + --force-write-index && + test "$(git rev-parse :.gitattributes)" = \ + "$new_attributes" && + test_grep ! FSMN .git/index && + test_grep FSCF .git/index && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index && + git show HEAD:.gitattributes >.gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >.git/advanced.expect && + test_must_be_empty .git/advanced.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DTCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/advanced.trace" \ + git diff >.git/advanced.actual && + test_cmp .git/advanced.expect .git/advanced.actual && + test_trace2_data fsmonitor \ + history/external-bootstrap-manifest 1 \ + <.git/advanced.trace && + test_trace2_data fsmonitor \ + semantic/nonconversion-attributes 1 \ + <.git/advanced.trace && + test_trace2_data fsmonitor \ + semantic/attribute-history-commits 1039 \ + <.git/advanced.trace && + test_trace2_data fsmonitor \ + semantic/attribute-history-bloom-skips 1038 \ + <.git/advanced.trace && + test_trace2_data fsmonitor \ + semantic/token-reset-stat-baseline 1 \ + <.git/advanced.trace && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <.git/advanced.trace && + ! test_trace2_data fsmonitor \ + history/external-fsmn-recovered 1 \ + <.git/advanced.trace && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-count \ + <.git/advanced.trace && + ! test_trace2_data fsmonitor \ + semantic/strong-invalidation 1 \ + <.git/advanced.trace && + + test_write_lines "*.txt text eol=crlf" \ + >.gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >.git/advanced-conversion.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DTCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/advanced-conversion.trace" \ + git diff >.git/advanced-conversion.actual && + test_cmp .git/advanced-conversion.expect \ + .git/advanced-conversion.actual && + ! test_trace2_data fsmonitor \ + history/external-bootstrap-manifest 1 \ + <.git/advanced-conversion.trace && + ! test_trace2_data fsmonitor \ + semantic/nonconversion-attributes 1 \ + <.git/advanced-conversion.trace && + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <.git/advanced-conversion.trace + fi + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'tracked attribute events reopen semantic history' ' test_when_finished "rm -rf tracked-attr-change" && @@ -3063,6 +6938,7 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.checkStat minimal && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ git status --porcelain=v2 >.git/prime.actual && test_must_be_empty .git/prime.actual && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index eddcc3e8d08f51..1435fb94dadfad 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -55,7 +55,8 @@ prime_semantic_history () { repo=$1 && bulk_status -C "$repo" status --porcelain=2 >actual.1 && test_must_be_empty actual.1 && - bulk_status -C "$repo" status --porcelain=2 >actual.2 && + test_env GIT_INDEX_FILE="$PWD/$repo/.git/index" \ + bulk_status -C "$repo" status --porcelain=2 >actual.2 && test_must_be_empty actual.2 && test_grep FSCF "$repo/.git/index" && rm -f "$repo"/.git/index.csh1.* @@ -285,6 +286,675 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'ordinary clean status installs its first missing sidecar' ' + test_when_finished "stop_daemon sidecar-plain-first" && + setup_repo sidecar-plain-first && + git -C sidecar-plain-first config core.autocrlf false && + git -C sidecar-plain-first config core.untrackedCache true && + prime_semantic_history sidecar-plain-first && + test_path_is_missing sidecar-plain-first/.git/index.csts && + cp sidecar-plain-first/.git/index plain-first.index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -C sidecar-plain-first status >plain-first.expect && + test_env GIT_TRACE2_EVENT="$PWD/plain-first.trace" \ + git -C sidecar-plain-first status >plain-first.actual && + test_cmp plain-first.expect plain-first.actual && + test_cmp plain-first.index sidecar-plain-first/.git/index && + test_path_is_file sidecar-plain-first/.git/index.csts && + test_trace2_data fsmonitor config/coherent 1 \ + sidecar-plain-dirty/tracked && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -C sidecar-plain-dirty status >plain-dirty-tracked.expect && + test_env GIT_TRACE2_EVENT="$PWD/plain-dirty-tracked.trace" \ + git -C sidecar-plain-dirty status \ + >plain-dirty-tracked.actual && + test_cmp plain-dirty-tracked.expect plain-dirty-tracked.actual && + test_trace2_data status count/changed 1 \ + sidecar-plain-dirty/untracked && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -C sidecar-plain-dirty status >plain-dirty-untracked.expect && + test_env GIT_TRACE2_EVENT="$PWD/plain-dirty-untracked.trace" \ + git -C sidecar-plain-dirty status \ + >plain-dirty-untracked.actual && + test_cmp plain-dirty-untracked.expect \ + plain-dirty-untracked.actual && + test_trace2_data status count/untracked 1 \ + plain-dirty-recovered.actual && + test_grep "nothing to commit, working tree clean" \ + plain-dirty-recovered.actual && + test_trace2_data status clean-proof/sidecar 1 \ + sidecar-hardlink/.gitignore && + git -C sidecar-hardlink add .gitignore && + git -C sidecar-hardlink commit -qm ignores && + mkdir sidecar-hardlink/ignored && + ln sidecar-hardlink/tracked sidecar-hardlink/ignored/alias && + test-tool -C sidecar-hardlink chmtime -120 tracked .gitignore && + git -C sidecar-hardlink update-index --refresh && + git -C sidecar-hardlink config core.autocrlf false && + git -C sidecar-hardlink config core.untrackedCache true && + git -C sidecar-hardlink config core.trustctime true && + git -C sidecar-hardlink config core.checkStat default && + prime_semantic_history sidecar-hardlink && + test_path_is_missing sidecar-hardlink/.git/index.csts && + test_env GIT_TRACE2_EVENT="$PWD/hardlink-issue.trace" \ + git -C sidecar-hardlink status >hardlink-issue.actual && + test_grep "nothing to commit, working tree clean" \ + hardlink-issue.actual && + test_trace2_data status clean-proof/sidecar 1 \ + sidecar-hardlink/ignored/alias && + test-tool chmtime =$mtime sidecar-hardlink/ignored/alias && + test "$(git -C sidecar-hardlink hash-object tracked)" != \ + "$(git -C sidecar-hardlink rev-parse HEAD:tracked)" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/hardlink-dirty.trace" \ + git -C sidecar-hardlink status --porcelain=v2 \ + >hardlink-dirty.actual && + test_grep "^1 \\.M .* tracked$" hardlink-dirty.actual && + test_grep ! "\"key\":\"clean-proof/hit\"" hardlink-dirty.trace && + test_grep "fast-hardlink-changed" hardlink-dirty.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'explicitly invalid tracked hardlinks keep authenticated clean proofs' ' + test_when_finished "stop_daemon sidecar-hardlinks-invalid" && + setup_repo sidecar-hardlinks-invalid && + test_write_lines "/ignored/" \ + >sidecar-hardlinks-invalid/.gitignore && + printf "bbbb\\n" >sidecar-hardlinks-invalid/other && + git -C sidecar-hardlinks-invalid add .gitignore other && + git -C sidecar-hardlinks-invalid commit -qm hardlinks && + mkdir sidecar-hardlinks-invalid/ignored && + ln sidecar-hardlinks-invalid/tracked \ + sidecar-hardlinks-invalid/ignored/tracked && + ln sidecar-hardlinks-invalid/other \ + sidecar-hardlinks-invalid/ignored/other && + test-tool -C sidecar-hardlinks-invalid \ + chmtime -120 tracked other .gitignore && + git -C sidecar-hardlinks-invalid update-index --refresh && + git -C sidecar-hardlinks-invalid config core.autocrlf false && + git -C sidecar-hardlinks-invalid config core.untrackedCache true && + git -C sidecar-hardlinks-invalid config core.trustctime true && + git -C sidecar-hardlinks-invalid config core.checkStat default && + prime_semantic_history sidecar-hardlinks-invalid && + test_grep FSCF sidecar-hardlinks-invalid/.git/index && + test_grep FSUC sidecar-hardlinks-invalid/.git/index && + test_env GIT_TRACE2_EVENT="$PWD/hardlinks-invalid-checkpoint.trace" \ + git -C sidecar-hardlinks-invalid status \ + >hardlinks-invalid-checkpoint.actual && + test_grep "nothing to commit, working tree clean" \ + hardlinks-invalid-checkpoint.actual && + test_trace2_data fsmonitor history/external-stored 1 \ + hardlinks-invalid.checkpoints && + test_line_count = 1 hardlinks-invalid.checkpoints && + rm -f sidecar-hardlinks-invalid/.git/index.csts && + rm sidecar-hardlinks-invalid/.git/index && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -C sidecar-hardlinks-invalid read-tree HEAD && + test_grep ! FSCF sidecar-hardlinks-invalid/.git/index && + test_grep ! FSMN sidecar-hardlinks-invalid/.git/index && + GIT_TRACE2_EVENT="$PWD/hardlinks-invalid-refresh.trace" \ + git -C sidecar-hardlinks-invalid update-index \ + --refresh -- tracked && + test_trace2_data fsmonitor history/external-restored 1 \ + hardlinks-invalid.tree-before && + test_grep ! "^invalid " hardlinks-invalid.tree-before && + GIT_TRACE2_EVENT="$PWD/hardlinks-invalid-update.trace" \ + git -C sidecar-hardlinks-invalid update-index \ + --no-fsmonitor-valid tracked other && + test_grep FSCF sidecar-hardlinks-invalid/.git/index && + test_grep FSUC sidecar-hardlinks-invalid/.git/index && + test-tool -C sidecar-hardlinks-invalid dump-cache-tree \ + >hardlinks-invalid.tree-after && + test_grep ! "^invalid " hardlinks-invalid.tree-after && + test_cmp hardlinks-invalid.tree-before hardlinks-invalid.tree-after && + GIT_TRACE2_EVENT="$PWD/hardlinks-invalid-separated.trace" \ + git -C sidecar-hardlinks-invalid update-index \ + --no-fsmonitor-valid -- tracked other && + test_grep FSCF sidecar-hardlinks-invalid/.git/index && + test_grep FSUC sidecar-hardlinks-invalid/.git/index && + test-tool -C sidecar-hardlinks-invalid dump-cache-tree \ + >hardlinks-invalid.tree-separated && + test_cmp hardlinks-invalid.tree-before \ + hardlinks-invalid.tree-separated && + test_env GIT_TRACE2_EVENT="$PWD/hardlinks-invalid-issue.trace" \ + git -C sidecar-hardlinks-invalid status \ + >hardlinks-invalid-issue.actual && + test_grep "nothing to commit, working tree clean" \ + hardlinks-invalid-issue.actual && + test_trace2_data status clean-proof/sidecar 1 \ + sidecar-hardlinks-invalid/ignored/tracked && + test-tool chmtime =$mtime \ + sidecar-hardlinks-invalid/ignored/tracked && + test "$(git -C sidecar-hardlinks-invalid hash-object tracked)" \ + != "$(git -C sidecar-hardlinks-invalid \ + rev-parse HEAD:tracked)" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/hardlinks-invalid-dirty.trace" \ + git -C sidecar-hardlinks-invalid status --porcelain=v2 \ + >hardlinks-invalid-dirty.actual && + test_grep "^1 \\.M .* tracked$" hardlinks-invalid-dirty.actual && + test_grep "fast-hardlink-changed" hardlinks-invalid-dirty.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'a racy scoped hardlink refresh still installs a clean sidecar' ' + test_when_finished "stop_daemon sidecar-hardlink-racy" && + setup_repo sidecar-hardlink-racy && + test_write_lines "/ignored/" >sidecar-hardlink-racy/.gitignore && + git -C sidecar-hardlink-racy add .gitignore && + git -C sidecar-hardlink-racy commit -qm ignores && + git -C sidecar-hardlink-racy config core.autocrlf false && + git -C sidecar-hardlink-racy config core.untrackedCache true && + git -C sidecar-hardlink-racy config core.trustctime true && + git -C sidecar-hardlink-racy config core.checkStat default && + prime_semantic_history sidecar-hardlink-racy && + test_grep FSCF sidecar-hardlink-racy/.git/index && + test_grep FSUC sidecar-hardlink-racy/.git/index && + git -C sidecar-hardlink-racy update-index --refresh -- tracked && + test_grep FSCF sidecar-hardlink-racy/.git/index && + test_grep FSUC sidecar-hardlink-racy/.git/index && + test-tool -C sidecar-hardlink-racy dump-fsmonitor \ + >hardlink-racy.token && + hardlink_token=$(sed -n "s/^fsmonitor last update //p" \ + hardlink-racy.token) && + test -n "$hardlink_token" && + mkdir sidecar-hardlink-racy/ignored && + ln sidecar-hardlink-racy/tracked \ + sidecar-hardlink-racy/ignored/tracked && + test-tool -C sidecar-hardlink-racy fsmonitor-client query \ + --token "$hardlink_token" >hardlink-racy-event.out && + test_env GIT_INDEX_FILE="$PWD/sidecar-hardlink-racy/.git/index" \ + GIT_TRACE2_EVENT="$PWD/hardlink-racy-rebaseline.trace" \ + git -C sidecar-hardlink-racy status --porcelain=v2 \ + >hardlink-racy-rebaseline.actual && + test_must_be_empty hardlink-racy-rebaseline.actual && + test_trace2_data fsmonitor apply/global-invalidation 1 \ + hardlink-racy.tree && + test_grep ! "^invalid " hardlink-racy.tree && + test-tool -C sidecar-hardlink-racy chmtime -180 .git/index && + rm -f sidecar-hardlink-racy/.git/index.csts && + test_env GIT_TRACE2_EVENT="$PWD/hardlink-racy-issue.trace" \ + git -C sidecar-hardlink-racy status >hardlink-racy-issue.actual && + test_grep "nothing to commit, working tree clean" \ + hardlink-racy-issue.actual && + test_trace2_data fsmonitor history/external-save-reject racy-index \ + sidecar-hardlink-stale-stat/.gitignore && + printf "aaaa\\n" >sidecar-hardlink-stale-stat/.npmrc && + mkdir -p sidecar-hardlink-stale-stat/nested && + printf "bbbb\\n" \ + >sidecar-hardlink-stale-stat/nested/.node-version && + git -C sidecar-hardlink-stale-stat add \ + .gitignore .npmrc nested/.node-version && + git -C sidecar-hardlink-stale-stat commit -qm hardlinks && + git -C sidecar-hardlink-stale-stat config core.autocrlf false && + git -C sidecar-hardlink-stale-stat config core.untrackedCache true && + git -C sidecar-hardlink-stale-stat config core.trustctime true && + git -C sidecar-hardlink-stale-stat config core.checkStat default && + git -C sidecar-hardlink-stale-stat update-index \ + --index-version 2 && + prime_semantic_history sidecar-hardlink-stale-stat && + test_grep FSCF sidecar-hardlink-stale-stat/.git/index && + test_grep FSUC sidecar-hardlink-stale-stat/.git/index && + test-tool -C sidecar-hardlink-stale-stat dump-fsmonitor \ + >hardlink-stale-stat.token && + stale_token=$(sed -n "s/^fsmonitor last update //p" \ + hardlink-stale-stat.token) && + test -n "$stale_token" && + mkdir sidecar-hardlink-stale-stat/ignored && + stale_same_second= && + for stale_attempt in 1 2 3 4 5 + do + rm -f sidecar-hardlink-stale-stat/ignored/npmrc \ + sidecar-hardlink-stale-stat/ignored/node-version && + test-tool -C sidecar-hardlink-stale-stat \ + chmtime -1 .npmrc nested/.node-version && + git -C sidecar-hardlink-stale-stat update-index \ + --refresh -- .npmrc && + npmrc_second=$(/usr/bin/stat -f %c \ + sidecar-hardlink-stale-stat/.npmrc) && + node_second=$(/usr/bin/stat -f %c \ + sidecar-hardlink-stale-stat/nested/.node-version) && + ln sidecar-hardlink-stale-stat/.npmrc \ + sidecar-hardlink-stale-stat/ignored/npmrc && + ln sidecar-hardlink-stale-stat/nested/.node-version \ + sidecar-hardlink-stale-stat/ignored/node-version || + return 1 + if test "$npmrc_second" = "$(/usr/bin/stat -f %c \ + sidecar-hardlink-stale-stat/.npmrc)" && + test "$node_second" = "$(/usr/bin/stat -f %c \ + sidecar-hardlink-stale-stat/nested/.node-version)" + then + stale_same_second=1 && + break + fi + done && + test "$stale_same_second" = 1 && + test-tool -C sidecar-hardlink-stale-stat fsmonitor-client query \ + --token "$stale_token" >hardlink-stale-stat-event.out && + test_env GIT_INDEX_FILE="$PWD/sidecar-hardlink-stale-stat/.git/index" \ + GIT_TRACE2_EVENT="$PWD/hardlink-stale-stat-rebaseline.trace" \ + git -C sidecar-hardlink-stale-stat status --porcelain=v2 \ + >hardlink-stale-stat-rebaseline.actual && + test_must_be_empty hardlink-stale-stat-rebaseline.actual && + test_trace2_data fsmonitor apply/global-invalidation 1 \ + sidecar-hardlink-stale-stat/.git/stale-index-stat.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $algorithm = $ARGV[0]; + my $rawsz = $algorithm eq "sha256" ? 32 : 20; + my $payload = substr($index, 0, -$rawsz); + die "not a version 2 index\n" + unless substr($payload, 0, 4) eq "DIRC" && + unpack("N", substr($payload, 4, 4)) == 2; + my $entries = unpack("N", substr($payload, 8, 4)); + my $offset = 12; + my $changed = 0; + for (1 .. $entries) { + my $name_offset = $offset + 40 + $rawsz + 2; + my $end = index($payload, "\0", $name_offset); + die "unterminated index entry\n" if $end < 0; + my $name = substr($payload, $name_offset, $end - $name_offset); + if ($name eq ".npmrc" || $name eq "nested/.node-version") { + my $nsec = unpack("N", substr($payload, $offset + 4, 4)); + $nsec = ($nsec + 1) % 1000000000; + substr($payload, $offset + 4, 4, pack("N", $nsec)); + $changed++; + } + $offset += (($end + 1 - $offset + 7) & ~7); + } + die "did not rewrite both indexed hardlinks\n" unless $changed == 2; + print $payload, + $algorithm eq "sha256" ? sha256($payload) : sha1($payload); + EOF + perl sidecar-hardlink-stale-stat/.git/stale-index-stat.pl \ + "$(test_oid algo)" \ + sidecar-hardlink-stale-stat/.git/index.stale && + mv sidecar-hardlink-stale-stat/.git/index.stale \ + sidecar-hardlink-stale-stat/.git/index && + test_grep FSCF sidecar-hardlink-stale-stat/.git/index && + test_grep FSUC sidecar-hardlink-stale-stat/.git/index && + rm -f sidecar-hardlink-stale-stat/.git/index.csts && + test_env GIT_TRACE2_EVENT="$PWD/hardlink-stale-stat-issue.trace" \ + git -C sidecar-hardlink-stale-stat status \ + >hardlink-stale-stat-issue.actual && + test_grep "nothing to commit, working tree clean" \ + hardlink-stale-stat-issue.actual && + test_trace2_data status clean-proof/hardlink-content-verified 2 \ + >sidecar-hardlink-stale-stat/.git/index.csts + ;; + truncated) + printf "CSTS" \ + >sidecar-hardlink-stale-stat/.git/index.csts + ;; + oversized) + dd if=/dev/zero \ + of=sidecar-hardlink-stale-stat/.git/index.csts \ + bs=1048577 count=1 2>/dev/null + ;; + esac && + test_env \ + GIT_TRACE2_EVENT="$PWD/hardlink-sidecar-$malformed.trace" \ + git -C sidecar-hardlink-stale-stat status \ + >"hardlink-sidecar-$malformed.actual" && + test_grep "nothing to commit, working tree clean" \ + "hardlink-sidecar-$malformed.actual" && + test_grep "fast-sidecar-missing-or-corrupt" \ + "hardlink-sidecar-$malformed.trace" && + test_trace2_data status clean-proof/hardlink-content-verified 2 \ + <"hardlink-sidecar-$malformed.trace" && + test_trace2_data status clean-proof/hardlink-witnesses 2 \ + <"hardlink-sidecar-$malformed.trace" && + test_trace2_data status clean-proof/sidecar 1 \ + <"hardlink-sidecar-$malformed.trace" && + assert_clean_sidecar_hit sidecar-hardlink-stale-stat \ + sidecar-hardlink-stale-stat \ + "hardlink-sidecar-$malformed-hit" && + test_trace2_data status clean-proof/hardlink-validated 2 \ + <"hardlink-sidecar-$malformed-hit.trace" || return 1 + done && + cp sidecar-hardlink-stale-stat/.git/index.csts.valid \ + sidecar-hardlink-stale-stat/.git/index.csts.pristine && + for unsafe in fifo symlink directory multilink + do + rm -rf sidecar-hardlink-stale-stat/.git/index.csts && + case "$unsafe" in + fifo) + mkfifo sidecar-hardlink-stale-stat/.git/index.csts + ;; + symlink) + ln -s index.csts.valid \ + sidecar-hardlink-stale-stat/.git/index.csts + ;; + directory) + mkdir sidecar-hardlink-stale-stat/.git/index.csts + ;; + multilink) + ln sidecar-hardlink-stale-stat/.git/index.csts.valid \ + sidecar-hardlink-stale-stat/.git/index.csts + ;; + esac && + test_env \ + GIT_TRACE2_EVENT="$PWD/hardlink-sidecar-$unsafe.trace" \ + git -C sidecar-hardlink-stale-stat status \ + >"hardlink-sidecar-$unsafe.actual" && + test_grep "nothing to commit, working tree clean" \ + "hardlink-sidecar-$unsafe.actual" && + ! test_trace2_data status clean-proof/sidecar 1 \ + <"hardlink-sidecar-$unsafe.trace" && + test_cmp sidecar-hardlink-stale-stat/.git/index.csts.pristine \ + sidecar-hardlink-stale-stat/.git/index.csts.valid && + case "$unsafe" in + fifo) + test -p sidecar-hardlink-stale-stat/.git/index.csts + ;; + symlink) + test -h sidecar-hardlink-stale-stat/.git/index.csts + ;; + directory) + test -d sidecar-hardlink-stale-stat/.git/index.csts + ;; + multilink) + test "$(/usr/bin/stat -f %i \ + sidecar-hardlink-stale-stat/.git/index.csts)" = \ + "$(/usr/bin/stat -f %i \ + sidecar-hardlink-stale-stat/.git/index.csts.valid)" + ;; + esac || return 1 + done && + rm -f sidecar-hardlink-stale-stat/.git/index.csts && + cp sidecar-hardlink-stale-stat/.git/index.csts.valid \ + sidecar-hardlink-stale-stat/.git/index.csts && + git -C sidecar-hardlink-stale-stat status \ + >hardlink-sidecar-repair-prime.actual && + test_grep "nothing to commit, working tree clean" \ + hardlink-sidecar-repair-prime.actual && + assert_clean_sidecar_hit sidecar-hardlink-stale-stat \ + sidecar-hardlink-stale-stat hardlink-sidecar-repair-prime-hit && + cp sidecar-hardlink-stale-stat/.git/index \ + sidecar-hardlink-stale-stat/.git/index.before-repair && + test-tool -C sidecar-hardlink-stale-stat chmtime -60 tracked && + chmod 0600 sidecar-hardlink-stale-stat/ignored/npmrc \ + sidecar-hardlink-stale-stat/ignored/node-version && + chmod 0644 sidecar-hardlink-stale-stat/ignored/npmrc \ + sidecar-hardlink-stale-stat/ignored/node-version && + test_env \ + GIT_TRACE2_EVENT="$PWD/hardlink-sidecar-repair-reissue.trace" \ + git -C sidecar-hardlink-stale-stat status \ + >hardlink-sidecar-repair-reissue.actual && + test_grep "nothing to commit, working tree clean" \ + hardlink-sidecar-repair-reissue.actual && + test_grep "fast-hardlink-changed" \ + hardlink-sidecar-repair-reissue.trace && + test_grep "\"label\":\"do_write_index\"" \ + hardlink-sidecar-repair-reissue.trace && + test_trace2_data status clean-proof/sidecar 1 \ + hook-created + printf "ran\n" >.git/post-index-change-ran + EOF + test-tool -C sidecar-hardlink-stale-stat chmtime -120 tracked && + chmod 0600 sidecar-hardlink-stale-stat/ignored/npmrc \ + sidecar-hardlink-stale-stat/ignored/node-version && + chmod 0644 sidecar-hardlink-stale-stat/ignored/npmrc \ + sidecar-hardlink-stale-stat/ignored/node-version && + test_env \ + GIT_TRACE2_EVENT="$PWD/hardlink-sidecar-post-hook.trace" \ + git -C sidecar-hardlink-stale-stat status \ + >hardlink-sidecar-post-hook.actual && + test_path_is_file \ + sidecar-hardlink-stale-stat/.git/post-index-change-ran && + test_path_is_file sidecar-hardlink-stale-stat/hook-created && + test_grep "fast-hardlink-changed" \ + hardlink-sidecar-post-hook.trace && + test_grep "\"label\":\"do_write_index\"" \ + hardlink-sidecar-post-hook.trace && + ! test_trace2_data status clean-proof/postwrite-reissued 1 \ + hardlink-sidecar-post-hook.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/hardlink-sidecar-post-hook-repeat.trace" \ + git -C sidecar-hardlink-stale-stat status --porcelain=v2 \ + >hardlink-sidecar-post-hook-repeat.actual && + test_cmp hardlink-sidecar-post-hook.expect \ + hardlink-sidecar-post-hook-repeat.actual && + test_grep "^? hook-created$" \ + hardlink-sidecar-post-hook-repeat.actual && + ! test_trace2_data status clean-proof/hit 1 \ + sidecar-hardlink-stale-stat/ignored/npmrc && + test-tool chmtime =$mtime \ + sidecar-hardlink-stale-stat/ignored/npmrc && + test "$(git -C sidecar-hardlink-stale-stat hash-object .npmrc)" \ + != "$(git -C sidecar-hardlink-stale-stat \ + rev-parse HEAD:.npmrc)" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/hardlink-stale-stat-dirty.trace" \ + git -C sidecar-hardlink-stale-stat status --porcelain=v2 \ + >hardlink-stale-stat-dirty.actual && + test_grep "^1 \\.M .* \\.npmrc$" \ + hardlink-stale-stat-dirty.actual && + test_path_is_missing sidecar-hardlink-stale-stat/.git/index.csts && + ! test_trace2_data status clean-proof/sidecar 1 \ + sidecar-hardlink-first-dirty/.gitignore && + git -C sidecar-hardlink-first-dirty add .gitignore && + git -C sidecar-hardlink-first-dirty commit -qm ignores && + mkdir sidecar-hardlink-first-dirty/ignored && + ln sidecar-hardlink-first-dirty/tracked \ + sidecar-hardlink-first-dirty/ignored/alias && + test-tool -C sidecar-hardlink-first-dirty \ + chmtime -120 tracked .gitignore && + git -C sidecar-hardlink-first-dirty update-index --refresh && + git -C sidecar-hardlink-first-dirty config core.autocrlf false && + git -C sidecar-hardlink-first-dirty config core.untrackedCache true && + git -C sidecar-hardlink-first-dirty config core.trustctime true && + git -C sidecar-hardlink-first-dirty config core.checkStat default && + prime_semantic_history sidecar-hardlink-first-dirty && + test_path_is_missing sidecar-hardlink-first-dirty/.git/index.csts && + mtime=$(test-tool chmtime --get \ + sidecar-hardlink-first-dirty/tracked) && + printf "xxxx\\n" \ + >sidecar-hardlink-first-dirty/ignored/alias && + test-tool chmtime =$mtime \ + sidecar-hardlink-first-dirty/ignored/alias && + test "$(git -C sidecar-hardlink-first-dirty hash-object tracked)" \ + != "$(git -C sidecar-hardlink-first-dirty \ + rev-parse HEAD:tracked)" && + test_env GIT_TRACE2_EVENT="$PWD/hardlink-first-dirty.trace" \ + git -C sidecar-hardlink-first-dirty status \ + >hardlink-first-dirty.actual && + test_path_is_missing sidecar-hardlink-first-dirty/.git/index.csts && + test_grep ! "\"key\":\"clean-proof/sidecar\"" \ + hardlink-first-dirty.trace +' + +test_expect_success DURABLE_FSMONITOR \ + 'weak stat identity never certifies multiply linked tracked files' ' + test_when_finished "stop_daemon sidecar-hardlink-weak" && + setup_repo sidecar-hardlink-weak && + test_write_lines "/ignored/" >sidecar-hardlink-weak/.gitignore && + git -C sidecar-hardlink-weak add .gitignore && + git -C sidecar-hardlink-weak commit -qm ignores && + mkdir sidecar-hardlink-weak/ignored && + ln sidecar-hardlink-weak/tracked \ + sidecar-hardlink-weak/ignored/alias && + test-tool chmtime -120 sidecar-hardlink-weak/tracked && + git -C sidecar-hardlink-weak update-index --refresh && + git -C sidecar-hardlink-weak config core.autocrlf false && + git -C sidecar-hardlink-weak config core.untrackedCache true && + git -C sidecar-hardlink-weak config core.trustctime false && + git -C sidecar-hardlink-weak config core.checkStat minimal && + prime_semantic_history sidecar-hardlink-weak && + test_env GIT_TRACE2_EVENT="$PWD/hardlink-weak.trace" \ + git -C sidecar-hardlink-weak status >hardlink-weak.actual && + test_grep "nothing to commit, working tree clean" \ + hardlink-weak.actual && + test_path_is_missing sidecar-hardlink-weak/.git/index.csts && + test_grep ! "\"key\":\"clean-proof/sidecar\"" hardlink-weak.trace +' + test_expect_success DURABLE_FSMONITOR \ 'a clean sidecar serves every index-independent status shape' ' shapes=sidecar-query-shapes && @@ -776,6 +1446,9 @@ test_expect_success DURABLE_FSMONITOR \ test_when_finished "stop_daemon external-stat-bootstrap" && setup_repo external-stat-bootstrap && git -C external-stat-bootstrap update-index --fsmonitor && + test-tool chmtime -60 external-stat-bootstrap/tracked && + test-tool -C external-stat-bootstrap \ + fsmonitor-client flush >bootstrap.flush && test_env GIT_TRACE2_EVENT="$PWD/external-stat-bootstrap.trace" \ git -C external-stat-bootstrap status >actual && test_trace2_data fsmonitor history/external-stored 1 \ @@ -798,7 +1471,7 @@ test_expect_success DURABLE_FSMONITOR \ bulk_status -C external-stat-exact status --porcelain=v2 \ >actual && test_must_be_empty actual && - ! test_trace2_data fsmonitor history/external-stored 1 \ + test_trace2_data fsmonitor history/external-stored 1 \ sidecar-invalid-tree/tracked && + git -C sidecar-invalid-tree add tracked && + cp tracked.original sidecar-invalid-tree/tracked && + test-tool chmtime -120 sidecar-invalid-tree/tracked && + git -C sidecar-invalid-tree add tracked && + test-tool -C sidecar-invalid-tree dump-cache-tree >tree.dump && + test_grep "^invalid " tree.dump && + GIT_OPTIONAL_LOCKS=0 \ + git -C sidecar-invalid-tree status --porcelain=v2 >before && + test_must_be_empty before && + cp stale-proof sidecar-invalid-tree/.git/index.csts && + rm -f sidecar-invalid-tree/.git/index.csh1.* && + cp sidecar-invalid-tree/.git/index invalid-tree.index && + test_env GIT_TRACE2_EVENT="$PWD/invalid-tree-reissue.trace" \ + git -C sidecar-invalid-tree status >actual && + test_grep "working tree clean" actual && + test_cmp invalid-tree.index sidecar-invalid-tree/.git/index && + test_trace2_data status index/full-tree-match 1 \ + actual.hit && + test_cmp actual actual.hit && + test_trace2_data status clean-proof/hit 1 \ + external-sidecars && diff --git a/t/unit-tests/u-attr-fingerprint.c b/t/unit-tests/u-attr-fingerprint.c index e7b61b687c788f..1d930f5e32b874 100644 --- a/t/unit-tests/u-attr-fingerprint.c +++ b/t/unit-tests/u-attr-fingerprint.c @@ -1,6 +1,7 @@ #include "unit-test.h" #include "attr-fingerprint.h" #include "dir.h" +#include "path.h" #include "strbuf.h" #include "wrapper.h" @@ -59,10 +60,14 @@ void test_attr_fingerprint__separates_contents_from_namespace(void) algo->rawsz)); cl_assert(memcmp(initial.namespace_hash, metadata.namespace_hash, algo->rawsz)); + cl_assert(memcmp(initial.portable_namespace_hash, + metadata.portable_namespace_hash, algo->rawsz)); write_file(path.buf, "*.txt -text\n"); fingerprint(path.buf, 1, algo, &changed); cl_assert(memcmp(metadata.content_hash, changed.content_hash, algo->rawsz)); + cl_assert(memcmp(metadata.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); strbuf_release(&path); remove_directory(directory); @@ -100,6 +105,8 @@ void test_attr_fingerprint__records_missing_parent_namespaces(void) cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); cl_assert(memcmp(before.namespace_hash, after.namespace_hash, algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); strbuf_release(&path); remove_directory(directory); @@ -121,7 +128,118 @@ void test_attr_fingerprint__does_not_observe_disabled_sources(void) cl_assert(!memcmp(before.content_hash, after.content_hash, algo->rawsz)); cl_assert(!memcmp(before.namespace_hash, after.namespace_hash, algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); strbuf_release(&path); remove_directory(directory); } + +void test_attr_fingerprint__equates_distinct_absent_source_paths(void) +{ +#ifndef O_NONBLOCK + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + char *directory; + struct strbuf absent_a = STRBUF_INIT; + struct strbuf absent_b = STRBUF_INIT; + struct strbuf present = STRBUF_INIT; + struct attr_fingerprint_source first[2], second[2]; + struct attr_fingerprint before, after, changed; + + if (!fstat_is_reliable()) + cl_skip(); + directory = create_directory(); + strbuf_addf(&absent_a, "%s/system-a/attributes", directory); + strbuf_addf(&absent_b, "%s/system-b/attributes", directory); + strbuf_addf(&present, "%s/global-attributes", directory); + write_file(present.buf, "*.txt text\n"); + + first[0] = (struct attr_fingerprint_source) { + .path = absent_a.buf, + .enabled = 1, + }; + first[1] = (struct attr_fingerprint_source) { + .path = present.buf, + .enabled = 1, + }; + second[0] = first[0]; + second[0].path = absent_b.buf; + second[1] = first[1]; + + cl_assert_equal_i(attr_fingerprint_sources( + first, ARRAY_SIZE(first), algo, &before), 0); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &after), 0); + cl_assert(before.sources_present); + cl_assert(after.sources_present); + cl_assert(!memcmp(before.content_hash, after.content_hash, + algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); + cl_assert(memcmp(before.namespace_hash, after.namespace_hash, + algo->rawsz)); + + second[0].enabled = 0; + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &changed), 0); + cl_assert(memcmp(before.content_hash, changed.content_hash, + algo->rawsz)); + cl_assert(memcmp(before.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); + + first[0].enabled = 0; + cl_assert_equal_i(attr_fingerprint_sources( + first, ARRAY_SIZE(first), algo, &before), 0); + cl_assert(!memcmp(before.content_hash, changed.content_hash, + algo->rawsz)); + cl_assert(!memcmp(before.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); + cl_assert(memcmp(before.namespace_hash, changed.namespace_hash, + algo->rawsz)); + + first[0].enabled = 1; + first[0].path = present.buf; + first[1].path = absent_a.buf; + second[0].enabled = 1; + cl_assert_equal_i(attr_fingerprint_sources( + first, ARRAY_SIZE(first), algo, &before), 0); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &after), 0); + cl_assert(memcmp(before.content_hash, after.content_hash, + algo->rawsz)); + cl_assert(memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); + + write_file(present.buf, "*.txt -text\n"); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &changed), 0); + cl_assert(memcmp(after.content_hash, changed.content_hash, + algo->rawsz)); + cl_assert(memcmp(after.portable_namespace_hash, + changed.portable_namespace_hash, algo->rawsz)); + + cl_assert_equal_i( + safe_create_leading_directories_no_share(absent_b.buf), 0); + write_file(absent_b.buf, "*.system text\n"); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &before), 0); + cl_assert(memcmp(changed.content_hash, before.content_hash, + algo->rawsz)); + cl_assert(memcmp(changed.portable_namespace_hash, + before.portable_namespace_hash, algo->rawsz)); + write_file(absent_b.buf, "*.system -text\n"); + cl_assert_equal_i(attr_fingerprint_sources( + second, ARRAY_SIZE(second), algo, &after), 0); + cl_assert(memcmp(before.content_hash, after.content_hash, + algo->rawsz)); + cl_assert(memcmp(before.portable_namespace_hash, + after.portable_namespace_hash, algo->rawsz)); + + strbuf_release(&present); + strbuf_release(&absent_b); + strbuf_release(&absent_a); + remove_directory(directory); +#endif +} diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index a41eb2a7975fac..33e1b048316be1 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -181,6 +181,32 @@ void test_attr_manifest__does_not_report_identical_entries(void) strbuf_release(&old); } +void test_attr_manifest__distinguishes_display_only_attribute_edits(void) +{ + static const char original[] = "*.txt text\n# keep me\n"; + static const char display[] = + "*.txt text\n# keep me\n*.gen linguist-generated\n"; + static const char removed[] = + "*.txt text\n*.old -linguist-generated\n# keep me\n"; + static const char converted[] = + "*.txt text\n# keep me\n*.gen linguist-generated text\n"; + static const char filtered[] = + "*.txt text\n# keep me\n*.gen filter=smudge\n"; + static const char macro[] = + "[attr]linguist-generated text\n*.gen linguist-generated\n"; + + cl_assert(attr_manifest_only_linguist_generated_changed( + original, strlen(original), display, strlen(display))); + cl_assert(attr_manifest_only_linguist_generated_changed( + removed, strlen(removed), original, strlen(original))); + cl_assert(!attr_manifest_only_linguist_generated_changed( + original, strlen(original), converted, strlen(converted))); + cl_assert(!attr_manifest_only_linguist_generated_changed( + original, strlen(original), filtered, strlen(filtered))); + cl_assert(!attr_manifest_only_linguist_generated_changed( + original, strlen(original), macro, strlen(macro))); +} + void test_attr_manifest__rejects_malformed_tail_before_callbacks(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 74e40b205d85c0..7b96ae955b2c1a 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -67,6 +67,85 @@ void test_clean_status_config__origin_only_affects_full_hash(void) cl_assert(hashes_equal(global.semantic_hash, local.semantic_hash)); } +void test_clean_status_config__command_transport_config_does_not_change_proof(void) +{ + static const char *const ignored_keys[] = { + "credential.helper", + "credential.https://Example/Team.helper", + "url.https://Proxy.Example/Team/.insteadof", + "url.https://Proxy.Example/Team/.pushinsteadof", + }; + static const enum config_scope persistent_scopes[] = { + CONFIG_SCOPE_GLOBAL, + CONFIG_SCOPE_LOCAL, + CONFIG_SCOPE_WORKTREE, + CONFIG_SCOPE_UNKNOWN, + }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + struct clean_status_config_digest baseline, digest; + + clean_status_config_init(&baseline, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_final(&baseline); + kvi.scope = CONFIG_SCOPE_COMMAND; + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + + for (size_t i = 0; i < ARRAY_SIZE(ignored_keys); i++) { + digest_one(&digest, ignored_keys[i], "transport", &ctx); + cl_assert(hashes_equal(digest.hash, baseline.hash)); + cl_assert(hashes_equal(digest.semantic_hash, + baseline.semantic_hash)); + cl_assert(!digest.filter_configured); + cl_assert(!digest.semantic_config_explicit); + + for (size_t j = 0; j < ARRAY_SIZE(persistent_scopes); j++) { + kvi.scope = persistent_scopes[j]; + digest_one(&digest, ignored_keys[i], "transport", &ctx); + cl_assert(!hashes_equal(digest.hash, baseline.hash)); + } + kvi.scope = CONFIG_SCOPE_COMMAND; + digest_one(&digest, ignored_keys[i], "transport", NULL); + cl_assert(!hashes_equal(digest.hash, baseline.hash)); + } +} + +void test_clean_status_config__command_worktree_config_still_changes_proof(void) +{ + static const char *const retained_keys[] = { + "url.insteadof", + "url.https://Proxy.Example/Team/.other", + "core.excludesfile", + "status.showuntrackedfiles", + }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + struct clean_status_config_digest baseline, digest; + + clean_status_config_init(&baseline, &hash_algos[GIT_HASH_SHA1]); + clean_status_config_final(&baseline); + kvi.scope = CONFIG_SCOPE_COMMAND; + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + + for (size_t i = 0; i < ARRAY_SIZE(retained_keys); i++) { + digest_one(&digest, retained_keys[i], "value", &ctx); + cl_assert(!hashes_equal(digest.hash, baseline.hash)); + cl_assert(hashes_equal(digest.semantic_hash, + baseline.semantic_hash)); + } + + digest_one(&digest, "core.autocrlf", "true", &ctx); + cl_assert(!hashes_equal(digest.hash, baseline.hash)); + cl_assert(!hashes_equal(digest.semantic_hash, baseline.semantic_hash)); + cl_assert(digest.semantic_config_explicit); + cl_assert(!digest.filter_configured); + + digest_one(&digest, "filter.demo.clean", "cat", &ctx); + cl_assert(!hashes_equal(digest.hash, baseline.hash)); + cl_assert(!hashes_equal(digest.semantic_hash, baseline.semantic_hash)); + cl_assert(digest.semantic_config_explicit); + cl_assert(digest.filter_configured); +} + static void digest_without_final_domain( const struct clean_status_config_digest *digest, unsigned char *full_hash, unsigned char *semantic_hash) @@ -207,6 +286,8 @@ void test_clean_status_config__attaches_only_to_the_staged_repository(void) algo->rawsz)); cl_assert(!memcmp(state->current_attr_namespace_hash, attrs.namespace_hash, algo->rawsz)); + cl_assert(!memcmp(state->current_attr_portable_namespace_hash, + attrs.portable_namespace_hash, algo->rawsz)); clean_status_set_config_digest(&repo_a, &replacement); clean_status_attach_config(&istate_a); diff --git a/t/unit-tests/u-clean-status-sidecar.c b/t/unit-tests/u-clean-status-sidecar.c index a4d819eb21290a..e5a974a11d4e2f 100644 --- a/t/unit-tests/u-clean-status-sidecar.c +++ b/t/unit-tests/u-clean-status-sidecar.c @@ -113,6 +113,8 @@ static void assert_round_trip(const struct git_hash_algo *algo) fixture_encode(&fixture, algo); cl_assert_equal_i(clean_status_sidecar_parse( &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(get_be32(fixture.encoded.buf + 4), + CLEAN_STATUS_SIDECAR_VERSION); cl_assert(clean_status_identity_equal(&parsed.identity, &fixture.sidecar.identity)); cl_assert_equal_i(parsed.proof.index_version, @@ -132,6 +134,7 @@ static void assert_round_trip(const struct git_hash_algo *algo) cl_assert_equal_i(parsed.token_len, fixture.sidecar.token_len); cl_assert(!memcmp(parsed.token, fixture.sidecar.token, parsed.token_len)); + cl_assert_equal_i(parsed.hardlink_nr, 0); fixture_release(&fixture); } @@ -141,6 +144,124 @@ void test_clean_status_sidecar__round_trips_both_object_formats(void) assert_round_trip(&hash_algos[GIT_HASH_SHA256]); } +static void assert_hardlink_round_trip(const struct git_hash_algo *algo) +{ + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + struct path_stat_identity expected = { 0 }, actual; + struct strbuf witnesses = STRBUF_INIT; + const unsigned char *cursor, *path; + size_t path_len; + + fixture_init(&fixture, algo); + expected.fields[0] = 11; + expected.fields[1] = 12; + expected.fields[2] = S_IFREG | 0644; + expected.fields[3] = 2; + expected.fields[10] = 123456789; + cl_assert_equal_i(clean_status_sidecar_append_hardlink( + &witnesses, "tracked/file", &expected), 0); + fixture.sidecar.hardlinks = (unsigned char *)witnesses.buf; + fixture.sidecar.hardlinks_len = witnesses.len; + fixture.sidecar.hardlink_nr = 1; + fixture_encode(&fixture, algo); + cl_assert_equal_i(get_be32(fixture.encoded.buf + 4), + CLEAN_STATUS_SIDECAR_HARDLINK_VERSION); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.hardlink_nr, 1); + cursor = parsed.hardlinks; + cl_assert_equal_i(clean_status_sidecar_next_hardlink( + &cursor, parsed.hardlinks + parsed.hardlinks_len, + &path, &path_len, &actual), 0); + cl_assert_equal_i(path_len, strlen("tracked/file")); + cl_assert(!memcmp(path, "tracked/file", path_len)); + cl_assert(path_stat_identity_equal(&expected, &actual)); + cl_assert(cursor == parsed.hardlinks + parsed.hardlinks_len); + strbuf_release(&witnesses); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__round_trips_hardlinks_in_both_formats(void) +{ + assert_hardlink_round_trip(&hash_algos[GIT_HASH_SHA1]); + assert_hardlink_round_trip(&hash_algos[GIT_HASH_SHA256]); +} + +void test_clean_status_sidecar__accepts_hardlink_payloads_over_old_limit(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct clean_status_sidecar parsed; + struct path_stat_identity identity = { 0 }; + struct strbuf witnesses = STRBUF_INIT; + char path[32]; + + fixture_init(&fixture, algo); + identity.fields[2] = S_IFREG | 0644; + identity.fields[3] = 2; + for (uint32_t i = 0; i < 80; i++) { + xsnprintf(path, sizeof(path), "tracked/%04"PRIu32, i); + cl_assert_equal_i(clean_status_sidecar_append_hardlink( + &witnesses, path, &identity), 0); + } + fixture.sidecar.hardlinks = (unsigned char *)witnesses.buf; + fixture.sidecar.hardlinks_len = witnesses.len; + fixture.sidecar.hardlink_nr = 80; + fixture_encode(&fixture, algo); + cl_assert(fixture.encoded.len > 8192); + cl_assert_equal_i(clean_status_sidecar_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.hardlink_nr, 80); + strbuf_release(&witnesses); + fixture_release(&fixture); +} + +void test_clean_status_sidecar__rejects_invalid_hardlink_witnesses(void) +{ + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; + struct sidecar_fixture fixture; + struct path_stat_identity identity = { 0 }; + struct strbuf witnesses = STRBUF_INIT; + size_t count_offset, first_path, second_path; + + fixture_init(&fixture, algo); + identity.fields[2] = S_IFREG | 0644; + identity.fields[3] = 2; + cl_assert_equal_i(clean_status_sidecar_append_hardlink( + &witnesses, "a/file", &identity), 0); + cl_assert_equal_i(clean_status_sidecar_append_hardlink( + &witnesses, "z/file", &identity), 0); + fixture.sidecar.hardlinks = (unsigned char *)witnesses.buf; + fixture.sidecar.hardlinks_len = witnesses.len; + fixture.sidecar.hardlink_nr = 2; + fixture_encode(&fixture, algo); + count_offset = token_offset(algo) + fixture.sidecar.token_len; + first_path = count_offset + 2 * sizeof(uint32_t); + second_path = first_path + strlen("a/file") + + CLEAN_STATUS_IDENTITY_SIZE + sizeof(uint32_t); + + put_be32(fixture.encoded.buf + count_offset, 0); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + count_offset, + CLEAN_STATUS_HARDLINK_WITNESS_MAX + 1); + assert_parse_fails(&fixture, algo); + put_be32(fixture.encoded.buf + count_offset, 2); + + fixture.encoded.buf[first_path] = '/'; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[first_path] = 'a'; + fixture.encoded.buf[second_path] = 'a'; + assert_parse_fails(&fixture, algo); + fixture.encoded.buf[second_path] = 'z'; + + put_be64(fixture.encoded.buf + first_path + strlen("a/file") + + 3 * sizeof(uint64_t), 1); + assert_parse_fails(&fixture, algo); + strbuf_release(&witnesses); + fixture_release(&fixture); +} + void test_clean_status_sidecar__rejects_bad_envelopes(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; diff --git a/t/unit-tests/u-clean-status-store.c b/t/unit-tests/u-clean-status-store.c index 4c2e5739aaceea..9d2cbc2daf4b7a 100644 --- a/t/unit-tests/u-clean-status-store.c +++ b/t/unit-tests/u-clean-status-store.c @@ -187,7 +187,7 @@ void test_clean_status_store__rejects_oversized_sidecars(void) fixture_init(&fixture, algo); path = sidecar_path(&fixture); - strbuf_addchars(&oversized, 'x', 8193); + strbuf_addchars(&oversized, 'x', CLEAN_STATUS_SIDECAR_MAX_SIZE + 1); write_file_buf(path.buf, oversized.buf, oversized.len); cl_assert_equal_i(clean_status_sidecar_load( fixture.index_path.buf, algo, &record), -1); diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index be1f2d046599b1..4ac7690f2bf286 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -367,11 +367,16 @@ void test_exclude_source_proof__reresolves_absent_source_parent(void) void test_exclude_source_proof__accepts_dev_null(void) { - struct exclude_source_proof *proof = new_proof(); + int valid = 0; - record_file(proof, "/dev/null"); - cl_assert(exclude_source_proof_validate(proof)); - exclude_source_proof_release(proof); + for (int attempt = 0; attempt < 16 && !valid; attempt++) { + struct exclude_source_proof *proof = new_proof(); + + record_file(proof, "/dev/null"); + valid = exclude_source_proof_validate(proof); + exclude_source_proof_release(proof); + } + cl_assert(valid); } void test_exclude_source_proof__accepts_empty_fifo_replacement(void) diff --git a/t/unit-tests/u-fsmonitor-clean-proof.c b/t/unit-tests/u-fsmonitor-clean-proof.c index b4691221c75f49..037f8d7f57d1cb 100644 --- a/t/unit-tests/u-fsmonitor-clean-proof.c +++ b/t/unit-tests/u-fsmonitor-clean-proof.c @@ -9,6 +9,7 @@ struct proof_fixture { unsigned char config_hash[GIT_MAX_RAWSZ]; unsigned char semantic_hash[GIT_MAX_RAWSZ]; unsigned char attr_hash[GIT_MAX_RAWSZ]; + unsigned char tracked_policy_hash[GIT_MAX_RAWSZ]; struct fsmonitor_clean_proof proof; }; @@ -26,6 +27,7 @@ static void fixture_init(struct proof_fixture *fixture, memset(fixture->config_hash, 2, algo->rawsz); memset(fixture->semantic_hash, 3, algo->rawsz); memset(fixture->attr_hash, 4, algo->rawsz); + memset(fixture->tracked_policy_hash, 5, algo->rawsz); attr_manifest_writer_init(&writer, &fixture->manifest, algo); cl_assert_equal_i(attr_manifest_writer_add( &writer, ".gitattributes", ATTR_MANIFEST_INDEX, hash), 0); @@ -56,6 +58,9 @@ static void assert_round_trip(const struct git_hash_algo *algo) &fixture.encoded, &fixture.proof, algo), 0); cl_assert_equal_i(fsmonitor_clean_proof_parse( &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.version, + FSMONITOR_CLEAN_PROOF_VERSION_LEGACY); + cl_assert_equal_p(parsed.tracked_policy_hash, NULL); cl_assert_equal_i(parsed.flags, fixture.proof.flags); cl_assert_equal_i(parsed.token_len, fixture.proof.token_len); cl_assert(!memcmp(parsed.token, fixture.proof.token, parsed.token_len)); @@ -78,6 +83,7 @@ static void assert_rejected(struct fsmonitor_clean_proof *parsed, cl_assert_equal_p(parsed->config_hash, NULL); cl_assert_equal_p(parsed->semantic_hash, NULL); cl_assert_equal_p(parsed->attr_hash, NULL); + cl_assert_equal_p(parsed->tracked_policy_hash, NULL); cl_assert_equal_p(parsed->attr_manifest, NULL); cl_assert_equal_i(parsed->attr_manifest_len, 0); } @@ -88,6 +94,52 @@ void test_fsmonitor_clean_proof__round_trips_both_object_formats(void) assert_round_trip(&hash_algos[GIT_HASH_SHA256]); } +static void assert_tracked_policy_round_trip( + const struct git_hash_algo *algo) +{ + struct proof_fixture fixture; + struct fsmonitor_clean_proof parsed; + struct strbuf unbound = STRBUF_INIT; + size_t policy_offset; + unsigned char saved; + + fixture_init(&fixture, algo); + fixture.proof.tracked_policy_hash = fixture.tracked_policy_hash; + cl_assert_equal_i(fsmonitor_clean_proof_write( + &fixture.encoded, &fixture.proof, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(parsed.version, FSMONITOR_CLEAN_PROOF_VERSION); + cl_assert(!memcmp(parsed.tracked_policy_hash, + fixture.tracked_policy_hash, algo->rawsz)); + cl_assert_equal_i(fsmonitor_clean_proof_copy_without_bindings( + &unbound, fixture.encoded.buf, fixture.encoded.len, algo), 0); + cl_assert_equal_i(fsmonitor_clean_proof_parse( + &parsed, unbound.buf, unbound.len, algo), 0); + cl_assert_equal_i(parsed.version, FSMONITOR_CLEAN_PROOF_VERSION); + cl_assert(!memcmp(parsed.tracked_policy_hash, + fixture.tracked_policy_hash, algo->rawsz)); + cl_assert_equal_i(parsed.flags, + FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX); + policy_offset = 5 * sizeof(uint32_t) + fixture.proof.token_len + + 3 * algo->rawsz; + saved = fixture.encoded.buf[policy_offset]; + fixture.encoded.buf[policy_offset] ^= 1; + assert_rejected(&parsed, &fixture.encoded, algo); + fixture.encoded.buf[policy_offset] = saved; + fixture.encoded.len--; + assert_rejected(&parsed, &fixture.encoded, algo); + strbuf_release(&unbound); + fixture_release(&fixture); +} + +void test_fsmonitor_clean_proof__binds_tracked_policy_in_both_formats(void) +{ + assert_tracked_policy_round_trip(&hash_algos[GIT_HASH_SHA1]); + assert_tracked_policy_round_trip(&hash_algos[GIT_HASH_SHA256]); +} + void test_fsmonitor_clean_proof__rejects_corrupt_records(void) { const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA1]; diff --git a/unpack-trees.c b/unpack-trees.c index 06bcb0ee9bff0e..eb90eaa40e5d19 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -1877,6 +1877,43 @@ static void update_sparsity_for_prefix(const char *prefix, static int verify_absent(const struct cache_entry *, enum unpack_trees_error_types, struct unpack_trees_options *); + +static int checkout_introduces_new_indexed_directory( + struct index_state *source, const struct index_state *result) +{ + unsigned int source_pos = 0; + + for (unsigned int result_pos = 0; + result_pos < result->cache_nr; result_pos++) { + const struct cache_entry *entry = result->cache[result_pos]; + const char *slash; + + while (source_pos < source->cache_nr && + strcmp(source->cache[source_pos]->name, entry->name) < 0) + source_pos++; + if (source_pos < source->cache_nr && + !strcmp(source->cache[source_pos]->name, entry->name)) + continue; + + for (slash = strchr(entry->name, '/'); slash; + slash = strchr(slash + 1, '/')) { + size_t len = slash - entry->name; + int position = index_name_pos(source, entry->name, len); + + if (position >= 0) + return 1; + position = -position - 1; + if (position >= source->cache_nr || + ce_namelen(source->cache[position]) <= len || + source->cache[position]->name[len] != '/' || + memcmp(source->cache[position]->name, + entry->name, len)) + return 1; + } + } + return 0; +} + /* * N-way merge "len" trees. Returns 0 on success, -1 on failure to manipulate the * resulting index, -2 on failure to reflect the changes to the work tree. @@ -2077,10 +2114,56 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options ret = check_updates(o, &o->internal.result) ? (-2) : 0; if (o->dst_index) { - if (!ret) - clean_status_transfer_current_proof_if_same_index( - &o->internal.result, o->src_index); + int history_transferred = 0; + int new_indexed_directory = 0; + + if (!ret) { + history_transferred = + clean_status_transfer_current_proof_if_same_index( + &o->internal.result, o->src_index); + if (!history_transferred && o->preserve_semantic_history) + history_transferred = + clean_status_transfer_current_proof_if_semantically_same_index( + &o->internal.result, o->src_index); + if (history_transferred && o->preserve_semantic_history) + new_indexed_directory = + checkout_introduces_new_indexed_directory( + o->src_index, &o->internal.result); + } move_index_extensions(&o->internal.result, o->src_index); + if (!ret && o->preserve_semantic_history && history_transferred && + !new_indexed_directory && + !o->src_index->sparse_index && + !o->internal.result.sparse_index && + !o->src_index->split_index && + !o->internal.result.split_index && + o->internal.result.untracked && + o->src_index->fsmonitor_token_valid && + o->internal.result.fsmonitor_token_valid && + o->src_index->fsmonitor_untracked_valid && + o->src_index->fsmonitor_untracked_extension_seen && + !o->src_index->fsmonitor_untracked_extension_invalid && + !o->src_index->fsmonitor_legacy_untracked_fallback && + o->src_index->fsmonitor_untracked_token && + o->src_index->fsmonitor_last_update && + o->internal.result.fsmonitor_last_update && + !strcmp(o->src_index->fsmonitor_untracked_token, + o->src_index->fsmonitor_last_update) && + !strcmp(o->src_index->fsmonitor_untracked_token, + o->internal.result.fsmonitor_last_update)) { + o->internal.result.fsmonitor_untracked_token = + xstrdup(o->src_index->fsmonitor_untracked_token); + o->internal.result.fsmonitor_untracked_extension_seen = 1; + o->internal.result.fsmonitor_untracked_extension_invalid = 0; + o->internal.result.fsmonitor_untracked_valid = 1; + o->internal.result.untracked->use_fsmonitor = 1; + trace2_data_intmax("fsmonitor", repo, + "history/untracked-paired-transfer", 1); + } else if (new_indexed_directory) { + trace2_data_intmax( + "fsmonitor", repo, + "history/untracked-paired-new-directory-deferred", 1); + } if (!ret) { if (git_env_bool("GIT_TEST_CHECK_CACHE_TREE", 0) && cache_tree_verify(the_repository, @@ -2309,6 +2392,45 @@ static void invalidate_ce_path(const struct cache_entry *ce, untracked_cache_invalidate_path(o->src_index, ce->name, 1); } +static void invalidate_replaced_ce_path(const struct cache_entry *old, + const struct cache_entry *new, + struct unpack_trees_options *o) +{ + const unsigned int unsafe_flags = CE_SKIP_WORKTREE | + CE_NEW_SKIP_WORKTREE | CE_INTENT_TO_ADD | CE_CONFLICTED; + const char *basename; + + if (!o->preserve_semantic_history || + o->src_index->sparse_index || o->src_index->split_index || + !o->src_index->fsmonitor_untracked_valid || + !o->src_index->untracked || + !o->src_index->untracked->use_fsmonitor || + ce_stage(old) || + strcmp(old->name, new->name) || + ((old->ce_flags | new->ce_flags) & unsafe_flags) || + (!S_ISREG(old->ce_mode) && !S_ISLNK(old->ce_mode)) || + ((old->ce_mode & S_IFMT) != (new->ce_mode & S_IFMT))) + goto rooted; + + basename = find_last_dir_sep(old->name); + basename = basename ? basename + 1 : old->name; + if (!fspathcmp(basename, ".gitattributes") || + !fspathcmp(basename, ".gitignore")) + goto rooted; + + cache_tree_invalidate_path(o->src_index, old->name); + untracked_cache_invalidate_path(o->src_index, old->name, 0); + trace2_data_intmax("fsmonitor", o->src_index->repo, + "checkout/untracked-replacement-targeted", 1); + return; + +rooted: + invalidate_ce_path(old, o); + if (o->preserve_semantic_history) + trace2_data_intmax("fsmonitor", o->src_index->repo, + "checkout/untracked-replacement-rooted", 1); +} + /* * Check that checking out ce->sha1 in subdir ce->name is not * going to overwrite any working files. @@ -2621,7 +2743,7 @@ static int merged_entry(const struct cache_entry *ce, } /* Migrate old flags over */ update |= old->ce_flags & (CE_SKIP_WORKTREE | CE_NEW_SKIP_WORKTREE); - invalidate_ce_path(old, o); + invalidate_replaced_ce_path(old, merge, o); } if (submodule_from_ce(ce) && file_exists(ce->name)) { diff --git a/unpack-trees.h b/unpack-trees.h index 5867e26e177774..b09b7e38dce988 100644 --- a/unpack-trees.h +++ b/unpack-trees.h @@ -70,7 +70,8 @@ struct unpack_trees_options { quiet, exiting_early, dry_run, - skip_cache_tree_update; + skip_cache_tree_update, + preserve_semantic_history; enum unpack_trees_reset_type reset; const char *prefix; const char *super_prefix; diff --git a/wt-status.c b/wt-status.c index 018dcc36efb705..66cb8a17cd2cca 100644 --- a/wt-status.c +++ b/wt-status.c @@ -559,9 +559,16 @@ static struct cache_entry **wt_status_collect_preload_changes( struct wt_status_change_data *d; unsigned char state = istate->preload_bulk_tracked_state[i]; + unsigned int worktree_mode = 0; int status; if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_MODIFIED) { + struct stat st; + + if (lstat(ce->name, &st)) + continue; + worktree_mode = ce_mode_from_stat( + s->repo, ce, st.st_mode); status = DIFF_STATUS_MODIFIED; modified++; } else if (state == PRELOAD_BULK_TRACKED_DEFINITIVE_DELETED) { @@ -575,8 +582,7 @@ static struct cache_entry **wt_status_collect_preload_changes( if (!d->worktree_status) d->worktree_status = status; d->mode_index = ce->ce_mode; - d->mode_worktree = status == DIFF_STATUS_MODIFIED ? - ce->ce_mode : 0; + d->mode_worktree = worktree_mode; oidcpy(&d->oid_index, &ce->oid); ce_mark_uptodate(ce); ALLOC_GROW(direct, *direct_nr + 1, direct_alloc); @@ -831,6 +837,13 @@ static void wt_status_collect_changes_index(struct wt_status *s) copy_pathspec(&rev.prune_data, &s->pathspec); run_diff_index(&rev, DIFF_INDEX_CACHED); + if (!s->pathspec.nr && !s->is_initial && + !s->ignore_submodule_arg && !s->repo->index->split_index && + s->repo->index->sparse_index == INDEX_EXPANDED && !s->change.nr) { + s->index_tree_verified = 1; + trace2_data_intmax("status", s->repo, + "index/full-tree-match", 1); + } release_revisions(&rev); } @@ -935,6 +948,15 @@ static unsigned int wt_status_untracked_dir_flags(const struct wt_status *s) return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES; } +static unsigned int wt_status_exclude_preload_flags(const struct wt_status *s) +{ + const struct untracked_cache *untracked = s->repo->index->untracked; + + if (untracked) + return untracked->dir_flags; + return wt_status_untracked_dir_flags(s); +} + struct wt_status_exclude_context { int root_fd; }; @@ -1086,29 +1108,39 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) unsigned int dir_flags; int has_fsmonitor = fsm_settings__get_mode(s->repo) > FSMONITOR_MODE_DISABLED; + int reopened_valid_token = 0; if (s->untracked_cache_preload) BUG("untracked-cache preload already started"); + if (!use_optional_locks()) + s->certify_clean_status = 0; wt_status_begin_attr_snapshot(s); /* Record the provider token before either filesystem traversal. */ refresh_fsmonitor(istate); if (s->certify_clean_status && !fsmonitor_has_pending_token(istate)) - fsmonitor_reopen_token(istate); - if (s->pathspec.nr || - s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || - s->show_ignored_mode) - return; - - dir_flags = wt_status_untracked_dir_flags(s); + reopened_valid_token = + fsmonitor_reopen_token(istate) && + istate->fsmonitor_untracked_valid && + istate->untracked && istate->untracked->root && + istate->untracked->use_fsmonitor && + !clean_status_fsmonitor_semantic_adoption_needed(istate); if (has_fsmonitor && (!fsmonitor_has_pending_token(istate) || + reopened_valid_token || !fstat_is_reliable())) { s->untracked_cache_preload = untracked_cache_preload_start_fsmonitor_excludes( - istate, dir_flags); + istate, wt_status_exclude_preload_flags(s), + s->pathspec.nr ? &s->pathspec : NULL); return; } + if (s->pathspec.nr || + s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode) + return; + + dir_flags = wt_status_untracked_dir_flags(s); if (s->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !istate->untracked && @@ -1140,7 +1172,7 @@ static void wt_status_finish_untracked_cache_preload(struct wt_status *s) return; s->untracked_cache_preloaded = untracked_cache_preload_finish( s->untracked_cache_preload, istate, - wt_status_untracked_dir_flags(s), &index_invalidated); + wt_status_exclude_preload_flags(s), &index_invalidated); s->untracked_cache_preload = NULL; if (!index_invalidated) return; @@ -1164,19 +1196,28 @@ static struct untracked_cache_dir *wt_status_find_cached_directory( const char *slash = memchr(path, '/', end - path); size_t component_len = slash ? slash - path : end - path; struct untracked_cache_dir *child = NULL; + size_t first = 0, last = dir->dirs_nr; if (!component_len) { path++; continue; } - for (size_t i = 0; i < dir->dirs_nr; i++) { - struct untracked_cache_dir *candidate = dir->dirs[i]; - - if (strlen(candidate->name) == component_len && - !strncmp(candidate->name, path, component_len)) { + while (last > first) { + size_t next = first + ((last - first) >> 1); + struct untracked_cache_dir *candidate = dir->dirs[next]; + int compare = strncmp(path, candidate->name, + component_len); + + if (!compare && candidate->name[component_len]) + compare = -1; + if (!compare) { child = candidate; break; } + if (compare < 0) + last = next; + else + first = next + 1; } if (!child || !child->recurse || child->check_only) return NULL; @@ -1225,6 +1266,161 @@ static void wt_status_collect_cached_directory( strbuf_setlen(path, base_len); } +static int wt_status_index_directory_pos( + struct index_state *istate, const char *path, size_t len, int first) +{ + int last = istate->cache_nr; + + if (first < last && + ce_namelen(istate->cache[first]) == len && + !memcmp(istate->cache[first]->name, path, len)) + return -1; + + while (last > first) { + int next = first + ((last - first) >> 1); + const struct cache_entry *ce = istate->cache[next]; + int compare = strncmp(ce->name, path, len); + + if (!compare) + compare = (unsigned char)ce->name[len] - '/'; + if (compare < 0) + first = next + 1; + else + last = next; + } + return first; +} + +static int wt_status_pathspec_matches_clean_tracked_entries( + struct wt_status *s, int validate_entries) +{ + struct index_state *istate = s->repo->index; + int i, positive = 0; + + if ((!validate_entries && !s->tracked_from_fsmonitor) || + !s->pathspec.nr || + istate->sparse_index != INDEX_EXPANDED || + fsmonitor_has_pending_token(istate) || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) + return 0; + + for (i = 0; i < s->pathspec.nr; i++) { + const struct pathspec_item *item = &s->pathspec.items[i]; + const struct cache_entry *ce; + size_t len = item->len; + int pos, subtree = 0, selected = 0, trailing = 0, wildcard = 0; + + if (item->magic & PATHSPEC_EXCLUDE) + continue; + positive = 1; + if ((item->magic & ~(PATHSPEC_FROMTOP | PATHSPEC_LITERAL | + PATHSPEC_GLOB)) || !len) + return 0; + if (item->nowildcard_len != item->len) { + if (!validate_entries || + (s->pathspec.magic & PATHSPEC_ATTR)) + return 0; + len = item->nowildcard_len; + if (!len) + return 0; + wildcard = 1; + } + if (!wildcard && item->match[len - 1] == '/') { + trailing = 1; + while (len && item->match[len - 1] == '/') + len--; + if (!len) + return 0; + } + pos = index_name_pos(istate, item->match, len); + if (pos >= 0 && trailing && memchr(item->match, '/', len) && + !validate_entries) + return 0; + if (pos < 0) { + if (!validate_entries) + return 0; + pos = -pos - 1; + if (!wildcard) { + pos = wt_status_index_directory_pos( + istate, item->match, len, pos); + if (pos < 0) + return 0; + } + subtree = 1; + } + if (wildcard) + subtree = 1; + for (; pos < istate->cache_nr; pos++) { + ce = istate->cache[pos]; + if (subtree && + (ce_namelen(ce) < len || + strncmp(ce->name, item->match, len) || + (!wildcard && (ce_namelen(ce) == len || + ce->name[len] != '/')))) + break; + if (((subtree && + (wildcard || (s->pathspec.magic & PATHSPEC_EXCLUDE))) || + (validate_entries && trailing)) && + !(s->pathspec.magic & PATHSPEC_ATTR) && + !ce_path_match(istate, ce, &s->pathspec, NULL)) { + selected = 1; + if (!subtree) + break; + continue; + } + if (S_ISGITLINK(ce->ce_mode) && + s->ignore_submodule_arg && + !strcmp(s->ignore_submodule_arg, "all")) { + if (validate_entries && + (ce->ce_flags & ~(CE_UPTODATE | CE_HASHED | + CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE))) + return 0; + selected = 1; + if (!subtree) + break; + continue; + } + if ((!S_ISREG(ce->ce_mode) && !S_ISLNK(ce->ce_mode)) || + (validate_entries && + (!(ce->ce_flags & CE_FSMONITOR_VALID) || + (ce->ce_flags & ~(CE_UPTODATE | CE_HASHED | + CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE))))) + return 0; + selected = 1; + if (!subtree) + break; + } + if (!selected && !validate_entries) + return 0; + } + return positive; +} + +static int wt_status_ignored_submodules_are_clean(struct wt_status *s) +{ + const struct index_state *istate = s->repo->index; + const unsigned int supported_flags = + CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID | + CE_UPDATE_IN_BASE; + + if (s->pathspec.nr || !s->ignore_submodule_arg || + strcmp(s->ignore_submodule_arg, "all")) + return 0; + + for (size_t i = 0; i < istate->cache_nr; i++) { + const struct cache_entry *ce = istate->cache[i]; + + if ((ce->ce_flags & ~supported_flags) || + (!S_ISGITLINK(ce->ce_mode) && + (!(ce->ce_flags & CE_FSMONITOR_VALID) || + (!S_ISREG(ce->ce_mode) && !S_ISLNK(ce->ce_mode))))) + return 0; + } + return 1; +} + static int wt_status_collect_cached_pathspec( struct wt_status *s, struct dir_struct *dir, @@ -1239,7 +1435,7 @@ static int wt_status_collect_cached_pathspec( size_t len; int pos; - if (s->pathspec.nr != 1 || s->pathspec.has_wildcard || + if (!s->pathspec.nr || s->pathspec.has_wildcard || (s->pathspec.magic & ~(PATHSPEC_FROMTOP | PATHSPEC_LITERAL)) || s->show_ignored_mode || s->show_untracked_files != SHOW_NORMAL_UNTRACKED_FILES || @@ -1257,6 +1453,14 @@ static int wt_status_collect_cached_pathspec( &uc->ss_excludes_file.oid)) return 0; + if (wt_status_pathspec_matches_clean_tracked_entries(s, 0)) { + trace2_data_intmax("status", s->repo, + "untracked/pathspec-cache", 1); + return 1; + } + if (s->pathspec.nr != 1) + return 0; + item = &s->pathspec.items[0]; if (item->nowildcard_len != item->len) return 0; @@ -1269,7 +1473,10 @@ static int wt_status_collect_cached_pathspec( pos = index_name_pos(istate, item->match, len); if (pos >= 0) return 0; - pos = -pos - 1; + pos = wt_status_index_directory_pos( + istate, item->match, len, -pos - 1); + if (pos < 0) + return 0; if (pos >= istate->cache_nr) return 0; ce = istate->cache[pos]; @@ -1303,6 +1510,28 @@ static int wt_status_collect_cached_pathspec( return 1; } +static void wt_status_materialize_deferred_untracked( + struct index_state *istate) +{ + const char *path, *end; + + if (!istate->untracked || + !istate->untracked->fsmonitor_dirty_paths.len) + return; + path = istate->untracked->fsmonitor_dirty_paths.buf; + end = path + istate->untracked->fsmonitor_dirty_paths.len; + + /* Deferred provider paths are not saved with the cache. */ + while (path < end) { + size_t len = strlen(path) + 1; + + untracked_cache_invalidate_path(istate, path, 1); + path += len; + } + istate->cache_changed |= UNTRACKED_CHANGED; + istate->fsmonitor_untracked_must_persist = 1; +} + static int wt_status_collect_untracked_1( struct wt_status *s, struct string_list *untracked, @@ -1314,8 +1543,10 @@ static int wt_status_collect_untracked_1( uint64_t t_begin = getnanotime(); struct index_state *istate = s->repo->index; - if (!s->show_untracked_files) + if (!s->show_untracked_files) { + wt_status_materialize_deferred_untracked(istate); return 0; + } if (s->show_untracked_files != SHOW_ALL_UNTRACKED_FILES) dir.flags |= wt_status_untracked_dir_flags(s); @@ -1337,6 +1568,10 @@ static int wt_status_collect_untracked_1( if (wt_status_collect_cached_pathspec(s, &dir, untracked)) { used_untracked_cache = 1; + } else if (wt_status_pathspec_matches_clean_tracked_entries(s, 0)) { + trace2_data_intmax("status", s->repo, + "untracked/pathspec-cache", 1); + used_untracked_cache = 0; } else { fill_directory(&dir, istate, &s->pathspec); if (s->certify_clean_status && dir.internal.traversal_failed) @@ -1351,6 +1586,11 @@ static int wt_status_collect_untracked_1( } } string_list_sort_u(untracked, 0); + if (!s->pathspec.nr && used_untracked_cache && dir.nr && + dir.untracked->dir_opened && !dir.internal.traversal_failed && + !clean_status_external_history_was_restored(istate) && + (istate->cache_changed & UNTRACKED_CHANGED)) + istate->fsmonitor_untracked_must_persist = 1; for (i = 0; i < dir.ignored_nr; i++) { struct dir_entry *ent = dir.ignored[i]; @@ -1360,6 +1600,8 @@ static int wt_status_collect_untracked_1( string_list_sort_u(ignored, 0); dir_clear(&dir); + if (!used_untracked_cache) + wt_status_materialize_deferred_untracked(istate); if (advice_enabled(ADVICE_STATUS_U_OPTION)) s->untracked_in_ms = (getnanotime() - t_begin) / 1000000; @@ -1440,6 +1682,7 @@ struct wt_status_token_closure { struct string_list staged_untracked; struct string_list staged_ignored; int staged_untracked_ready; + int staged_output_matches_status; int refresh_result; int queries; }; @@ -1456,17 +1699,43 @@ static int wt_status_stage_untracked( struct wt_status_token_closure *closure) { struct wt_status *s = closure->status; + struct index_state *istate = s->repo->index; struct pathspec pathspec = s->pathspec; + enum untracked_status_type requested_untracked = + s->show_untracked_files; + int prime_configured_cache = + requested_untracked == SHOW_ALL_UNTRACKED_FILES && + istate->untracked && !s->untracked_cache_preload && + istate->untracked->dir_flags == + (DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES); wt_status_discard_staged_untracked(closure); + closure->staged_output_matches_status = !prime_configured_cache; /* A provider token can certify only a complete untracked traversal. */ if (pathspec.nr) memset(&s->pathspec, 0, sizeof(s->pathspec)); + if (prime_configured_cache) + s->show_untracked_files = SHOW_NORMAL_UNTRACKED_FILES; closure->staged_untracked_ready = wt_status_collect_untracked_1( s, &closure->staged_untracked, - &closure->staged_ignored); + &closure->staged_ignored) || + (!istate->untracked && + !s->certify_untracked_scan_failed); + s->show_untracked_files = requested_untracked; + if (prime_configured_cache) { + string_list_clear(&closure->staged_untracked, 0); + string_list_clear(&closure->staged_ignored, 0); + } + if (closure->staged_untracked_ready && + istate->preload_untracked == &s->untracked) { + if (closure->staged_untracked.nr || + !closure->use_bulk_provider) + istate->preload_untracked = NULL; + else + wt_status_discard_staged_untracked(closure); + } if (pathspec.nr) { s->pathspec = pathspec; /* The ordinary scoped traversal supplies the displayed results. */ @@ -1483,7 +1752,8 @@ static void wt_status_publish_staged_untracked( { struct wt_status *s = closure->status; - if (!closure->staged_untracked_ready || s->pathspec.nr) + if (!closure->staged_untracked_ready || + !closure->staged_output_matches_status || s->pathspec.nr) return; if (s->untracked.nr || s->ignored.nr) BUG("publishing untracked results over collected status"); @@ -1564,9 +1834,9 @@ static void wt_status_refresh_for_token( { struct index_state *istate = s->repo->index; - clean_status_release_proof_epoch(*epoch); - *epoch = clean_status_capture_proof_epoch( - istate, s->attr_source_snapshot, 0); + if (!*epoch) + *epoch = clean_status_capture_proof_epoch( + istate, s->attr_source_snapshot, 0); if (*epoch && use_bulk_provider) istate->preload_bulk_proof_epoch = *epoch; if (*epoch) { @@ -1594,10 +1864,36 @@ static int wt_status_close_ordinary_fsmonitor_token( * be validated by capturing its inputs afterward. */ if (validate_epoch) { - wt_status_refresh_for_token( - s, closure->refresh_flags, &scan_epoch, - closure->use_bulk_provider, - &closure->refresh_result); + if (s->allow_clean_status_shortcuts && + s->certify_clean_status && + closure->can_prime && + !s->untracked_cache_preload && + !getenv(INDEX_ENVIRONMENT) && + !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + istate->fsmonitor_token_valid && + clean_status_revalidated_token_matches(istate) && + !clean_status_manifest_global_fallback(istate) && + !clean_status_worktree_manifest_needs_refresh(istate) && + clean_status_index_entries_are_certifiable(istate) && + (scan_epoch = clean_status_capture_proof_epoch( + istate, s->attr_source_snapshot, 0)) && + wt_status_stage_untracked(closure) && + closure->staged_untracked.nr && + !clean_status_worktree_manifest_needs_refresh(istate)) { + s->tracked_from_fsmonitor = 1; + closure->untracked_ready = 1; + closure->untracked_proof_complete = 1; + } else { + if (closure->staged_untracked_ready) { + closure->untracked_ready = 1; + closure->untracked_proof_complete = 1; + } + wt_status_refresh_for_token( + s, closure->refresh_flags, &scan_epoch, + closure->use_bulk_provider, + &closure->refresh_result); + } if (!scan_epoch) return 0; } else if (!refreshed_before_closure || @@ -1651,13 +1947,20 @@ static int wt_status_close_ordinary_fsmonitor_token( closure->untracked_proof_complete, wt_status_untracked_cache_valid( closure)); + if (s->tracked_from_fsmonitor) { + s->certify_clean_status = 0; + trace2_data_intmax("status", s->repo, + "fsmonitor/tracked-clean", 1); + } return 1; } break; } + s->tracked_from_fsmonitor = 0; wt_status_discard_staged_untracked(closure); closure->untracked_proof_complete = - !closure->require_untracked || !istate->untracked; + !closure->require_untracked || + (!istate->untracked && !closure->can_prime); clean_status_release_proof_epoch(scan_epoch); scan_epoch = NULL; if (!fsmonitor_token_requires_rescan(result)) @@ -1767,9 +2070,24 @@ wt_status_close_semantic_fsmonitor_token( istate, wt_status_untracked_cache_valid(closure)); if (result != FSMONITOR_TOKEN_CLEAN) { + int reuse_semantic_subtrees = + result == FSMONITOR_TOKEN_CHANGED && + !clean_status_filter_scope_needs_validation(istate) && + !clean_status_worktree_manifest_needs_refresh(istate) && + semantic_verify_proof_is_current(istate, *proof); + wt_status_discard_staged_untracked(closure); - untracked_cache_invalidate_all(istate); - fsmonitor_invalidate_semantics(istate); + if (reuse_semantic_subtrees) { + /* Recompute scanned subtrees after the localized delta. */ + untracked_cache_recompute_fsmonitor_valid_recursive( + istate->untracked); + trace2_data_intmax( + "status", s->repo, + "fsmonitor_token/reused-semantic-subtrees", 1); + } else { + untracked_cache_invalidate_all(istate); + fsmonitor_invalidate_semantics(istate); + } closure->untracked_ready = 0; closure->untracked_proof_complete = 0; wt_status_discard_semantic_verify( @@ -1807,7 +2125,7 @@ static int wt_status_tracked_fsmonitor_state_is_current( struct index_state *istate = s->repo->index; return s->allow_clean_status_shortcuts && - !s->certify_clean_status && !s->pathspec.nr && + !s->certify_clean_status && !getenv(INDEX_ENVIRONMENT) && !istate->split_index && istate->sparse_index == INDEX_EXPANDED && fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && @@ -1847,7 +2165,9 @@ static int wt_status_close_fsmonitor_token( s, &proof, "provider-unavailable"); if (!refreshed_before_closure && attr_inputs_match && wt_status_tracked_fsmonitor_state_is_current(s) && - clean_status_index_entries_are_certifiable(istate)) { + (wt_status_pathspec_matches_clean_tracked_entries(s, 1) || + clean_status_index_entries_are_certifiable(istate) || + wt_status_ignored_submodules_are_clean(s))) { s->tracked_from_fsmonitor = 1; trace2_data_intmax( "status", s->repo, @@ -1878,7 +2198,7 @@ static int wt_status_close_fsmonitor_token( s->tracked_from_fsmonitor = 0; closure.can_prime = require_untracked && - istate->untracked && + (istate->untracked || s->certify_clean_status) && s->show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode; closure.use_bulk_provider = @@ -1887,7 +2207,14 @@ static int wt_status_close_fsmonitor_token( !istate->untracked->root || (istate->fsmonitor_legacy_untracked_adopted && istate->fsmonitor_untracked_valid && - istate->untracked->root->valid_recursive); + istate->untracked->root->valid_recursive) || + (!require_untracked && + (s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || + s->show_ignored_mode) && + istate->fsmonitor_untracked_token && + istate->fsmonitor_last_update && + !strcmp(istate->fsmonitor_untracked_token, + istate->fsmonitor_last_update)); closure.untracked_proof_complete = !require_untracked || !istate->untracked || (istate->fsmonitor_legacy_untracked_adopted && @@ -1918,6 +2245,7 @@ static int wt_status_close_fsmonitor_token( /* Keep the last valid token and fall back to complete scans. */ fallback: + s->tracked_from_fsmonitor = 0; wt_status_discard_semantic_verify(s, &proof, "fallback"); wt_status_discard_staged_untracked(&closure); preload_index_bulk_result_clear(istate); diff --git a/wt-status.h b/wt-status.h index 6f5300fe8e5481..5106c02384dda4 100644 --- a/wt-status.h +++ b/wt-status.h @@ -145,6 +145,7 @@ struct wt_status { int workdir_dirty; unsigned allow_clean_status_shortcuts : 1; unsigned certify_clean_status : 1; + unsigned index_tree_verified : 1; unsigned tracked_from_fsmonitor : 1; unsigned untracked_from_token_closure : 1; unsigned untracked_from_preload : 1; From 35e68d5cba0faa9b8ca4a6e64d7fede9062c0e6b Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Tue, 28 Jul 2026 19:51:08 -0700 Subject: [PATCH 270/403] checkout: avoid rewriting an unchanged index Checking out an unchanged path currently rewrites the index even when no entries changed. This also affects "git restore", which uses the same path checkout machinery. Use SKIP_IF_UNCHANGED to avoid the write when the index is unchanged and no post-index-change hook is installed. Preserve the existing write when such a hook exists, since checkout has invoked it even for unchanged paths and t7113 explicitly covers that behavior. Actual worktree writes still refresh cached stat information and mark the index dirty, so those updates continue to be written. Mark sparse-directory entries dirty when replacing their object IDs in non-overlay mode. These in-place updates previously relied on the unconditional write and must not be skipped. On a repository with 1,000,001 tracked paths and a 103 MiB index, checking out an unchanged path improves from 242 ms to 32 ms. Add checkout and restore coverage while disabling fsmonitor within timestamp-sensitive tests, since fsmonitor metadata can itself dirty the index. Signed-off-by: Ted Nyman --- builtin/checkout.c | 8 +++++++- t/t2022-checkout-paths.sh | 10 ++++++++++ t/t2070-restore.sh | 10 ++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/builtin/checkout.c b/builtin/checkout.c index bae66a8ae5456c..fbc324b8b7dee7 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -193,6 +193,7 @@ static int try_update_sparse_directory(const struct object_id *oid, *context->index_changed = 1; oidcpy(&old->oid, oid); old->ce_flags |= CE_UPDATE; + the_repository->index->cache_changed |= CE_ENTRY_CHANGED; result = 0; } @@ -754,11 +755,16 @@ static int checkout_paths(const struct checkout_opts *opts, checkout_index = opts->checkout_index; if (checkout_index) { + unsigned int flags = COMMIT_LOCK; + if (preserve_source_tree_history && (source_tree_index_changed || errs)) clean_status_invalidate_current_proof( the_repository->index); - if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) + if (!the_repository->index->cache_changed && + !hook_exists(the_repository, "post-index-change")) + flags |= SKIP_IF_UNCHANGED; + if (write_locked_index(the_repository->index, &lock_file, flags)) die(_("unable to write new index file")); } else { /* diff --git a/t/t2022-checkout-paths.sh b/t/t2022-checkout-paths.sh index c49ba7f9bd4fe0..ac1ba6e3558672 100755 --- a/t/t2022-checkout-paths.sh +++ b/t/t2022-checkout-paths.sh @@ -19,6 +19,16 @@ test_expect_success setup ' test_tick && git commit -m "next has dir/next but not dir/main" ' +test_expect_success 'checkout does not rewrite an unchanged index' ' + test_config core.fsmonitor false && + git update-index --no-fsmonitor && + test-tool chmtime =1000000000 .git/index && + git checkout -- dir/common && + test "$(test-tool chmtime --get .git/index)" = 1000000000 && + git checkout HEAD -- dir/common && + test "$(test-tool chmtime --get .git/index)" = 1000000000 +' + test_expect_success 'checking out paths out of a tree does not clobber unrelated paths' ' git checkout next && git reset --hard && diff --git a/t/t2070-restore.sh b/t/t2070-restore.sh index 2c222fb9342777..81b870c77074ec 100755 --- a/t/t2070-restore.sh +++ b/t/t2070-restore.sh @@ -21,6 +21,16 @@ test_expect_success 'setup' ' git update-ref refs/heads/one main ' +test_expect_success 'restore does not rewrite an unchanged index' ' + test_config core.fsmonitor false && + git update-index --no-fsmonitor && + test-tool chmtime =1000000000 .git/index && + git restore --worktree first.t && + test "$(test-tool chmtime --get .git/index)" = 1000000000 && + git restore --staged first.t && + test "$(test-tool chmtime --get .git/index)" = 1000000000 +' + test_expect_success 'restore without pathspec is not ok' ' test_must_fail git restore && test_must_fail git restore --source=first From af83c31026f80af1af2b472d34db01bf0e2c238d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:10 -0500 Subject: [PATCH 271/403] t7530: tolerate an already-exited fast fallback The exclude-race helper observes a Trace2 fallback marker before stopping its background status process. A fast fallback can exit successfully between that observation and kill, which made an otherwise correct race test fail nondeterministically. Keep terminating a process that is still running. If it has already exited, require wait to report successful completion instead of treating the failed signal as a test failure. --- t/t7530-status-clean-sidecar.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 1435fb94dadfad..026523ab06ca1e 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -233,8 +233,12 @@ stop_after_fast_fallback () { if grep -q "\"value\":\"fast-excludes-raced\"" \ "$race_trace" then - kill "$status_pid" 2>/dev/null || return 1 - wait "$status_pid" 2>/dev/null || : + if kill "$status_pid" 2>/dev/null + then + wait "$status_pid" 2>/dev/null || : + else + wait "$status_pid" 2>/dev/null || return 1 + fi status_pid= return 0 fi From 3bedbdabdbd279d57c3235cc64d15c27170a8d23 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:33 -0500 Subject: [PATCH 272/403] commit: honor optional locks during a dry run A commit dry run refreshes the index to report whether anything could be committed. Its as-is preparation also takes the real index lock and persists refreshed stat information, even when optional locks have been explicitly disabled. Avoid taking or writing the real index lock for an as-is dry run under --no-optional-locks. Keep the in-memory refresh and cache-tree update, so clean stat mismatches, genuine worktree changes, and staged changes produce the same result as before. Real commits and partial dry runs retain their existing locking behavior. Cover clean and dirty dry runs, a preexisting index lock, and the ordinary dry run that still persists its stat repair. --- builtin/commit.c | 10 ++++++--- t/t7501-commit-basic-functionality.sh | 31 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 731ef7df12b4aa..23bf6325e93d66 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -512,8 +512,11 @@ static const char *prepare_index(const char **argv, const char *prefix, * We still need to refresh the index here. */ if (!only && !pathspec.nr) { - repo_hold_locked_index(the_repository, &index_lock, - LOCK_DIE_ON_ERROR); + int update_index = !is_status || use_optional_locks(); + + if (update_index) + repo_hold_locked_index(the_repository, &index_lock, + LOCK_DIE_ON_ERROR); if (!fstat_is_reliable() || the_repository->index->split_index || fsm_settings__get_mode(the_repository) != @@ -525,7 +528,8 @@ static const char *prepare_index(const char **argv, const char *prefix, if (the_repository->index->cache_changed || !cache_tree_fully_valid(the_repository->index->cache_tree)) cache_tree_update(the_repository->index, WRITE_TREE_SILENT); - if (write_locked_index(the_repository->index, &index_lock, + if (update_index && + write_locked_index(the_repository->index, &index_lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) die(_("unable to write new index file")); commit_style = COMMIT_AS_IS; diff --git a/t/t7501-commit-basic-functionality.sh b/t/t7501-commit-basic-functionality.sh index 7794babe462bbe..a15de3a3a32097 100755 --- a/t/t7501-commit-basic-functionality.sh +++ b/t/t7501-commit-basic-functionality.sh @@ -79,6 +79,37 @@ test_expect_success '--dry-run fails with nothing to commit' ' test_must_fail git commit -m initial --dry-run ' +test_expect_success '--no-optional-locks prevents dry-run index updates' ' + test_when_finished "rm -rf optional-locks-dry-run" && + test_create_repo optional-locks-dry-run && + ( + cd optional-locks-dry-run && + git config core.fsmonitor false && + test_commit base tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + test_must_fail git --no-optional-locks commit --dry-run >../actual && + test_grep "working tree clean" ../actual && + test_is_magic_mtime .git/index +1 && + echo modified >>tracked && + test_must_fail git --no-optional-locks commit --dry-run >../actual && + test_grep "modified:.*tracked" ../actual && + test_is_magic_mtime .git/index +1 && + git add tracked && + test_set_magic_mtime tracked && + test_set_magic_mtime .git/index +1 && + >.git/index.lock && + git --no-optional-locks commit --dry-run >../actual && + test_grep "modified:.*tracked" ../actual && + test_is_magic_mtime .git/index +1 && + test_must_fail git commit --dry-run >../actual 2>../err && + test_grep "index.lock" ../err && + rm .git/index.lock && + git commit --dry-run >../actual && + ! test_is_magic_mtime .git/index +1 + ) +' + test_expect_success '--short fails with nothing to commit' ' test_must_fail git commit -m initial --short ' From 60c41c10cbb8c786cfff647e398c6e9e7fe41f4c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:43 -0500 Subject: [PATCH 273/403] reset: avoid rewriting an unchanged index A mixed reset always writes the index after reading its target tree, even when its selected paths and stat information are already current. Replacing an identical index invalidates its physical clean-status proof and makes the following status rescan the repository. Skip the write only for a mixed reset with no index changes and no post-index-change hook. Continue taking the index lock, refreshing as requested, updating HEAD and ORIG_HEAD, and running configured hooks; hard, merge, and keep resets retain their existing behavior. Cover same-HEAD and pathspec resets, resetting to another commit with an identical tree, preserved ORIG_HEAD, and an installed hook that still forces the original index write. --- builtin/reset.c | 9 ++++++++- t/t7102-reset.sh | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/builtin/reset.c b/builtin/reset.c index 69dda83f02ca8f..0c5eae4724ea10 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -19,6 +19,7 @@ #include "gettext.h" #include "hash.h" #include "hex.h" +#include "hook.h" #include "lockfile.h" #include "object.h" #include "pretty.h" @@ -528,6 +529,8 @@ int cmd_reset(int argc, if (reset_type != SOFT) { struct lock_file lock = LOCK_INIT; + unsigned int write_flags = COMMIT_LOCK; + repo_hold_locked_index(the_repository, &lock, LOCK_DIE_ON_ERROR); if (reset_type == MIXED) { @@ -575,7 +578,11 @@ int cmd_reset(int argc, free(ref); } - if (write_locked_index(the_repository->index, &lock, COMMIT_LOCK)) + if (reset_type == MIXED && + !the_repository->index->cache_changed && + !hook_exists(the_repository, "post-index-change")) + write_flags |= SKIP_IF_UNCHANGED; + if (write_locked_index(the_repository->index, &lock, write_flags)) die(_("Could not write new index file.")); } diff --git a/t/t7102-reset.sh b/t/t7102-reset.sh index 298272cb13c033..993b7c260d0f84 100755 --- a/t/t7102-reset.sh +++ b/t/t7102-reset.sh @@ -482,6 +482,48 @@ test_expect_success 'resetting an unmodified path is a no-op' ' git diff-index --cached --exit-code HEAD ' +test_expect_success 'mixed resets do not rewrite an unchanged index' ' + test_when_finished "rm -rf reset-unchanged-index" && + git init reset-unchanged-index && + ( + cd reset-unchanged-index && + sane_unset GIT_TEST_SPLIT_INDEX && + git config core.fsmonitor false && + test_commit base tracked && + git commit --allow-empty -m same-tree && + git update-index --no-fsmonitor && + test_set_magic_mtime .git/index && + + GIT_TRACE2_EVENT="$PWD/.git/head.trace" \ + git reset --mixed HEAD && + test_is_magic_mtime .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/head.trace && + + GIT_TRACE2_EVENT="$PWD/.git/path.trace" \ + git reset HEAD -- tracked && + test_is_magic_mtime .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/path.trace && + + old_head=$(git rev-parse HEAD) && + GIT_TRACE2_EVENT="$PWD/.git/same-tree.trace" \ + git reset --mixed HEAD^ && + test_is_magic_mtime .git/index && + test_grep ! "\"label\":\"do_write_index\"" \ + .git/same-tree.trace && + test "$(git rev-parse ORIG_HEAD)" = "$old_head" && + test "$(git rev-parse HEAD)" = "$(git rev-parse base)" && + + test_hook --setup post-index-change <<-\EOF && + echo "$1 $2" >.git/hook-args + EOF + GIT_TRACE2_EVENT="$PWD/.git/hook.trace" \ + git reset --mixed HEAD && + test_grep "\"label\":\"do_write_index\"" .git/hook.trace && + test_grep "^0 1$" .git/hook-args && + ! test_is_magic_mtime .git/index + ) +' + test_reset_refreshes_index () { # To test whether the index is refreshed in `git reset --mixed` with From 2039dce24f12767ec42712b059297eb27347f15c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:58 -0500 Subject: [PATCH 274/403] status: certify clean output with configured stash display Enabling status.showStash prevented every clean-status proof issuance path. Exact porcelain output is no longer empty when a stash exists, and ordinary root status rejected the stash decoration even after a complete verified worktree scan. Allow only the existing normal, root-wide issuance path to include the live stash summary. Preserve the exact-porcelain issuance restriction and every untracked, ignored, sparse, pathspec, and provider safety check. Exercise a real stash and configuration-namespace transition: exact porcelain saves reusable history without issuing a proof, ordinary status restores that history and issues one, and later long and porcelain queries reuse it while displaying current stash information. --- builtin/commit.c | 2 +- clean-status-sidecar-issue.c | 3 ++- t/t7530-status-clean-sidecar.sh | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 23bf6325e93d66..41b9ea47fb4062 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1791,7 +1791,7 @@ struct repository *repo UNUSED) s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES; normal_clean_query = default_status_command && status_format == STATUS_FORMAT_NONE && normal_has_head && - !s.pathspec.nr && !s.show_branch && !s.show_stash && + !s.pathspec.nr && !s.show_branch && !s.show_ignored_mode && !s.null_termination && !s.verbose && !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index 274bacbb870965..d046307b2a7d34 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -50,7 +50,8 @@ static int output_is_certifiable(const struct wt_status *status, (normal_clean_query && status->status_format == STATUS_FORMAT_NONE)) && !status->pathspec.nr && !status->show_branch && - !status->show_stash && !status->show_ignored_mode && + (!status->show_stash || normal_clean_query) && + !status->show_ignored_mode && !status->null_termination && !status->verbose && status->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !status->change.nr && !status->untracked.nr && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 026523ab06ca1e..9d3120ef893c67 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -1134,6 +1134,49 @@ test_expect_success DURABLE_FSMONITOR \ --porcelain=v2 --branch ' +test_expect_success DURABLE_FSMONITOR \ + 'configured stash output does not prevent clean sidecar issuance' ' + stash_repo=sidecar-configured-stash && + test_when_finished "stop_daemon $stash_repo" && + setup_repo "$stash_repo" && + git -C "$stash_repo" config core.autocrlf false && + git -C "$stash_repo" config core.untrackedCache true && + test_write_lines stashed >"$stash_repo/tracked" && + git -C "$stash_repo" stash push -qm configured-stash && + test-tool chmtime -120 "$stash_repo/tracked" && + git -C "$stash_repo" update-index --refresh && + prime_semantic_history "$stash_repo" && + git -C "$stash_repo" config status.showStash true && + test_path_is_missing "$stash_repo/.git/index.csts" && + + test_env GIT_TRACE2_EVENT="$PWD/configured-stash.exact.trace" \ + bulk_status -C "$stash_repo" status --porcelain=v2 \ + >configured-stash.exact && + test_grep "^# stash 1$" configured-stash.exact && + test_trace2_data fsmonitor history/external-stored 1 \ + configured-stash.issue && + test_grep "nothing to commit, working tree clean" \ + configured-stash.issue && + test_grep "Your stash currently has 1 entry" configured-stash.issue && + test_trace2_data fsmonitor history/external-restored 1 \ + Date: Wed, 12 Aug 2026 00:00:07 -0500 Subject: [PATCH 275/403] t7530: preserve clean proofs across no-op index commands Avoiding an index rewrite matters because the clean-status sidecar is bound to the physical index. A logically harmless checkout, restore, or mixed reset must leave both artifacts unchanged so the next status can reuse its existing proof. Exercise unchanged checkout and restore paths plus mixed HEAD and pathspec resets with the real fsmonitor provider. Require identical index and sidecar bytes, no index write, and an output-equivalent subsequent status without an index read, refresh, preload, or directory traversal. --- t/t7530-status-clean-sidecar.sh | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 9d3120ef893c67..a8006ea182d1cb 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -1355,6 +1355,45 @@ test_expect_success DURABLE_FSMONITOR \ test_grep "^1 \.M .* tracked$" external-pathspec-status.root ' +test_expect_success DURABLE_FSMONITOR \ + 'no-op checkout, restore, and mixed reset preserve a clean sidecar' ' + checkout_repo=sidecar-noop-checkout && + test_when_finished "stop_daemon $checkout_repo" && + setup_repo "$checkout_repo" && + git -C "$checkout_repo" config core.untrackedCache true && + issue_sidecar "$checkout_repo" && + + for checkout_case in checkout-index checkout-head \ + restore-worktree restore-staged reset-path reset-head \ + reset-mixed-head reset-mixed-no-refresh + do + case "$checkout_case" in + checkout-index) set -- checkout -- tracked ;; + checkout-head) set -- checkout HEAD -- tracked ;; + restore-worktree) set -- restore --worktree tracked ;; + restore-staged) set -- restore --staged tracked ;; + reset-path) set -- reset -- tracked ;; + reset-head) set -- reset HEAD -- tracked ;; + reset-mixed-head) set -- reset --mixed HEAD ;; + reset-mixed-no-refresh) + set -- reset --mixed --no-refresh HEAD ;; + esac && + cp "$checkout_repo/.git/index" "$checkout_case.before" && + cp "$checkout_repo/.git/index.csts" \ + "$checkout_case.sidecar" && + GIT_TRACE2_EVENT="$PWD/$checkout_case.command.trace" \ + git -C "$checkout_repo" "$@" && + test_cmp_bin "$checkout_case.before" \ + "$checkout_repo/.git/index" && + test_cmp_bin "$checkout_case.sidecar" \ + "$checkout_repo/.git/index.csts" && + test_grep ! "\"label\":\"do_write_index\"" \ + "$checkout_case.command.trace" && + assert_clean_sidecar_hit "$checkout_repo" "$checkout_repo" \ + "$checkout_case.hit" || return 1 + done +' + test_expect_success DURABLE_FSMONITOR \ 'clean pathspec status reuses an existing root-wide clean proof' ' test_when_finished "stop_daemon clean-pathspec-status" && From 4e32c1ab606ea6300b352310f314c25a9742df96 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 12 Aug 2026 00:39:37 -0500 Subject: [PATCH 276/403] checkout-index: avoid rewriting an unchanged index With -u, checkout-index always commits its index lock after checking out the requested paths. An already-current entry leaves cache_changed clear, but the unconditional write still replaces a byte-identical index and invalidates a clean-status sidecar tied to its identity. Skip that write only when the index is unchanged and no post-index-change hook is installed. Keep taking the lock, writing genuine stat updates, and invoking configured hooks as before. Exercise the unchanged index and hook cases directly. Also cover path, force, all-files, and stdin invocations with a real fsmonitor daemon, requiring both the index and sidecar to survive and the next status to reuse its clean proof without reading the index. --- builtin/checkout-index.c | 13 ++++++++--- t/t2006-checkout-index-basic.sh | 31 +++++++++++++++++++++++++ t/t7530-status-clean-sidecar.sh | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/builtin/checkout-index.c b/builtin/checkout-index.c index 1807696b1c92c8..ac17acea58233e 100644 --- a/builtin/checkout-index.c +++ b/builtin/checkout-index.c @@ -13,6 +13,7 @@ #include "config.h" #include "environment.h" #include "gettext.h" +#include "hook.h" #include "lockfile.h" #include "quote.h" #include "cache-tree.h" @@ -360,8 +361,14 @@ int cmd_checkout_index(int argc, if (err) return 1; - if (is_lock_file_locked(&lock_file) && - write_locked_index(repo->index, &lock_file, COMMIT_LOCK)) - die("Unable to write new index file"); + if (is_lock_file_locked(&lock_file)) { + unsigned int flags = COMMIT_LOCK; + + if (!repo->index->cache_changed && + !hook_exists(repo, "post-index-change")) + flags |= SKIP_IF_UNCHANGED; + if (write_locked_index(repo->index, &lock_file, flags)) + die("Unable to write new index file"); + } return 0; } diff --git a/t/t2006-checkout-index-basic.sh b/t/t2006-checkout-index-basic.sh index 6538a24c951f9b..f1ade19c9c9f4d 100755 --- a/t/t2006-checkout-index-basic.sh +++ b/t/t2006-checkout-index-basic.sh @@ -107,4 +107,35 @@ test_expect_success 'checkout-index --temp correctly reports error for submodule test_grep "cannot create temporary submodule sub" stderr ' +test_expect_success 'checkout-index -u preserves an unchanged index' ' + test_when_finished "rm -rf checkout-index-unchanged" && + test_create_repo checkout-index-unchanged && + test_commit -C checkout-index-unchanged base tracked && + test-tool -C checkout-index-unchanged chmtime -120 tracked && + git -C checkout-index-unchanged update-index --refresh && + cp checkout-index-unchanged/.git/index checkout-index.before && + GIT_TRACE2_EVENT="$PWD/checkout-index.trace" \ + git -C checkout-index-unchanged checkout-index -u tracked && + test_cmp_bin checkout-index.before checkout-index-unchanged/.git/index && + test_grep ! "\"label\":\"do_write_index\"" checkout-index.trace +' + +test_expect_success 'checkout-index -u retains post-index-change hooks' ' + test_when_finished "rm -rf checkout-index-hook" && + test_create_repo checkout-index-hook && + test_commit -C checkout-index-hook base tracked && + test-tool -C checkout-index-hook chmtime -120 tracked && + git -C checkout-index-hook update-index --refresh && + mkdir checkout-index-hook/hooks && + git -C checkout-index-hook config core.hooksPath hooks && + write_script checkout-index-hook/hooks/post-index-change <<-\EOF && + printf "%s %s\n" "$1" "$2" >hook-actual + EOF + GIT_TRACE2_EVENT="$PWD/checkout-index-hook.trace" \ + git -C checkout-index-hook checkout-index -u tracked && + test_write_lines "0 0" >checkout-index-hook.expect && + test_cmp checkout-index-hook.expect checkout-index-hook/hook-actual && + test_grep "\"label\":\"do_write_index\"" checkout-index-hook.trace +' + test_done diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index a8006ea182d1cb..ce8c9eb38c19da 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2237,4 +2237,44 @@ test_expect_success DURABLE_FSMONITOR \ external-token.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'no-op checkout-index -u preserves a clean status proof' ' + update_repo=sidecar-noop-checkout-index && + test_when_finished "stop_daemon $update_repo" && + setup_repo "$update_repo" && + git -C "$update_repo" config core.untrackedCache true && + issue_sidecar "$update_repo" && + + for update_case in path force all stdin + do + case "$update_case" in + path) set -- -u tracked ;; + force) set -- -u -f tracked ;; + all) set -- -u -a ;; + stdin) set -- -u --stdin ;; + esac && + if test "$update_case" = stdin + then + echo tracked >checkout-update.stdin + else + : >checkout-update.stdin + fi && + cp "$update_repo/.git/index" \ + "checkout-update-$update_case.index" && + cp "$update_repo/.git/index.csts" \ + "checkout-update-$update_case.sidecar" && + GIT_TRACE2_EVENT="$PWD/checkout-update-$update_case.trace" \ + git -C "$update_repo" checkout-index "$@" \ + Date: Wed, 12 Aug 2026 00:39:56 -0500 Subject: [PATCH 277/403] diff: restore external fsmonitor history when available A Git implementation without clean-status extensions can rewrite the same logical index while dropping its fsmonitor state. A later diff currently initializes an unusable timestamp token, receives a full invalidation from the daemon, scans every attribute directory, and stats every tracked entry even when a valid external checkpoint exists. Opt into existing external-history restoration only when a validated clean-status sidecar names the current configuration. Reject alternate indexes, non-main or sparse worktrees, configured clean filters, unsupported filesystems, and repositories without builtin fsmonitor. The existing checkpoint checks still validate the index, attributes, provider token, and staged entries before restoring any history. Reproduce a foreign rewrite without depending on another Git binary by rebuilding its index with fsmonitor disabled. Require a clean diff to restore the checkpoint without scanning worktree metadata, statting tracked entries, or rewriting the index, and verify that a subsequent real tracked change still appears in the diff. --- builtin.h | 1 + builtin/describe.c | 1 + builtin/diff-files.c | 1 + builtin/diff-index.c | 1 + builtin/diff.c | 27 +++++++++++++++++ t/t7530-status-clean-sidecar.sh | 53 +++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+) diff --git a/builtin.h b/builtin.h index 4e47a4ebd30ba3..512df065158d40 100644 --- a/builtin.h +++ b/builtin.h @@ -177,6 +177,7 @@ int cmd_diagnose(int argc, const char **argv, const char *prefix, struct reposit int cmd_diff_files(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff_index(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff(int argc, const char **argv, const char *prefix, struct repository *repo); +void prepare_diff_external_history(struct repository *repo); int cmd_diff_pairs(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_diff_tree(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_difftool(int argc, const char **argv, const char *prefix, struct repository *repo); diff --git a/builtin/describe.c b/builtin/describe.c index 8e216206bcc19f..a2d8c60e16e0e5 100644 --- a/builtin/describe.c +++ b/builtin/describe.c @@ -790,6 +790,7 @@ int cmd_describe(int argc, */ clean_status_set_config_digest(the_repository, &clean_digest); + prepare_diff_external_history(the_repository); repo_read_index(the_repository); refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED, NULL, NULL, NULL); diff --git a/builtin/diff-files.c b/builtin/diff-files.c index ea91347ce23beb..0de2094ca2a62d 100644 --- a/builtin/diff-files.c +++ b/builtin/diff-files.c @@ -84,6 +84,7 @@ int cmd_diff_files(int argc, (rev.diffopt.output_format & DIFF_FORMAT_PATCH)) diff_merges_set_dense_combined_if_unset(&rev); + prepare_diff_external_history(the_repository); if (repo_read_index_preload(the_repository, &rev.diffopt.pathspec, 0) < 0) die_errno("repo_read_index_preload"); run_diff_files(&rev, options); diff --git a/builtin/diff-index.c b/builtin/diff-index.c index 3db7cffede578c..880a12d34b258f 100644 --- a/builtin/diff-index.c +++ b/builtin/diff-index.c @@ -68,6 +68,7 @@ int cmd_diff_index(int argc, if (rev.pending.nr != 1 || rev.max_count != -1 || rev.min_age != -1 || rev.max_age != -1) usage(diff_cache_usage); + prepare_diff_external_history(the_repository); if (!(option & DIFF_INDEX_CACHED)) { setup_work_tree(the_repository); if (repo_read_index_preload(the_repository, &rev.diffopt.pathspec, 0) < 0) { diff --git a/builtin/diff.c b/builtin/diff.c index c597935957c74e..d397463cde2b0c 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -8,6 +8,8 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "clean-status.h" +#include "clean-status-sidecar.h" #include "config.h" #include "ewah/ewok.h" #include "lockfile.h" @@ -15,6 +17,7 @@ #include "commit.h" #include "environment.h" #include "gettext.h" +#include "fsmonitor-settings.h" #include "tag.h" #include "diff.h" #include "diff-merges.h" @@ -26,6 +29,7 @@ #include "setup.h" #include "oid-array.h" #include "tree.h" +#include "worktree.h" #define DIFF_NO_INDEX_EXPLICIT 1 #define DIFF_NO_INDEX_IMPLICIT 2 @@ -400,6 +404,28 @@ static void symdiff_release(struct symdiff *sdiff) bitmap_free(sdiff->skip); } +void prepare_diff_external_history(struct repository *repo) +{ + struct clean_status_config_digest digest; + struct worktree *worktree = NULL; + + if (!fstat_is_reliable() || getenv(INDEX_ENVIRONMENT) || + is_bare_repository(repo) || !repo_get_work_tree(repo) || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC || + repo_config_values(repo)->apply_sparse_checkout) + goto done; + worktree = get_current_worktree(repo); + if (!worktree || !is_main_worktree(worktree) || + clean_status_config_read_repository(repo, &digest) || + digest.filter_configured) + goto done; + clean_status_set_config_digest(repo, &digest); + clean_status_enable_external_history(repo); + +done: + free_worktree(worktree); +} + int cmd_diff(int argc, const char **argv, const char *prefix, @@ -537,6 +563,7 @@ int cmd_diff(int argc, if (nongit) die(_("Not a git repository")); + prepare_diff_external_history(the_repository); argc = setup_revisions(argc, argv, &rev, NULL); if (!rev.diffopt.output_format) { rev.diffopt.output_format = DIFF_FORMAT_PATCH; diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index ce8c9eb38c19da..a2747d84d2b2fe 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2237,6 +2237,59 @@ test_expect_success DURABLE_FSMONITOR \ external-token.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'diff restores clean history lost by a foreign index writer' ' + diff_repo=sidecar-foreign-diff && + test_when_finished "stop_daemon $diff_repo" && + setup_repo "$diff_repo" && + git -C "$diff_repo" config core.untrackedCache true && + issue_sidecar "$diff_repo" && + test_grep FSMN "$diff_repo/.git/index" && + test_grep FSCF "$diff_repo/.git/index" && + find "$diff_repo/.git" -maxdepth 1 -type f \ + -name "index.csh1.*" >diff-history.checkpoints && + test_line_count = 1 diff-history.checkpoints && + git -C "$diff_repo" ls-files --stage >diff-history.stage && + + rm "$diff_repo/.git/index" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -C "$diff_repo" read-tree HEAD && + test_grep ! FSMN "$diff_repo/.git/index" && + test_grep ! FSCF "$diff_repo/.git/index" && + git -c core.fsmonitor=false -C "$diff_repo" \ + ls-files --stage >diff-history.rewritten.stage && + test_cmp diff-history.stage diff-history.rewritten.stage && + cp "$diff_repo/.git/index" diff-history.index && + cp "$diff_repo/.git/index.csts" diff-history.sidecar && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/diff-history.clean.trace" \ + git -C "$diff_repo" diff --no-ext-diff \ + >diff-history.clean && + test_must_be_empty diff-history.clean && + test_cmp_bin diff-history.index "$diff_repo/.git/index" && + test_cmp_bin diff-history.sidecar "$diff_repo/.git/index.csts" && + test_trace2_data fsmonitor history/external-restored 1 \ + "$diff_repo/tracked" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/diff-history.dirty.trace" \ + git -C "$diff_repo" diff --no-ext-diff \ + >diff-history.dirty && + test_grep "^+changed$" diff-history.dirty && + test_trace2_data fsmonitor history/external-restored 1 \ + Date: Wed, 12 Aug 2026 00:50:57 -0500 Subject: [PATCH 278/403] sparse-checkout: avoid rewriting an unchanged index Sparse-checkout reapply and repeated set or add commands always commit the index after updating sparsity, even when no entries or stat data changed. Replacing an identical index discards its physical identity and needlessly refreshes repository metadata. Skip the write only when the index and its pending worktree flags are unchanged and no post-index-change hook is installed. Explicit --sparse-index and --no-sparse-index requests set updated_workdir before converting the index, so retain their required rewrite even when cache_changed is clear. Preserve index locking, warnings, cleanup, and configured hook invocations. Cover all three settled no-op commands, the configured hook, and both explicit sparse-index format transitions. --- builtin/sparse-checkout.c | 14 +++++-- t/t1091-sparse-checkout-builtin.sh | 59 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/builtin/sparse-checkout.c b/builtin/sparse-checkout.c index cb4a037b770291..f48d981ec10ccd 100644 --- a/builtin/sparse-checkout.c +++ b/builtin/sparse-checkout.c @@ -7,6 +7,7 @@ #include "dir.h" #include "environment.h" #include "gettext.h" +#include "hook.h" #include "object-file.h" #include "object-name.h" #include "parse-options.h" @@ -243,9 +244,16 @@ static int update_working_directory(struct repository *r, * files in the way or dirty entries that can't be removed. */ result = UPDATE_SPARSITY_SUCCESS; - if (result == UPDATE_SPARSITY_SUCCESS) - write_locked_index(r->index, &lock_file, COMMIT_LOCK); - else + if (result == UPDATE_SPARSITY_SUCCESS) { + unsigned int flags = COMMIT_LOCK; + + if (!r->index->cache_changed && + !r->index->updated_workdir && + !r->index->updated_skipworktree && + !hook_exists(r, "post-index-change")) + flags |= SKIP_IF_UNCHANGED; + write_locked_index(r->index, &lock_file, flags); + } else rollback_lock_file(&lock_file); clean_tracked_sparse_directories(r); diff --git a/t/t1091-sparse-checkout-builtin.sh b/t/t1091-sparse-checkout-builtin.sh index 74b1761e0c8507..48d4d30cae67e7 100755 --- a/t/t1091-sparse-checkout-builtin.sh +++ b/t/t1091-sparse-checkout-builtin.sh @@ -1274,4 +1274,63 @@ test_expect_success 'sparse-checkout operations with merge conflicts' ' ) ' +test_expect_success 'unchanged sparse-checkout commands preserve the index' ' + test_when_finished "rm -rf sparse-unchanged" && + test_create_repo sparse-unchanged && + mkdir sparse-unchanged/included sparse-unchanged/omitted && + test_write_lines included >sparse-unchanged/included/tracked && + test_write_lines omitted >sparse-unchanged/omitted/tracked && + git -C sparse-unchanged add . && + git -C sparse-unchanged commit -qm base && + git -C sparse-unchanged sparse-checkout set included && + for sparse_command in reapply set add + do + case "$sparse_command" in + reapply) set -- reapply ;; + set) set -- set included ;; + add) set -- add included ;; + esac && + cp sparse-unchanged/.git/index \ + "sparse-$sparse_command.index" && + GIT_TRACE2_EVENT="$PWD/sparse-$sparse_command.trace" \ + git -C sparse-unchanged sparse-checkout "$@" && + test_cmp_bin "sparse-$sparse_command.index" \ + sparse-unchanged/.git/index && + test_grep ! "\"label\":\"do_write_index\"" \ + "sparse-$sparse_command.trace" || return 1 + done +' + +test_expect_success 'sparse-checkout preserves hooks and explicit index modes' ' + test_when_finished "rm -rf sparse-hook" && + test_create_repo sparse-hook && + mkdir sparse-hook/included sparse-hook/omitted && + test_write_lines included >sparse-hook/included/tracked && + test_write_lines omitted >sparse-hook/omitted/tracked && + git -C sparse-hook add . && + git -C sparse-hook commit -qm base && + git -C sparse-hook sparse-checkout set included && + mkdir sparse-hook/hooks && + git -C sparse-hook config core.hooksPath hooks && + write_script sparse-hook/hooks/post-index-change <<-\EOF && + printf "%s %s\n" "$1" "$2" >>hook-actual + EOF + GIT_TRACE2_EVENT="$PWD/sparse-hook.trace" \ + git -C sparse-hook sparse-checkout reapply && + test_write_lines "0 0" >sparse-hook.expect && + test_cmp sparse-hook.expect sparse-hook/hook-actual && + test_grep "\"label\":\"do_write_index\"" sparse-hook.trace && + rm sparse-hook/hooks/post-index-change && + GIT_TRACE2_EVENT="$PWD/sparse-collapse.trace" \ + git -C sparse-hook sparse-checkout reapply --sparse-index && + git -C sparse-hook ls-files --sparse >sparse-collapsed && + test_grep "^omitted/$" sparse-collapsed && + test_grep "\"label\":\"do_write_index\"" sparse-collapse.trace && + GIT_TRACE2_EVENT="$PWD/sparse-expand.trace" \ + git -C sparse-hook sparse-checkout reapply --no-sparse-index && + git -C sparse-hook ls-files --sparse >sparse-expanded && + test_grep "^omitted/tracked$" sparse-expanded && + test_grep "\"label\":\"do_write_index\"" sparse-expand.trace +' + test_done From 9fa154929198f3b43f618c9ab3b1bad024789628 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 10:14:56 -0500 Subject: [PATCH 279/403] fsmonitor: reduce attribute-manifest recovery overhead A full worktree-attribute manifest refresh probes one potential .gitattributes source per tracked directory. On the OpenAI monorepo that means approximately 237,000 candidates, almost all absent. Allocate candidate state in one contiguous block, collect indexed attribute sources during the existing index walk, and avoid sorting when candidate order is already monotonic. On Darwin, revalidate a pinned parent with fstatat() instead of reopening and closing it. The complete anchored namespace identity remains unchanged; Linux keeps its existing openat2-based validation. Cover unusual path ordering, both object formats, indexed fallback, and replacement of an anchored parent with a symlink. --- semantic-verify-path.c | 10 ++- t/unit-tests/u-attr-manifest.c | 145 +++++++++++++++++++++++++++++++++ worktree-attr-manifest.c | 64 +++++++++------ 3 files changed, 193 insertions(+), 26 deletions(-) diff --git a/semantic-verify-path.c b/semantic-verify-path.c index db7fd874084c42..2ae011add3eff4 100644 --- a/semantic-verify-path.c +++ b/semantic-verify-path.c @@ -34,9 +34,16 @@ static void pop_anchored_dir(struct semantic_verify_path *path) struct anchored_dir *dir = &path->dirs[path->dirs_nr - 1]; int parent_fd = path->dirs_nr == 1 ? path->root->fd : path->dirs[path->dirs_nr - 2].fd; - int named_fd; struct stat named_stat; +#ifdef __APPLE__ + if (fstatat(parent_fd, dir->component, &named_stat, + AT_SYMLINK_NOFOLLOW) || + !path_namespace_stat_equal(&dir->stat, &named_stat)) + note_namespace_unstable(path, dir->first_cache_pos); +#else + int named_fd; + named_fd = semantic_verify_openat(parent_fd, dir->component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); if (named_fd < 0 || fstat(named_fd, &named_stat) || @@ -44,6 +51,7 @@ static void pop_anchored_dir(struct semantic_verify_path *path) note_namespace_unstable(path, dir->first_cache_pos); if (named_fd >= 0) close(named_fd); +#endif close(dir->fd); free(dir->component); path->dirs_nr--; diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 33e1b048316be1..3e8cf0082bfab7 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -516,6 +516,94 @@ static void many_sources_fixture_release(struct many_sources_fixture *fixture) remove_worktree(fixture->worktree); } +static void verify_unusual_manifest_paths(const struct git_hash_algo *algo) +{ + static const char *const directories[] = { + "!before", ".hidden", "a", "a/!nested", "a/nested", "z", + }; + static const char *const tracked[] = { + "!before/file", + ".hidden/.gitattributes", + ".hidden/file", + "a/!nested/file", + "a/file", + "a/nested/file", + "a/nested/other", + "a/other", + "z/file", + }; + static const char *const expected_paths[] = { + "!before/.gitattributes", + ".gitattributes", + ".hidden/.gitattributes", + "a/!nested/.gitattributes", + }; + char indexed_source[] = "*.hidden text\n"; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats stats; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct cache_entry *indexed; + struct strbuf path = STRBUF_INIT, manifest = STRBUF_INIT; + unsigned char hash[GIT_MAX_RAWSZ]; + int have_repository, ret; + size_t i; + + init_object_store(&repo, worktree); + for (i = 0; i < ARRAY_SIZE(directories); i++) { + strbuf_reset(&path); + strbuf_addf(&path, "%s/%s", worktree, directories[i]); + cl_must_pass(mkdir(path.buf, 0777)); + } + strbuf_reset(&path); + strbuf_addf(&path, "%s/.gitattributes", worktree); + write_file(path.buf, "*.root text\n"); + strbuf_reset(&path); + strbuf_addf(&path, "%s/!before/.gitattributes", worktree); + write_file(path.buf, "*.before -text\n"); + strbuf_reset(&path); + strbuf_addf(&path, "%s/a/!nested/.gitattributes", worktree); + write_file(path.buf, "*.nested text\n"); + + CALLOC_ARRAY(istate.cache, ARRAY_SIZE(tracked)); + istate.cache_alloc = istate.cache_nr = ARRAY_SIZE(tracked); + for (i = 0; i < ARRAY_SIZE(tracked); i++) + add_index_path(&istate, i, tracked[i], 0); + indexed = istate.cache[1]; + cl_must_pass(odb_pretend_object( + repo.objects, indexed_source, strlen(indexed_source), + OBJ_BLOB, &indexed->oid)); + + have_repository = startup_info->have_repository; + startup_info->have_repository = 1; + ret = worktree_attr_manifest_build(&istate, &manifest, hash, &stats); + startup_info->have_repository = have_repository; + cl_assert_equal_i(ret, 0); + cl_assert_equal_i(stats.candidates, ARRAY_SIZE(directories) + 1); + cl_assert_equal_i(stats.worktree_sources, 3); + cl_assert_equal_i(stats.index_sources, 1); + cl_must_pass(attr_manifest_cursor_init( + &cursor, manifest.buf, manifest.len, algo)); + for (i = 0; i < ARRAY_SIZE(expected_paths); i++) { + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.path_len, strlen(expected_paths[i])); + cl_assert(!memcmp(entry.path, expected_paths[i], entry.path_len)); + cl_assert_equal_i(entry.source, + i == 2 ? ATTR_MANIFEST_INDEX : ATTR_MANIFEST_WORKTREE); + if (i == 2) + cl_assert(!memcmp(entry.hash, indexed->oid.hash, algo->rawsz)); + } + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + + strbuf_release(&manifest); + strbuf_release(&path); + release_index(&istate); + odb_free(repo.objects); + remove_worktree(worktree); +} + static void clear_attr_manifest_thread_env(void *unused UNUSED) { unsetenv(ATTR_MANIFEST_TEST_THREADS); @@ -523,6 +611,63 @@ static void clear_attr_manifest_thread_env(void *unused UNUSED) } #endif +void test_attr_manifest__preserves_unusual_paths_for_both_hash_algorithms(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + verify_unusual_manifest_paths(&hash_algos[GIT_HASH_SHA1]); + verify_unusual_manifest_paths(&hash_algos[GIT_HASH_SHA256]); +#endif +} + +void test_attr_manifest__detects_symlink_replaced_parent(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + char *worktree = create_worktree(); + struct repository repo = { + .worktree = worktree, + .hash_algo = &hash_algos[GIT_HASH_SHA1], + }; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path; + struct strbuf parent = STRBUF_INIT; + struct strbuf original = STRBUF_INIT; + struct strbuf replacement = STRBUF_INIT; + const char *basename; + size_t namespace_unstable_from; + unsigned int namespace_unstable; + int parent_fd; + + strbuf_addf(&parent, "%s/parent", worktree); + strbuf_addf(&original, "%s/original", worktree); + strbuf_addf(&replacement, "%s/replacement", worktree); + cl_must_pass(mkdir(parent.buf, 0777)); + cl_must_pass(mkdir(replacement.buf, 0777)); + cl_must_pass(semantic_verify_root_init(&repo, &root)); + path = semantic_verify_path_new(root); + cl_assert(path != NULL); + cl_must_pass(semantic_verify_resolve_parent( + path, "parent/.gitattributes", 23, &parent_fd, &basename)); + cl_assert_equal_s(basename, ".gitattributes"); + cl_assert(parent_fd >= 0); + cl_must_pass(rename(parent.buf, original.buf)); + cl_must_pass(symlink("replacement", parent.buf)); + semantic_verify_path_free( + path, &namespace_unstable, &namespace_unstable_from); + cl_assert_equal_i(namespace_unstable, 1); + cl_assert_equal_i(namespace_unstable_from, 23); + + semantic_verify_root_clear(root); + strbuf_release(&replacement); + strbuf_release(&original); + strbuf_release(&parent); + remove_worktree(worktree); +#endif +} + void test_attr_manifest__parallel_probes_match_serial_output(void) { #if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c index 116ff10b8e3fb7..2a0ca66ce10b78 100644 --- a/worktree-attr-manifest.c +++ b/worktree-attr-manifest.c @@ -46,18 +46,21 @@ struct attr_manifest_thread { }; static int collect_candidates(struct index_state *istate, - struct string_list *candidates) + struct string_list *candidates, + struct string_list *index_sources) { struct strbuf candidate = STRBUF_INIT; const char *previous = NULL; size_t previous_len = 0; unsigned int i; + int sorted = 1; int ret = -1; string_list_append(candidates, GITATTRIBUTES_FILE); for (i = 0; i < istate->cache_nr; i++) { const struct cache_entry *ce = istate->cache[i]; const char *slash = ce->name; + const char *basename = ce->name; if (ce_stage(ce) || S_ISSPARSEDIR(ce->ce_mode)) goto done; @@ -67,17 +70,30 @@ static int collect_candidates(struct index_state *istate, if (!previous || previous_len <= len || !is_dir_sep(previous[len]) || fspathncmp(previous, ce->name, len)) { + const char *last; + strbuf_reset(&candidate); strbuf_add(&candidate, ce->name, len + 1); strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + last = candidates->items[candidates->nr - 1].string; + if (strcmp(last, candidate.buf) > 0) + sorted = 0; string_list_append(candidates, candidate.buf); } - slash++; + basename = ++slash; + } + if (!fspathcmp(basename, GITATTRIBUTES_FILE)) { + strbuf_reset(&candidate); + strbuf_add(&candidate, ce->name, basename - ce->name); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + string_list_append(index_sources, candidate.buf)->util = + (void *)ce; } previous = ce->name; previous_len = ce->ce_namelen; } - string_list_sort(candidates); + if (!sorted) + string_list_sort(candidates); string_list_remove_duplicates(candidates, 0); ret = candidates->nr <= UINT32_MAX ? 0 : -1; done: @@ -86,32 +102,26 @@ static int collect_candidates(struct index_state *istate, } static int collect_index_sources(struct index_state *istate, - struct string_list *candidates) + struct string_list *candidates, + struct string_list *index_sources, + struct attr_manifest_candidate **states_out) { - struct strbuf candidate = STRBUF_INIT; - unsigned int i; + struct attr_manifest_candidate *states; + size_t i; int ret = 0; + CALLOC_ARRAY(states, candidates->nr); + *states_out = states; for (i = 0; i < candidates->nr; i++) { - struct attr_manifest_candidate *state; - - CALLOC_ARRAY(state, 1); - candidates->items[i].util = state; + candidates->items[i].util = &states[i]; } - for (i = 0; i < istate->cache_nr; i++) { - const struct cache_entry *ce = istate->cache[i]; - const char *base = strrchr(ce->name, '/'); + for (i = 0; i < index_sources->nr; i++) { + const struct cache_entry *ce = index_sources->items[i].util; struct string_list_item *item; struct attr_manifest_candidate *state; - base = base ? base + 1 : ce->name; - if (fspathcmp(base, GITATTRIBUTES_FILE)) - continue; - strbuf_reset(&candidate); - if (base != ce->name) - strbuf_add(&candidate, ce->name, base - ce->name); - strbuf_addstr(&candidate, GITATTRIBUTES_FILE); - item = string_list_lookup(candidates, candidate.buf); + item = string_list_lookup(candidates, + index_sources->items[i].string); if (!item) BUG("tracked attribute source lacks manifest candidate"); state = item->util; @@ -125,7 +135,6 @@ static int collect_index_sources(struct index_state *istate, break; } } - strbuf_release(&candidate); return ret; } @@ -245,6 +254,8 @@ int worktree_attr_manifest_build( struct worktree_attr_manifest_stats *stats) { struct string_list candidates = STRING_LIST_INIT_DUP; + struct string_list index_sources = STRING_LIST_INIT_DUP; + struct attr_manifest_candidate *states = NULL; struct semantic_verify_root *root = NULL; struct attr_manifest_writer writer; const struct git_hash_algo *algo = istate->repo->hash_algo; @@ -254,8 +265,9 @@ int worktree_attr_manifest_build( memset(stats, 0, sizeof(*stats)); if (istate->sparse_index != INDEX_EXPANDED || semantic_verify_root_init(istate->repo, &root) || - collect_candidates(istate, &candidates) || - collect_index_sources(istate, &candidates)) + collect_candidates(istate, &candidates, &index_sources) || + collect_index_sources(istate, &candidates, &index_sources, + &states)) goto done; stats->candidates = candidates.nr; if (probe_candidates(&candidates, istate->repo, root, algo, stats)) @@ -291,7 +303,9 @@ int worktree_attr_manifest_build( ret = 0; done: semantic_verify_root_clear(root); - string_list_clear(&candidates, 1); + string_list_clear(&index_sources, 0); + string_list_clear(&candidates, 0); + free(states); if (ret) strbuf_reset(manifest); return ret; From 6d702076cc02a02a1685343c8ca18e395c1b94cc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 10:15:30 -0500 Subject: [PATCH 280/403] fsmonitor: revalidate cached directories after provider resets Losing a filesystem-monitor boundary currently discards every cached untracked directory, even when its prior state was authenticated by a paired full worktree proof. Rebuilding that cache enumerates roughly 237,000 directories in the OpenAI monorepo. Preserve a complete, previously authenticated untracked cache only when a successful provider reset leaves configuration, attributes, and conversion semantics unchanged. Demote the cache to ordinary directory validation, then reuse the existing parallel directory-stat and ignore file revalidation before closing the new provider token. Global invalidation, changed attributes, missing proofs, dirty caches, and ambiguous directory identities retain destructive fallback. Add coverage for nested untracked files, changed ignore rules, and provider global invalidation. On the real OpenAI checkout, full recovery improves from 30.79 seconds to 8.58 seconds; subsequent status calls retain a 31.86 ms median. --- dir.c | 43 ++++++++++ dir.h | 3 + fsmonitor.c | 21 ++++- t/t7519-status-fsmonitor.sh | 155 ++++++++++++++++++++++++++++++++++++ wt-status.c | 10 ++- 5 files changed, 229 insertions(+), 3 deletions(-) diff --git a/dir.c b/dir.c index 927dda6e5b2e9c..2bd3818bd80d13 100644 --- a/dir.c +++ b/dir.c @@ -2084,12 +2084,55 @@ static void invalidate_gitignore(struct untracked_cache *uc, do_invalidate_gitignore(dir); } +static void clear_untracked_cache_validation(struct untracked_cache_dir *dir) +{ + size_t i; + + dir->valid_recursive = 0; + dir->stat_checked = 0; + dir->stat_matches = 0; + dir->exclude_matches = 0; + for (i = 0; i < dir->dirs_nr; i++) + clear_untracked_cache_validation(dir->dirs[i]); +} + +int untracked_cache_preserve_for_revalidation(struct index_state *istate) +{ + struct untracked_cache *uc = istate->untracked; + + if (!uc || !uc->root || !uc->root->valid || + !uc->root->valid_recursive || uc->fsmonitor_dirty_paths.len || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_untracked_valid || + !istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + !istate->fsmonitor_last_update || + !istate->fsmonitor_untracked_token || + strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token)) + return 0; + + /* + * The paired provider proof authenticates these previously complete + * lists, but its boundary is no longer replayable. Keep their ordinary + * directory snapshots as candidates; every directory and exclude + * source must be revalidated before a fresh provider token is closed. + */ + clear_untracked_cache_validation(uc->root); + uc->use_fsmonitor = 0; + uc->fsmonitor_revalidation = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/provider-reset-preserved", 1); + return 1; +} + void untracked_cache_invalidate_all(struct index_state *istate) { if (!istate->untracked || !istate->untracked->root) return; invalidate_gitignore(istate->untracked, istate->untracked->root); istate->untracked->use_fsmonitor = 0; + istate->untracked->fsmonitor_revalidation = 0; istate->cache_changed |= UNTRACKED_CHANGED; } diff --git a/dir.h b/dir.h index bdef37a0c60cf3..3b5f0c704197f9 100644 --- a/dir.h +++ b/dir.h @@ -219,6 +219,8 @@ struct untracked_cache { struct strbuf fsmonitor_dirty_paths; /* fsmonitor invalidation data */ unsigned int use_fsmonitor : 1; + /* A lost provider boundary requires ordinary directory validation. */ + unsigned int fsmonitor_revalidation : 1; }; /** @@ -624,6 +626,7 @@ int check_dir_entry_contains(const struct dir_entry *out, const struct dir_entry void untracked_cache_invalidate_path(struct index_state *, const char *, int safe_path); void untracked_cache_invalidate_all(struct index_state *); +int untracked_cache_preserve_for_revalidation(struct index_state *); /* * Invalidate the untracked-cache for this path, but first strip * off a trailing slash, if present. diff --git a/fsmonitor.c b/fsmonitor.c index ed4c7d6324ae78..072a8dcf909d54 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1059,6 +1059,10 @@ static void invalidate_fsmonitor_for_bootstrap( } if (physical_history_unavailable) { + int authenticated_manifest = + clean_status_has_authenticated_worktree_manifest(istate); + int preserve_untracked = 0; + if (istate->fsmonitor_legacy_untracked_fallback) { invalidate_all_fsmonitor_for_baseline(istate); trace2_data_intmax("fsmonitor", istate->repo, @@ -1075,6 +1079,10 @@ static void invalidate_fsmonitor_for_bootstrap( istate->repo->config_values_private_.trust_ctime && istate->repo->config_values_private_.check_stat) { /* Strong stat identity survives a lost provider boundary. */ + if (authenticated_manifest && + !clean_status_fsmonitor_config_mismatch(istate)) + preserve_untracked = + untracked_cache_preserve_for_revalidation(istate); clean_status_begin_fsmonitor_semantic_baseline(istate); invalidate_all_fsmonitor_for_baseline(istate); trace2_data_intmax("fsmonitor", istate->repo, @@ -1082,7 +1090,8 @@ static void invalidate_fsmonitor_for_bootstrap( } else { fsmonitor_invalidate_semantics(istate); } - untracked_cache_invalidate_all(istate); + if (!preserve_untracked) + untracked_cache_invalidate_all(istate); return; } @@ -1518,8 +1527,14 @@ void fsmonitor_accept_pending_token(struct index_state *istate, istate->fsmonitor_pending_token_from_provider = 0; istate->fsmonitor_token_valid = 1; istate->fsmonitor_untracked_valid = !!untracked_cache_valid; - if (istate->untracked) + if (istate->untracked) { + if (istate->untracked->fsmonitor_revalidation && + untracked_cache_valid) + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/provider-reset-revalidated", 1); + istate->untracked->fsmonitor_revalidation = 0; istate->untracked->use_fsmonitor = !!untracked_cache_valid; + } istate->cache_changed |= FSMONITOR_CHANGED; FREE_AND_NULL(istate->fsmonitor_untracked_token); if (untracked_cache_valid) @@ -1543,6 +1558,8 @@ void fsmonitor_reject_pending_token(struct index_state *istate) { FREE_AND_NULL(istate->fsmonitor_last_update_pending); istate->fsmonitor_pending_token_from_provider = 0; + if (istate->untracked) + istate->untracked->fsmonitor_revalidation = 0; if (!istate->fsmonitor_token_valid) FREE_AND_NULL(istate->fsmonitor_last_update); invalidate_all_fsmonitor_strong(istate); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 187c70e2ddd135..d6a3448796cdc7 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -723,6 +723,161 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'provider reset revalidates authenticated untracked directories' ' + test_when_finished "rm -rf builtin-reset-untracked" && + test_create_repo builtin-reset-untracked && + ( + cd builtin-reset-untracked && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep sibling/empty && + test_write_lines "*.root-ignored" >.gitignore && + test_write_lines "*.nested-ignored" >cached/.gitignore && + test_write_lines tracked >cached/deep/tracked && + test_write_lines sibling >sibling/empty/tracked && + test_write_lines hidden >cached/deep/hidden.nested-ignored && + test_write_lines hidden >sibling/hidden.root-ignored && + test_write_lines visible >cached/deep/retained && + git add .gitignore cached/.gitignore cached/deep/tracked \ + sibling/empty/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime =-60 cached/deep cached \ + sibling/empty sibling . && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for prime in first second third + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime || return 1 + done && + test_grep "^? cached/deep/retained$" .git/prime && + test_grep FSUC .git/index && + test_grep FSCF .git/index && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git status --porcelain=v2 >.git/reset.actual && + test_cmp .git/prime .git/reset.actual && + test_trace2_data fsmonitor \ + untracked/provider-reset-preserved 1 <.git/reset.trace && + test_trace2_data status \ + untracked/provider-reset-preload 1 <.git/reset.trace && + test_trace2_data dir preload_untracked_cache/valid 1 \ + <.git/reset.trace && + test_trace2_data read_directory opendir 0 <.git/reset.trace && + test_trace2_data fsmonitor \ + untracked/provider-reset-revalidated 1 <.git/reset.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/reset.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'provider reset detects new nested untracked and ignore changes' ' + test_when_finished "rm -rf builtin-reset-changed" && + test_create_repo builtin-reset-changed && + ( + cd builtin-reset-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep sibling/empty && + test_write_lines hidden >cached/.gitignore && + test_write_lines tracked >cached/deep/tracked && + test_write_lines sibling >sibling/empty/tracked && + test_write_lines hidden >cached/deep/hidden && + git add cached/.gitignore cached/deep/tracked \ + sibling/empty/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime =-60 cached/deep cached \ + sibling/empty sibling . && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for prime in first second third + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime || return 1 + done && + test_must_be_empty .git/prime && + test_grep FSUC .git/index && + test_write_lines visible >cached/deep/new && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/new.expect && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/new.trace" \ + git status --porcelain=v2 >.git/new.actual && + test_cmp .git/new.expect .git/new.actual && + test_grep "^? cached/deep/new$" .git/new.actual && + test_trace2_data fsmonitor \ + untracked/provider-reset-preserved 1 <.git/new.trace && + test_trace2_data dir preload_untracked_cache/valid 0 \ + <.git/new.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/new.trace && + + test_write_lines other >cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/ignore.expect && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/ignore.trace" \ + git status --porcelain=v2 >.git/ignore.actual && + test_cmp .git/ignore.expect .git/ignore.actual && + test_grep "^1 \\.M .* cached/.gitignore$" .git/ignore.actual && + test_grep "^? cached/deep/hidden$" .git/ignore.actual && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/ignore.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'global provider invalidation never preserves untracked snapshots' ' + test_when_finished "rm -rf builtin-reset-global" && + test_create_repo builtin-reset-global && + ( + cd builtin-reset-global && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines tracked >cached/tracked && + git add cached/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSUC .git/index && + test_write_lines visible >cached/new && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/global.trace" \ + git status --porcelain=v2 >.git/global.actual && + test_grep "^? cached/new$" .git/global.actual && + test_trace2_data fsmonitor apply/global-invalidation 1 \ + <.git/global.trace && + ! test_trace2_data fsmonitor \ + untracked/provider-reset-preserved 1 <.git/global.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin changed closure rescans before acceptance' ' test_when_finished "rm -rf builtin-closure-changed" && diff --git a/wt-status.c b/wt-status.c index 66cb8a17cd2cca..3f840eb76dfd05 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1156,11 +1156,19 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) "fsmonitor_token/untracked-deferred", 1); return; } - if (has_fsmonitor) + if (has_fsmonitor && + (!istate->untracked || + !istate->untracked->fsmonitor_revalidation || + istate->untracked->use_fsmonitor || + !fsmonitor_pending_token_from_provider(istate))) return; s->untracked_cache_preload = untracked_cache_preload_start_ordinary(istate, dir_flags); + if (s->untracked_cache_preload && + istate->untracked->fsmonitor_revalidation) + trace2_data_intmax("status", s->repo, + "untracked/provider-reset-preload", 1); } static void wt_status_finish_untracked_cache_preload(struct wt_status *s) From d1269e629c198b504e1839bb2501f55cdfd3a1e5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 11:12:41 -0500 Subject: [PATCH 281/403] fsmonitor: pool attribute-manifest candidate paths A full manifest rebuild on the OpenAI checkout enumerates roughly 237,000 possible .gitattributes paths. Allocating and freeing every candidate separately adds avoidable allocator traffic to an already expensive recovery path. Store candidate names in a stable memory pool while retaining the existing anchored lookup, sorting, deduplication, and worker-lifetime guarantees. Cover a newly appearing nested attribute source under SHA-256 so pooled storage cannot turn an earlier negative lookup into stale proof. --- t/unit-tests/u-attr-manifest.c | 53 ++++++++++++++++++++++++++++++++++ worktree-attr-manifest.c | 19 ++++++++---- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index 3e8cf0082bfab7..7e4c2b972b7c5b 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -621,6 +621,59 @@ void test_attr_manifest__preserves_unusual_paths_for_both_hash_algorithms(void) #endif } +void test_attr_manifest__rechecks_previously_absent_nested_source(void) +{ +#if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + cl_skip(); +#else + const struct git_hash_algo *algo = &hash_algos[GIT_HASH_SHA256]; + char *worktree = create_worktree(); + struct repository repo = { .worktree = worktree, .hash_algo = algo }; + struct index_state istate = INDEX_STATE_INIT(&repo); + struct worktree_attr_manifest_stats absent_stats, present_stats; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct strbuf path = STRBUF_INIT, manifest = STRBUF_INIT; + unsigned char absent_hash[GIT_MAX_RAWSZ]; + unsigned char present_hash[GIT_MAX_RAWSZ]; + const char *name = "parent/nested/.gitattributes"; + + strbuf_addf(&path, "%s/parent", worktree); + cl_must_pass(mkdir(path.buf, 0777)); + strbuf_addstr(&path, "/nested"); + cl_must_pass(mkdir(path.buf, 0777)); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_index_path(&istate, 0, "parent/nested/first", 0); + add_index_path(&istate, 1, "parent/nested/second", 0); + cl_must_pass(worktree_attr_manifest_build( + &istate, &manifest, absent_hash, &absent_stats)); + cl_assert_equal_i(absent_stats.candidates, 3); + cl_assert_equal_i(absent_stats.worktree_sources, 0); + + strbuf_addstr(&path, "/.gitattributes"); + write_file(path.buf, "*.dat text\n"); + cl_must_pass(worktree_attr_manifest_build( + &istate, &manifest, present_hash, &present_stats)); + cl_assert_equal_i(present_stats.candidates, 3); + cl_assert_equal_i(present_stats.worktree_sources, 1); + cl_assert(memcmp(absent_hash, present_hash, algo->rawsz) != 0); + cl_must_pass(attr_manifest_cursor_init( + &cursor, manifest.buf, manifest.len, algo)); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 1); + cl_assert_equal_i(entry.path_len, strlen(name)); + cl_assert(!memcmp(entry.path, name, entry.path_len)); + cl_assert_equal_i(entry.source, ATTR_MANIFEST_WORKTREE); + cl_assert_equal_i(attr_manifest_cursor_next(&cursor, &entry), 0); + + strbuf_release(&manifest); + strbuf_release(&path); + release_index(&istate); + remove_worktree(worktree); +#endif +} + void test_attr_manifest__detects_symlink_replaced_parent(void) { #if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c index 2a0ca66ce10b78..f22efb927a02fc 100644 --- a/worktree-attr-manifest.c +++ b/worktree-attr-manifest.c @@ -5,6 +5,7 @@ #include "environment.h" #include "gettext.h" #include "hash-framing.h" +#include "mem-pool.h" #include "object.h" #include "odb.h" #include "parse.h" @@ -47,7 +48,8 @@ struct attr_manifest_thread { static int collect_candidates(struct index_state *istate, struct string_list *candidates, - struct string_list *index_sources) + struct string_list *index_sources, + struct mem_pool *candidate_pool) { struct strbuf candidate = STRBUF_INIT; const char *previous = NULL; @@ -56,7 +58,8 @@ static int collect_candidates(struct index_state *istate, int sorted = 1; int ret = -1; - string_list_append(candidates, GITATTRIBUTES_FILE); + string_list_append(candidates, + mem_pool_strdup(candidate_pool, GITATTRIBUTES_FILE)); for (i = 0; i < istate->cache_nr; i++) { const struct cache_entry *ce = istate->cache[i]; const char *slash = ce->name; @@ -78,7 +81,9 @@ static int collect_candidates(struct index_state *istate, last = candidates->items[candidates->nr - 1].string; if (strcmp(last, candidate.buf) > 0) sorted = 0; - string_list_append(candidates, candidate.buf); + string_list_append( + candidates, + mem_pool_strdup(candidate_pool, candidate.buf)); } basename = ++slash; } @@ -253,8 +258,9 @@ int worktree_attr_manifest_build( unsigned char *manifest_hash, struct worktree_attr_manifest_stats *stats) { - struct string_list candidates = STRING_LIST_INIT_DUP; + struct string_list candidates = STRING_LIST_INIT_NODUP; struct string_list index_sources = STRING_LIST_INIT_DUP; + struct mem_pool candidate_pool; struct attr_manifest_candidate *states = NULL; struct semantic_verify_root *root = NULL; struct attr_manifest_writer writer; @@ -263,9 +269,11 @@ int worktree_attr_manifest_build( int ret = -1; memset(stats, 0, sizeof(*stats)); + mem_pool_init(&candidate_pool, 0); if (istate->sparse_index != INDEX_EXPANDED || semantic_verify_root_init(istate->repo, &root) || - collect_candidates(istate, &candidates, &index_sources) || + collect_candidates(istate, &candidates, &index_sources, + &candidate_pool) || collect_index_sources(istate, &candidates, &index_sources, &states)) goto done; @@ -305,6 +313,7 @@ int worktree_attr_manifest_build( semantic_verify_root_clear(root); string_list_clear(&index_sources, 0); string_list_clear(&candidates, 0); + mem_pool_discard(&candidate_pool, 0); free(states); if (ret) strbuf_reset(manifest); From ef7d1509c1116aff12450317b9d1fa1500bdf3df Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 11:12:53 -0500 Subject: [PATCH 282/403] status: preload full recovery for scoped pathspecs After provider history is lost, a pathspec previously disabled threaded untracked-cache revalidation. The scoped command still needed a globally closed provider token, so it instead validated the entire cache serially before filtering its output. Permit the existing ordinary whole-tree preload for this narrow authenticated recovery state, then retain the original pathspec filtering and provider closure. Allow bounded test sweeps through sixteen recovery workers while keeping the normal six-worker default. Exercise changed files outside the requested cone, changed nested ignore rules, global invalidation, and every supported worker count. --- dir.c | 11 +++- t/t7519-status-fsmonitor.sh | 111 ++++++++++++++++++++++++++++++++++++ wt-status.c | 11 +++- 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/dir.c b/dir.c index 2bd3818bd80d13..9634d2b900323b 100644 --- a/dir.c +++ b/dir.c @@ -122,6 +122,7 @@ struct untracked_cache_preload { }; #define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 +#define UNTRACKED_CACHE_MAX_RECOVERY_THREADS 16 #define UNTRACKED_CACHE_PRELOAD_COST 1000 #define UNTRACKED_CACHE_EXCLUDE_PRELOAD_COST 256 #define UNTRACKED_CACHE_MAX_EXCLUDE_SIZE (1024 * 1024) @@ -451,9 +452,13 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( if (threads > UNTRACKED_CACHE_MAX_OVERLAP_THREADS) threads = UNTRACKED_CACHE_MAX_OVERLAP_THREADS; test_threads = git_env_ulong("GIT_TEST_UNTRACKED_CACHE_THREADS", 0); - if (test_threads && HAVE_THREADS) - threads = test_threads > UNTRACKED_CACHE_MAX_OVERLAP_THREADS ? - UNTRACKED_CACHE_MAX_OVERLAP_THREADS : test_threads; + if (test_threads && HAVE_THREADS) { + unsigned long limit = fsmonitor_excludes_only ? + UNTRACKED_CACHE_MAX_OVERLAP_THREADS : + UNTRACKED_CACHE_MAX_RECOVERY_THREADS; + + threads = test_threads > limit ? limit : test_threads; + } if (threads < 1) threads = 1; if (preload->nr && (size_t)threads > preload->nr) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index d6a3448796cdc7..709ef379132962 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -777,6 +777,116 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'scoped provider resets validate the complete cache in parallel' ' + test_when_finished "rm -rf builtin-reset-scoped" && + test_create_repo builtin-reset-scoped && + ( + cd builtin-reset-scoped && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep && + test_write_lines "*.root-ignored" >.gitignore && + test_write_lines "*.nested-ignored" >cached/.gitignore && + test_write_lines tracked >cached/deep/tracked && + test_write_lines visible >cached/deep/retained && + test_write_lines hidden >cached/deep/hidden.nested-ignored && + for nr in 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 + do + mkdir "outside-$nr" && + test_write_lines "$nr" >"outside-$nr/tracked" || return 1 + done && + test_write_lines outside >outside-01/retained && + test_write_lines hidden >outside-01/hidden.root-ignored && + git add .gitignore cached/.gitignore cached/deep/tracked \ + outside-*/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime =-60 cached/deep cached outside-* . && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + for prime in first second third + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime || return 1 + done && + test_grep "^? cached/deep/retained$" .git/prime && + test_grep "^? outside-01/retained$" .git/prime && + test_grep FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 -- cached/deep >.git/scoped.expect && + for workers in 6 8 12 16 + do + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS="$workers" \ + GIT_TRACE2_EVENT="$PWD/.git/scoped-$workers.trace" \ + git status --porcelain=v2 -- cached/deep \ + >.git/scoped.actual && + test_cmp .git/scoped.expect .git/scoped.actual && + test_trace2_data dir preload_untracked_cache/threads \ + "$workers" <".git/scoped-$workers.trace" && + test_trace2_data status \ + untracked/provider-reset-scoped-preload 1 \ + <".git/scoped-$workers.trace" && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <".git/scoped-$workers.trace" || return 1 + done && + + test_write_lines outside-new >outside-01/new && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=8 \ + GIT_TRACE2_EVENT="$PWD/.git/outside.trace" \ + git status --porcelain=v2 -- cached/deep \ + >.git/outside.actual && + test_cmp .git/scoped.expect .git/outside.actual && + test_trace2_data dir preload_untracked_cache/valid 0 \ + <.git/outside.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/outside.trace && + + test_write_lines retained >cached/.gitignore && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 -- cached/deep \ + >.git/ignore.expect && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=8 \ + GIT_TRACE2_EVENT="$PWD/.git/ignore.trace" \ + git status --porcelain=v2 -- cached/deep \ + >.git/ignore.actual && + test_cmp .git/ignore.expect .git/ignore.actual && + test_grep "^? cached/deep/hidden.nested-ignored$" \ + .git/ignore.actual && + test_grep ! "^? cached/deep/retained$" .git/ignore.actual && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/ignore.trace && + + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/global.trace" \ + git status --porcelain=v2 -- cached/deep \ + >.git/global.actual && + test_cmp .git/ignore.expect .git/global.actual && + test_trace2_data fsmonitor apply/global-invalidation 1 \ + <.git/global.trace && + ! test_trace2_data status \ + untracked/provider-reset-scoped-preload 1 \ + <.git/global.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'provider reset detects new nested untracked and ignore changes' ' test_when_finished "rm -rf builtin-reset-changed" && @@ -800,6 +910,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git update-index --fsmonitor && for prime in first second third do + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ git status --porcelain=v2 >.git/prime || return 1 done && diff --git a/wt-status.c b/wt-status.c index 3f840eb76dfd05..945693a3abbb2f 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1135,7 +1135,10 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) s->pathspec.nr ? &s->pathspec : NULL); return; } - if (s->pathspec.nr || + if ((s->pathspec.nr && + (!istate->untracked || + !istate->untracked->fsmonitor_revalidation || + !fsmonitor_pending_token_from_provider(istate))) || s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) return; @@ -1166,9 +1169,13 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) s->untracked_cache_preload = untracked_cache_preload_start_ordinary(istate, dir_flags); if (s->untracked_cache_preload && - istate->untracked->fsmonitor_revalidation) + istate->untracked->fsmonitor_revalidation) { trace2_data_intmax("status", s->repo, "untracked/provider-reset-preload", 1); + if (s->pathspec.nr) + trace2_data_intmax("status", s->repo, + "untracked/provider-reset-scoped-preload", 1); + } } static void wt_status_finish_untracked_cache_preload(struct wt_status *s) From 3c985882087086b578854546b87e1011f50e0503 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 11:13:06 -0500 Subject: [PATCH 283/403] status: reissue clean proofs after repository inputs change A clean-status certificate includes repository inputs such as the active locale. When those inputs changed, validation correctly rejected the existing certificate, but ordinary status saw a safely pinned sidecar and declined to replace it. Every subsequent command repeated the slower index-reading path. Remember the specific repository-input mismatch and allow ordinary clean status to reissue an otherwise safe, single-link sidecar after complete normal validation. Preserve the optional-locks boundary and leave the physical index untouched. Cover both successful locale-change repair and the read-only case. --- builtin/commit.c | 13 ++++++--- clean-status-fast.c | 9 ++++-- clean-status.h | 3 +- t/t7530-status-clean-sidecar.sh | 49 +++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 41b9ea47fb4062..07c565fbff6fa2 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1643,7 +1643,8 @@ static int print_clean_sidecar(struct wt_status *s, const char *prefix) return 1; } -static int clean_status_sidecar_needs_reissue(struct repository *repo) +static int clean_status_sidecar_needs_reissue(struct repository *repo, + int repository_inputs_changed) { struct clean_status_sidecar_record record = CLEAN_STATUS_SIDECAR_RECORD_INIT; @@ -1658,7 +1659,8 @@ static int clean_status_sidecar_needs_reissue(struct repository *repo) if (!clean_status_sidecar_load( repo->index_file, repo->hash_algo, &record)) reissue = safe_existing && - (!!clean_status_sidecar_pin_source( + (repository_inputs_changed || + !!clean_status_sidecar_pin_source( repo->index_file, &record.sidecar, repo->hash_algo, &index) || record.sidecar.hardlink_nr > 0); @@ -1693,6 +1695,7 @@ struct repository *repo UNUSED) int reusable_clean_query; int normal_has_head; int reissue_clean_sidecar = 0; + int repository_inputs_changed = 0; int reissue_after_write = 0; int save_history_after_write = 0; struct object_id oid; @@ -1808,7 +1811,8 @@ struct repository *repo UNUSED) clean_status_enable_external_history(the_repository); s.certify_clean_status = exact_clean_query; if (reusable_clean_query && - clean_status_try_sidecar(the_repository, &clean_digest)) { + clean_status_try_sidecar(the_repository, &clean_digest, + &repository_inputs_changed)) { if (exact_clean_query || print_clean_sidecar(&s, prefix)) { wt_status_collect_free_buffers(&s); @@ -1818,7 +1822,8 @@ struct repository *repo UNUSED) if (normal_clean_query && use_optional_locks() && clean_status_identity_is_durable()) reissue_clean_sidecar = - clean_status_sidecar_needs_reissue(the_repository); + clean_status_sidecar_needs_reissue( + the_repository, repository_inputs_changed); if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) { diff --git a/clean-status-fast.c b/clean-status-fast.c index 512f6b4b67aba0..b72f1e592374ac 100644 --- a/clean-status-fast.c +++ b/clean-status-fast.c @@ -21,8 +21,10 @@ int clean_status_try_sidecar( struct repository *repo UNUSED, - const struct clean_status_config_digest *config UNUSED) + const struct clean_status_config_digest *config UNUSED, + int *repository_inputs_changed) { + *repository_inputs_changed = 0; return 0; } @@ -196,7 +198,8 @@ static int current_worktree_is_main(struct repository *repo) int clean_status_try_sidecar( struct repository *repo, - const struct clean_status_config_digest *config) + const struct clean_status_config_digest *config, + int *repository_inputs_changed) { struct clean_status_sidecar_record record = CLEAN_STATUS_SIDECAR_RECORD_INIT; @@ -212,6 +215,7 @@ int clean_status_try_sidecar( char *query_token = NULL; int ret = 0; + *repository_inputs_changed = 0; if (!config->finalized || config->filter_configured || getenv(INDEX_ENVIRONMENT) || is_bare_repository(repo) || !repo_get_work_tree(repo) || @@ -259,6 +263,7 @@ int clean_status_try_sidecar( } if (memcmp(repo_hash, record.sidecar.proof.repo_hash, repo->hash_algo->rawsz)) { + *repository_inputs_changed = 1; trace_miss(repo, "fast-repository-input"); goto done; } diff --git a/clean-status.h b/clean-status.h index b6a78bcf8b7b8c..ac5d5ba19f4423 100644 --- a/clean-status.h +++ b/clean-status.h @@ -104,7 +104,8 @@ int clean_status_issue_sidecar( int normal_clean_query); int clean_status_try_sidecar( struct repository *repo, - const struct clean_status_config_digest *config); + const struct clean_status_config_digest *config, + int *repository_inputs_changed); int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index a2747d84d2b2fe..8896cf7e55d72a 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -1723,6 +1723,55 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" reissued-hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'a plain clean status reissues a proof after locale changes' ' + test_when_finished "stop_daemon sidecar-locale-reissue" && + setup_repo sidecar-locale-reissue && + git -C sidecar-locale-reissue config core.untrackedCache true && + issue_sidecar sidecar-locale-reissue && + cp sidecar-locale-reissue/.git/index locale-reissue.index && + + test_env LANG=sidecar-locale-reissue \ + GIT_TRACE2_EVENT="$PWD/locale-reissue.trace" \ + git -C sidecar-locale-reissue status >locale-reissue.actual && + test_grep "working tree clean" locale-reissue.actual && + test_cmp_bin locale-reissue.index \ + sidecar-locale-reissue/.git/index && + test_trace2_data status clean-proof/miss fast-repository-input \ + locale-reissue-hit.actual && + test_cmp locale-reissue.actual locale-reissue-hit.actual && + test_trace2_data status clean-proof/hit 1 \ + locale-readonly.actual && + test_grep "working tree clean" locale-readonly.actual && + test_cmp_bin locale-readonly.sidecar \ + sidecar-locale-readonly/.git/index.csts && + test_trace2_data status clean-proof/miss fast-repository-input \ + Date: Thu, 13 Aug 2026 11:13:22 -0500 Subject: [PATCH 284/403] fsmonitor: recover history after mixed-writer resets Unstaging an ordinary newly added file invalidated the entire fsmonitor proof despite each removed entry already passing the semantic-safety check. Worse, an installed Git reset can remove FSMN, FSCF, and FSUC altogether while changing the index relative to the last authenticated external checkpoint. The next status then rebuilt every attribute candidate and refreshed the entire index. Preserve proofs for individually safe removals and recover a missing provider boundary from a fully authenticated checkpoint only when its old token yields a safe delta. Start with every current entry dirty, restore only matching checkpoint-clean entries, and force content checks for remaining untrusted entries so externally modified hardlinks cannot become false clean. Exercise the exact add, delete, checkout, reset, status sequence for both the built-in reset and an actual foreign Git. Reject global invalidations, changed attributes, unsafe logical changes, and same-timestamp hardlink mutations. --- builtin/reset.c | 4 +- clean-status-history.c | 95 ++++++++++++++-- t/t7527-builtin-fsmonitor.sh | 214 +++++++++++++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 10 deletions(-) diff --git a/builtin/reset.c b/builtin/reset.c index 0c5eae4724ea10..d4129d64aa9fc9 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -543,8 +543,8 @@ int cmd_reset(int argc, (the_repository->index->split_index || the_repository->index->sparse_index || (the_repository->index->cache_changed & - (CE_ENTRY_CHANGED | CE_ENTRY_REMOVED | - CE_ENTRY_ADDED | RESOLVE_UNDO_CHANGED)))) + (CE_ENTRY_CHANGED | CE_ENTRY_ADDED | + RESOLVE_UNDO_CHANGED)))) clean_status_invalidate_current_proof( the_repository->index); the_repository->index->updated_skipworktree = 1; diff --git a/clean-status-history.c b/clean-status-history.c index 495566d74dbc6e..719b262dcaba81 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1076,13 +1076,34 @@ static int restore_external_semantic_history( struct clean_status_identity before_identity, after_identity; struct stat before, after; unsigned char witness_hash[GIT_MAX_RAWSZ]; + struct clean_status_state *state = istate->clean_status; char *path = NULL; int fd = -1, transferred = 0; + int missing_current = 0, seeded_current = 0; if (!checkpoint->source_alias_valid || - !has_usable_on_index_builtin_token(istate) || fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) goto done; + if (!has_usable_on_index_builtin_token(istate)) { + if (!state || istate != istate->repo->index || + istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + istate->fsmonitor_extension_seen || + istate->fsmonitor_token_valid || + istate->fsmonitor_last_update || + istate->fsmonitor_last_update_pending || + istate->fsmonitor_dirty || + istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_token || + istate->fsmonitor_untracked_valid || + state->disk_config_seen || state->disk_config_invalid || + state->filter_configured || + state->current_attr_sources_present || + !istate->repo->config_values_private_.trust_ctime || + !istate->repo->config_values_private_.check_stat) + goto done; + missing_current = 1; + } path = clean_status_history_store_witness_path( istate->repo->index_file, proof_namespace, istate->repo->hash_algo); @@ -1112,6 +1133,12 @@ static int restore_external_semantic_history( checkpoint->fsmonitor_config_len, istate->repo->hash_algo)) goto done; + if (missing_current && + ((proof.flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || !checkpoint->fsmonitor_len || + !checkpoint->untracked_cache_len || + !checkpoint->fsmonitor_untracked_len)) + goto done; clean_status_release(&witness); clean_status_attach_config(&witness); clean_status_read_fsmonitor_config( @@ -1124,14 +1151,48 @@ static int restore_external_semantic_history( clean_status_prepare_fsmonitor_config(&witness); if (!current_proof_is_writable(&witness) || query_builtin_fsmonitor(witness.fsmonitor_last_update, &old) != - FSMONITOR_QUERY_DELTA || - query_builtin_fsmonitor(istate->fsmonitor_last_update, ¤t) != - FSMONITOR_QUERY_DELTA || - strcmp(old.token.buf, current.token.buf) || - !external_semantic_delta_is_safe(&old.paths, &witness, istate) || - !clean_status_index_snapshot_still_matches_proof_epoch( - snapshot, istate)) + FSMONITOR_QUERY_DELTA) goto done; + if (!missing_current) { + if (query_builtin_fsmonitor(istate->fsmonitor_last_update, + ¤t) != FSMONITOR_QUERY_DELTA || + strcmp(old.token.buf, current.token.buf) || + !external_semantic_delta_is_safe(&old.paths, + &witness, istate) || + !clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + } + if (missing_current) { + const char *changed = old.paths.buf; + const char *end = old.paths.buf + old.paths.len; + + if (!has_usable_on_index_builtin_token(&witness) || + !old.token.len || + !starts_with(old.token.buf, "builtin:") || + !strcmp(old.token.buf, "builtin:fake") || + !external_semantic_delta_is_safe(&old.paths, + &witness, istate) || + !clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + while (changed < end) { + if (!strcmp(changed, FSMONITOR_PATH_GLOBAL_INVALIDATE)) + goto done; + changed += strlen(changed) + 1; + } + if (changed != end) + goto done; + for (size_t i = 0; i < istate->cache_nr; i++) + if (istate->cache[i]->ce_flags & CE_FSMONITOR_VALID) + goto done; + istate->fsmonitor_last_update = + xstrdup(witness.fsmonitor_last_update); + istate->fsmonitor_token_valid = 1; + istate->fsmonitor_extension_seen = 1; + fill_fsmonitor_bitmap(istate); + seeded_current = 1; + } if (strcmp(witness.fsmonitor_last_update, istate->fsmonitor_last_update)) { clean_status_advance_fsmonitor_config_token( @@ -1148,13 +1209,31 @@ static int restore_external_semantic_history( istate, &witness, &old.paths); restore_external_tracked_history( istate, &witness, checkpoint, &old.paths, &proof); + if (missing_current && istate->fsmonitor_dirty) + ewah_each_bit(istate->fsmonitor_dirty, + invalidate_unwatched_recovered_entry, istate); restore_external_untracked_history( istate, &witness, checkpoint, &old.paths, &proof); trace2_data_intmax("fsmonitor", istate->repo, "history/external-semantic-restored", 1); + if (missing_current) + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-fsmn-recovered", 1); } done: + if (seeded_current && !transferred) { + FREE_AND_NULL(istate->fsmonitor_last_update); + istate->fsmonitor_token_valid = 0; + istate->fsmonitor_extension_seen = 0; + if (istate->fsmonitor_dirty) + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; + for (size_t i = 0; i < istate->cache_nr; i++) + istate->cache[i]->ce_flags &= ~CE_FSMONITOR_VALID; + clean_status_release(istate); + clean_status_attach_config(istate); + } if (fd >= 0) close(fd); free(path); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 5a9359283867a4..21f946c2222a3b 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -3845,6 +3845,145 @@ test_expect_success MACOS,LEGACY_PREVIEW_FSMONITOR_GIT,UNTRACKED_CACHE,SEMANTIC_ ) ' +test_expect_success FOREIGN_FSMONITOR_GIT,HARDLINKS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a foreign reset without fsmonitor extensions replays staged history safely' ' + test_when_finished "rm -rf foreign-reset-no-provider" && + test_when_finished "rm -f foreign-reset-no-provider.alias" && + test_create_repo foreign-reset-no-provider && + ( + cd foreign-reset-no-provider && + sane_unset GIT_TEST_SPLIT_INDEX && + for sibling in $(test_seq 1 48) + do + mkdir -p "existing-$((sibling % 8))" && + test_write_lines "$sibling" \ + >"existing-$((sibling % 8))/tracked-$sibling" || + return 1 + done && + git add existing-* && + git commit -qm base && + ln existing-0/tracked-8 \ + ../foreign-reset-no-provider.alias && + test-tool chmtime -120 existing-*/* && + git update-index --refresh && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + test_write_lines staged >x && + test-tool chmtime -120 x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + git add x && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/physical-staged && + test_grep "^1 A\\..* x$" .git/physical-staged && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --porcelain=v2 >.git/checkpoint && + test_cmp .git/physical-staged .git/checkpoint && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + + rm x && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + git status --porcelain=v2 >.git/deleted && + test_grep "^1 AD .* x$" .git/deleted && + /opt/homebrew/bin/git -c core.fsmonitor=false \ + checkout -- x && + /opt/homebrew/bin/git -c core.fsmonitor=false \ + reset >.git/foreign-reset && + test_grep ! FSMN .git/index && + test_grep ! FSCF .git/index && + test_grep ! FSUC .git/index && + test_grep UNTR .git/index && + cp .git/index .git/foreign-before.index && + test_write_lines "? x" >.git/expect && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/reference && + test_cmp .git/expect .git/reference && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + GIT_TRACE2_EVENT="$PWD/.git/recover.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/recover.trace && + test_trace2_data fsmonitor history/external-fsmn-recovered 1 \ + <.git/recover.trace && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/recover.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/recover.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/recover.trace && + ! test_trace2_data index preload/bulk_dirs \ + "[1-9][0-9]*" <.git/recover.trace && + ! test_trace2_data index refresh/sum_scan \ + "[2-9][0-9]*" <.git/recover.trace && + + cp .git/foreign-before.index .git/index && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/global.trace" \ + git status --porcelain=v2 >.git/global && + test_cmp .git/expect .git/global && + ! test_trace2_data fsmonitor history/external-fsmn-recovered 1 \ + <.git/global.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/global.trace && + + mtime=$(test-tool chmtime --get existing-0/tracked-8) && + printf "9\\n" >../foreign-reset-no-provider.alias && + test-tool chmtime =$mtime \ + ../foreign-reset-no-provider.alias && + cp .git/foreign-before.index .git/index && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/hardlink.expect && + test_grep "^1 \\.M .* existing-0/tracked-8$" \ + .git/hardlink.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + GIT_TRACE2_EVENT="$PWD/.git/hardlink.trace" \ + git status --porcelain=v2 >.git/hardlink.actual && + test_cmp .git/hardlink.expect .git/hardlink.actual && + + test_write_lines "*.txt text" >.gitattributes && + cp .git/foreign-before.index .git/index && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/attributes.expect && + test_grep "^? \\.gitattributes$" .git/attributes.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git status --porcelain=v2 >.git/attributes.actual && + test_cmp .git/attributes.expect .git/attributes.actual && + ! test_trace2_data fsmonitor history/external-fsmn-recovered 1 \ + <.git/attributes.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/attributes.trace + ) +' + test_expect_success FOREIGN_FSMONITOR_GIT,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'a foreign index writer does not strand a racy provider token' ' test_when_finished "stop_daemon_delete_repo foreign-racy-token" && @@ -5294,6 +5433,81 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'mixed reset preserves semantic history when unstaging a new file' ' + test_when_finished "rm -rf reset-mixed-new-file" && + test_create_repo reset-mixed-new-file && + ( + cd reset-mixed-new-file && + sane_unset GIT_TEST_SPLIT_INDEX && + for sibling in $(test_seq 1 24) + do + mkdir -p "existing-$((sibling % 6))" && + test_write_lines "$sibling" \ + >"existing-$((sibling % 6))/tracked-$sibling" || + return 1 + done && + git add existing-* && + git commit -qm base && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + + test_write_lines staged >x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + git add x && + rm x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + git checkout -- x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git reset >.git/reset && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_write_lines "? x" >.git/expect && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" <.git/status.trace && + ! test_trace2_data index preload/bulk_dirs \ + "[1-9][0-9]*" <.git/status.trace && + ! test_trace2_data index refresh/sum_scan \ + "[2-9][0-9]*" <.git/status.trace && + + test_write_lines "*.txt text" >.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git add .gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git reset >.git/attributes-reset && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git status --porcelain=v2 >.git/attributes && + test_grep "^? \\.gitattributes$" .git/attributes && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/attributes.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'mixed reset drops history after a logical index change' ' test_when_finished "rm -rf reset-mixed-changed" && From acaa7bb1c7e3021e7b67c2d69692451d44d70d50 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 12:19:28 -0500 Subject: [PATCH 285/403] t7519: persist paired cache before testing provider reset The global invalidation fixture assumes its clean priming status physically writes the paired FSUC extension. On macOS, external history can satisfy the same query without rewriting the index, so that prerequisite intermittently fails before the intended global invalidation is exercised. Pin GIT_INDEX_FILE for the priming command, matching the existing neighboring fixtures and forcing deterministic physical proof materialization. --- t/t7519-status-fsmonitor.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 709ef379132962..3ce5f332241ae7 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -970,6 +970,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && From 7d89280eac819316da8f9f6464e575535aa1be0e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 12:54:20 -0500 Subject: [PATCH 286/403] add: never write the index during a dry run A dry run can refresh fsmonitor state while reading the index, which makes the common exit path write an otherwise unchanged index. When configured filters prevent semantic history from being preserved, that write invalidates the clean-status proof and makes the next status rebuild worktree metadata. Roll back the index lock for dry runs instead. They must not update the index or run index-change hooks. --- builtin/add.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/builtin/add.c b/builtin/add.c index e7871643c6b97b..d2ac83d9f0dbf9 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -637,8 +637,10 @@ int cmd_add(int argc, finish: if (preserve_add_history && exit_status) clean_status_invalidate_current_proof(repo->index); - if (write_locked_index(repo->index, &lock_file, - COMMIT_LOCK | SKIP_IF_UNCHANGED)) + if (show_only) + rollback_lock_file(&lock_file); + else if (write_locked_index(repo->index, &lock_file, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) die(_("unable to write new index file")); free(ps_matched); From 28d464ceacbbe529bc2803d3a97a305b050087c9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 12:54:27 -0500 Subject: [PATCH 287/403] status: preserve history for paths without active filters A configured clean or process filter does not make every index change semantically unsafe. In particular, a global Git LFS configuration was invalidating the authenticated worktree proof after adding or unstaging ordinary, unfiltered files. The following status then rebuilt the entire manifest and untracked cache. Preserve history when an already-authenticated filter scope exists and the changed path selects no active clean filter. Continue rejecting actual filtered paths, changed attribute sources, unsafe index shapes, and mismatched provider tokens. Cover ordinary staging, unstaging, dry runs, nested additions, and active-filter invalidation. --- clean-status.c | 10 ++- t/t7527-builtin-fsmonitor.sh | 118 +++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/clean-status.c b/clean-status.c index 603edf66e524fb..b8754350d58765 100644 --- a/clean-status.c +++ b/clean-status.c @@ -3,6 +3,7 @@ #include "attr-manifest.h" #include "clean-status.h" #include "clean-status-internal.h" +#include "convert.h" #include "dir.h" #include "fsmonitor-clean-proof.h" #include "progress.h" @@ -313,11 +314,13 @@ int clean_status_index_entry_is_semantically_safe( { const struct clean_status_state *state = istate->clean_status; const struct cache_entry *entry = old ? old : new_entry; + struct conv_attrs attrs; const char *base; if (!state || !state->config_revalidated || !clean_status_revalidated_token_matches(istate) || - state->filter_configured || istate->split_index || + (state->filter_configured && !state->filter_scope_valid) || + istate->split_index || istate->sparse_index || !entry) return 0; if ((old && (!S_ISREG(old->ce_mode) && !S_ISLNK(old->ce_mode))) || @@ -336,6 +339,11 @@ int clean_status_index_entry_is_semantically_safe( if (!fspathcmp(base, ".gitattributes") || !fspathcmp(base, ".gitignore")) return 0; + if (state->filter_configured) { + convert_attrs((struct index_state *)istate, &attrs, entry->name); + if (convert_attrs_has_clean_filter(&attrs)) + return 0; + } if (!old || !new_entry) return path_has_no_new_attribute_sources(istate, entry->name, old && !new_entry); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 21f946c2222a3b..373064bffda64c 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -6156,6 +6156,124 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'unused configured filters preserve staged and dry-run history' ' + test_when_finished "rm -rf configured-filter-staged" && + test_create_repo configured-filter-staged && + ( + cd configured-filter-staged && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir nested && + test_write_lines base >tracked && + test_write_lines sibling >nested/tracked && + test_write_lines "*.filtered filter=demo" \ + "*.processed filter=protocol" >.gitattributes && + git add .gitattributes tracked nested/tracked && + git commit -m base && + git config filter.demo.clean cat && + git config filter.protocol.process \ + "test-tool rot13-filter --log=.git/filter-process.log clean smudge" && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/prime.trace && + test_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/tracked-staged.trace" \ + git status --porcelain=v2 >.git/tracked-staged && + test_grep "^1 M\\..* tracked$" .git/tracked-staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/tracked-staged.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/tracked-staged.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/tracked-staged.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/tracked-unstaged.trace" \ + git status --porcelain=v2 >.git/tracked-unstaged && + test_grep "^1 \\.M.* tracked$" .git/tracked-unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/tracked-unstaged.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/tracked-unstaged.trace && + + cp .git/index .git/before-dry-run.index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/dry-run.trace" \ + git add --dry-run tracked >.git/dry-run && + test_grep "^add .*tracked" .git/dry-run && + test_cmp .git/before-dry-run.index .git/index && + test_grep ! "\"label\":\"do_write_index\"" .git/dry-run.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/after-dry-run.trace" \ + git status --porcelain=v2 >.git/after-dry-run && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/after-dry-run.trace && + ! test_trace2_data fsmonitor history/external-proof-invalidated 1 \ + <.git/after-dry-run.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/after-dry-run.trace && + + for path in new-root nested/new + do + test_write_lines added >"$path" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH="$path" \ + git add "$path" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/new-staged.trace" \ + git status --porcelain=v2 >.git/new-staged && + test_grep "^1 A\\..* $path$" .git/new-staged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/new-staged.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/new-staged.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git restore --staged "$path" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/new-unstaged.trace" \ + git status --porcelain=v2 >.git/new-unstaged && + test_grep "^? $path$" .git/new-unstaged && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/new-unstaged.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/new-unstaged.trace && + rm "$path" || return 1 + done && + + test_write_lines sensitive >active.filtered && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=active.filtered \ + git add active.filtered && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/active-staged.trace" \ + git status --porcelain=v2 >.git/active-staged && + test_grep "^1 A\\..* active.filtered$" .git/active-staged && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/active-staged.trace && + test_trace2_data semantic_verify active-filters 1 \ + <.git/active-staged.trace && + ! test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/active-staged.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'sparse index rebuilds semantic history without expansion' ' test_when_finished "rm -rf sparse-semantic" && From 4bc13b15c9cfe1122f78927420306cf1bc6435e0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 13:26:53 -0500 Subject: [PATCH 288/403] status: ignore command-scoped preload tuning in proofs The og wrapper enables bulk index preloading through command-scoped configuration, while direct Git invocations may not. Hashing those transient acceleration settings into the authenticated worktree proof made the two clients disagree about repository configuration. When an unused clean filter was configured, alternating clients repeatedly discarded the full metadata manifest and rebuilt it. Exclude only command-scoped core.preloadIndex and core.preloadIndexBulk from the proof digest. Continue hashing persistent preload settings, semantic conversion settings, tracked-file policy, and filters. Cover both hash algorithms, every persistent scope, alternating wrapper and direct invocations, and fail-closed filemode changes. --- clean-status-config.c | 13 +++- t/t7527-builtin-fsmonitor.sh | 96 ++++++++++++++++++++++++++++ t/unit-tests/u-clean-status-config.c | 78 ++++++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index 1d32470e1e5586..c224fe38769615 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -75,6 +75,14 @@ static int config_is_command_transport(const char *key, !strcmp(subkey, "pushinsteadof"); } +static int config_is_command_acceleration(const char *key, + const struct config_context *ctx) +{ + return ctx && ctx->kvi && ctx->kvi->scope == CONFIG_SCOPE_COMMAND && + (!strcmp(key, "core.preloadindex") || + !strcmp(key, "core.preloadindexbulk")); +} + static int config_is_tracked_policy(const char *key) { return !strcmp(key, "core.filemode") || @@ -101,8 +109,9 @@ void clean_status_config_add(struct clean_status_config_digest *digest, if (!digest->initialized || digest->finalized) BUG("invalid clean-status config digest state"); - /* Process-local transport settings cannot change a worktree proof. */ - if (config_is_command_transport(key, ctx)) + /* Process-local transport and traversal settings cannot change a proof. */ + if (config_is_command_transport(key, ctx) || + config_is_command_acceleration(key, ctx)) return; hash_config_entry(&digest->ctx, key, value, ctx); if (config_is_tracked_policy(key)) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 373064bffda64c..2decb3bad4bfb2 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -6274,6 +6274,102 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'command-scoped preload tuning preserves configured filter history' ' + test_when_finished "rm -rf configured-filter-preload" && + test_create_repo configured-filter-preload && + ( + cd configured-filter-preload && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir nested && + test_write_lines base >tracked && + test_write_lines sibling >nested/tracked && + test_write_lines "*.filtered filter=demo" \ + "*.processed filter=protocol" >.gitattributes && + git add .gitattributes tracked nested/tracked && + git commit -m base && + git config filter.demo.clean cat && + git config filter.protocol.process \ + "test-tool rot13-filter --log=.git/filter-process.log clean smudge" && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/prime.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + + for label in bulk preload both bulk-false preload-false + do + case "$label" in + bulk) set -- -c core.preloadIndexBulk ;; + preload) set -- -c core.preloadIndex ;; + both) set -- -c core.preloadIndexBulk -c core.preloadIndex ;; + bulk-false) set -- -c core.preloadIndexBulk=false ;; + preload-false) set -- -c core.preloadIndex=false ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git "$@" status --porcelain=v2 >.git/$label && + test_must_be_empty .git/$label && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/$label.trace && + ! test_trace2_data fsmonitor semantic/initial-mismatch 1 \ + <.git/$label.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/$label.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/$label.trace && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <.git/$label.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label-plain.trace" \ + git status --porcelain=v2 >.git/$label-plain && + test_must_be_empty .git/$label-plain && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/$label-plain.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/$label-plain.trace || return 1 + done && + + git config core.preloadIndexBulk true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/persistent.trace" \ + git status --porcelain=v2 >.git/persistent && + test_must_be_empty .git/persistent && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/persistent.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/persistent.trace && + + git config core.filemode false && + chmod +x tracked && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/filemode-prime && + test_must_be_empty .git/filemode-prime && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -c core.filemode=true \ + status --porcelain=v2 >.git/filemode.expect && + test_grep "^1 \\.M .* tracked$" .git/filemode.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/filemode.trace" \ + git -c core.filemode=true status --porcelain=v2 \ + >.git/filemode.actual && + test_cmp .git/filemode.expect .git/filemode.actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/filemode.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/filemode.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'sparse index rebuilds semantic history without expansion' ' test_when_finished "rm -rf sparse-semantic" && diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 7b96ae955b2c1a..745b9a1e17513b 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -109,6 +109,84 @@ void test_clean_status_config__command_transport_config_does_not_change_proof(vo } } +void test_clean_status_config__command_preload_config_does_not_change_proof(void) +{ + static const char *const ignored_keys[] = { + "core.preloadindex", + "core.preloadindexbulk", + }; + static const enum config_scope persistent_scopes[] = { + CONFIG_SCOPE_SYSTEM, + CONFIG_SCOPE_GLOBAL, + CONFIG_SCOPE_LOCAL, + CONFIG_SCOPE_WORKTREE, + CONFIG_SCOPE_UNKNOWN, + }; + static const int algorithms[] = { + GIT_HASH_SHA1, + GIT_HASH_SHA256, + }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + for (size_t i = 0; i < ARRAY_SIZE(algorithms); i++) { + const struct git_hash_algo *algo = &hash_algos[algorithms[i]]; + struct clean_status_config_digest baseline, digest; + + clean_status_config_init(&baseline, algo); + clean_status_config_final(&baseline); + for (size_t j = 0; j < ARRAY_SIZE(ignored_keys); j++) { + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, ignored_keys[j], + "true", &ctx); + clean_status_config_final(&digest); + cl_assert(hasheq(digest.hash, baseline.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + + for (size_t scope = 0; + scope < ARRAY_SIZE(persistent_scopes); scope++) { + kvi.scope = persistent_scopes[scope]; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, ignored_keys[j], + "true", &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, + baseline.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + } + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, ignored_keys[j], + "true", NULL); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + } + + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "core.filemode", "true", &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + cl_assert(!hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "core.autocrlf", "true", &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + cl_assert(!hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + } +} + void test_clean_status_config__command_worktree_config_still_changes_proof(void) { static const char *const retained_keys[] = { From 1f8294de8d9711c5a51a00417d6c3bf9a6d1d5b6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 16:19:31 -0500 Subject: [PATCH 289/403] attr-fingerprint: treat empty source paths as absent An explicitly empty core.attributesFile value disables global attributes. Passing that value to absolute_pathdup() instead aborts attribute fingerprinting. Treat empty source paths as absent while retaining their distinct namespace fingerprint. Cover disabled global attributes, repository-local precedence, and both object formats. --- attr-fingerprint.c | 4 ++-- t/t0003-attributes.sh | 17 +++++++++++++++++ t/unit-tests/u-attr-fingerprint.c | 24 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/attr-fingerprint.c b/attr-fingerprint.c index 3f6fbf18ba4007..4db09c16509fc7 100644 --- a/attr-fingerprint.c +++ b/attr-fingerprint.c @@ -55,7 +55,7 @@ static int hash_source(struct git_hash_ctx *content_ctx, hash_length_delimited(namespace_ctx, &state, sizeof(state)); hash_length_delimited(portable_namespace_ctx, &state, sizeof(state)); *present = 0; - if (!source->enabled || !source->path) { + if (!source->enabled || !source->path || !*source->path) { hash_optional_cstring(content_ctx, NULL); hash_length_delimited(content_ctx, &state, sizeof(state)); state = 0; @@ -222,7 +222,7 @@ static int legacy_absent_path_is_stable(const char *path) char *absolute = NULL; int stable = 0; - if (!path) + if (!path || !*path) return 0; absolute = absolute_pathdup(path); strbuf_addstr(&normalized, absolute); diff --git a/t/t0003-attributes.sh b/t/t0003-attributes.sh index 582e207aa12eb1..62c55ebd2b040e 100755 --- a/t/t0003-attributes.sh +++ b/t/t0003-attributes.sh @@ -241,6 +241,23 @@ test_expect_success 'core.attributesfile' ' attr_check global precedence ' +test_expect_success 'empty core.attributesfile disables global attributes' ' + test_when_finished "rm -rf empty-global-attributes" && + test_create_repo empty-global-attributes && + ( + cd empty-global-attributes && + echo "global test=global" >.git/global-attributes && + echo "local test=local" >.gitattributes && + git config core.attributesfile "$PWD/.git/global-attributes" && + attr_check global global && + attr_check global unspecified "-c core.attributesFile=" && + attr_check local local "-c core.attributesFile=" && + git -c core.attributesFile= status --porcelain=v1 \ + --untracked-files=no >actual && + test_must_be_empty actual + ) +' + test_expect_success 'attribute test: read paths from stdin' ' grep -v notest expect && sed -e "s/:.*//" actual && diff --git a/t/unit-tests/u-attr-fingerprint.c b/t/unit-tests/u-attr-fingerprint.c index 1d930f5e32b874..e43d4e92f55a26 100644 --- a/t/unit-tests/u-attr-fingerprint.c +++ b/t/unit-tests/u-attr-fingerprint.c @@ -135,6 +135,30 @@ void test_attr_fingerprint__does_not_observe_disabled_sources(void) remove_directory(directory); } +void test_attr_fingerprint__treats_empty_source_paths_as_absent(void) +{ + const struct git_hash_algo *algos[] = { + &hash_algos[GIT_HASH_SHA1], + &hash_algos[GIT_HASH_SHA256], + }; + + for (size_t i = 0; i < ARRAY_SIZE(algos); i++) { + const struct git_hash_algo *algo = algos[i]; + struct attr_fingerprint empty, absent; + + fingerprint("", 1, algo, &empty); + fingerprint(NULL, 1, algo, &absent); + cl_assert(!empty.sources_present); + cl_assert(!absent.sources_present); + cl_assert(!memcmp(empty.content_hash, absent.content_hash, + algo->rawsz)); + cl_assert(!memcmp(empty.portable_namespace_hash, + absent.portable_namespace_hash, algo->rawsz)); + cl_assert(memcmp(empty.namespace_hash, absent.namespace_hash, + algo->rawsz)); + } +} + void test_attr_fingerprint__equates_distinct_absent_source_paths(void) { #ifndef O_NONBLOCK From 6900890b2b67fc8e730d5f7ad4901c4dd283a419 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 16:19:38 -0500 Subject: [PATCH 290/403] status: reuse proofs for empty attribute overrides Empty command-scoped global-attribute and attribute-tree overrides can describe the same effective attribute sources as the default configuration. Including those overrides in clean-status and tracked-policy fingerprints unnecessarily invalidates authenticated proofs. Ignore an empty global attribute override because source contents remain independently authenticated. Ignore an empty attribute-tree override only when no nonempty tree was configured earlier. Retain fail-closed behavior for real global attributes and configured attribute trees, with coverage under both object formats. --- clean-status-config.c | 19 ++++- clean-status-config.h | 1 + t/t7527-builtin-fsmonitor.sh | 104 +++++++++++++++++++++++++++ t/unit-tests/u-clean-status-config.c | 95 ++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index c224fe38769615..076d56651350ed 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -83,6 +83,18 @@ static int config_is_command_acceleration(const char *key, !strcmp(key, "core.preloadindexbulk")); } +static int config_is_command_empty_attributes(const char *key, + const char *value, + const struct config_context *ctx, + const struct clean_status_config_digest *digest) +{ + return ctx && ctx->kvi && ctx->kvi->scope == CONFIG_SCOPE_COMMAND && + value && !*value && + (!strcmp(key, "core.attributesfile") || + (!strcmp(key, "attr.tree") && + !digest->attribute_tree_configured)); +} + static int config_is_tracked_policy(const char *key) { return !strcmp(key, "core.filemode") || @@ -109,9 +121,12 @@ void clean_status_config_add(struct clean_status_config_digest *digest, if (!digest->initialized || digest->finalized) BUG("invalid clean-status config digest state"); - /* Process-local transport and traversal settings cannot change a proof. */ + if (!strcmp(key, "attr.tree") && value && *value) + digest->attribute_tree_configured = 1; + /* Independent attribute fingerprints guard empty source overrides. */ if (config_is_command_transport(key, ctx) || - config_is_command_acceleration(key, ctx)) + config_is_command_acceleration(key, ctx) || + config_is_command_empty_attributes(key, value, ctx, digest)) return; hash_config_entry(&digest->ctx, key, value, ctx); if (config_is_tracked_policy(key)) diff --git a/clean-status-config.h b/clean-status-config.h index 1f72325b9f0059..aa26a009d02612 100644 --- a/clean-status-config.h +++ b/clean-status-config.h @@ -18,6 +18,7 @@ struct clean_status_config_digest { unsigned finalized : 1; unsigned filter_configured : 1; unsigned semantic_config_explicit : 1; + unsigned attribute_tree_configured : 1; }; void clean_status_config_init(struct clean_status_config_digest *digest, diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 2decb3bad4bfb2..d793adc4bc20a9 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -6370,6 +6370,110 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'empty command attributes preserve inactive filter history' ' + test_when_finished "rm -rf configured-filter-empty-attributes" && + test_create_repo configured-filter-empty-attributes && + ( + cd configured-filter-empty-attributes && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir nested && + test_write_lines original >tracked && + test_write_lines sibling >nested/tracked && + test_write_lines "*.filtered filter=demo" \ + "*.processed filter=protocol" >.gitattributes && + git add .gitattributes tracked nested/tracked && + git commit -m base && + git config filter.demo.clean cat && + git config filter.protocol.process \ + "test-tool rot13-filter --log=.git/filter-process.log clean smudge" && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + + for label in empty tree-empty repeated + do + case "$label" in + empty|repeated) set -- -c core.attributesFile= ;; + tree-empty) set -- -c attr.tree= -c core.attributesFile= ;; + esac && + cp .git/index .git/$label.index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git "$@" status --porcelain=v1 --untracked-files=no \ + >.git/$label && + test_must_be_empty .git/$label && + test_cmp .git/$label.index .git/index && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/$label.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/$label.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/$label.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$label-plain.trace" \ + git status --porcelain=v1 --untracked-files=no \ + >.git/$label-plain && + test_must_be_empty .git/$label-plain && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/$label-plain.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/$label-plain.trace || return 1 + done && + + test_write_lines "tracked filter=demo" >.git/global-attributes && + git config core.attributesFile "$PWD/.git/global-attributes" && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/global-prime && + test_must_be_empty .git/global-prime && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -c core.attributesFile= \ + status --porcelain=v1 --untracked-files=no \ + >.git/global.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/global-empty.trace" \ + git -c core.attributesFile= status --porcelain=v1 \ + --untracked-files=no >.git/global.actual && + test_cmp .git/global.expect .git/global.actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/global-empty.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/global-empty.trace && + + git config --unset core.attributesFile && + git config attr.tree HEAD && + test_write_lines "tracked filter=demo" >.gitattributes && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git status --porcelain=v1 --untracked-files=no \ + >.git/tree-prime && + test_grep "^ M .gitattributes$" .git/tree-prime && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false -c attr.tree= \ + status --porcelain=v1 --untracked-files=no \ + >.git/tree.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/tree-cleared.trace" \ + git -c attr.tree= status --porcelain=v1 \ + --untracked-files=no >.git/tree.actual && + test_cmp .git/tree.expect .git/tree.actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/tree-cleared.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/tree-cleared.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'sparse index rebuilds semantic history without expansion' ' test_when_finished "rm -rf sparse-semantic" && diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 745b9a1e17513b..0807c0c0010fd0 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -187,6 +187,101 @@ void test_clean_status_config__command_preload_config_does_not_change_proof(void } } +void test_clean_status_config__command_empty_attributes_do_not_change_proof(void) +{ + static const enum config_scope persistent_scopes[] = { + CONFIG_SCOPE_SYSTEM, + CONFIG_SCOPE_GLOBAL, + CONFIG_SCOPE_LOCAL, + CONFIG_SCOPE_WORKTREE, + CONFIG_SCOPE_UNKNOWN, + }; + static const int algorithms[] = { + GIT_HASH_SHA1, + GIT_HASH_SHA256, + }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + for (size_t i = 0; i < ARRAY_SIZE(algorithms); i++) { + const struct git_hash_algo *algo = &hash_algos[algorithms[i]]; + struct clean_status_config_digest baseline, digest; + + clean_status_config_init(&baseline, algo); + clean_status_config_final(&baseline); + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "core.attributesfile", "", &ctx); + clean_status_config_final(&digest); + cl_assert(hasheq(digest.hash, baseline.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "attr.tree", "", &ctx); + clean_status_config_final(&digest); + cl_assert(hasheq(digest.hash, baseline.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + cl_assert(!digest.attribute_tree_configured); + + for (size_t scope = 0; scope < ARRAY_SIZE(persistent_scopes); + scope++) { + kvi.scope = persistent_scopes[scope]; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "core.attributesfile", + "", &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + cl_assert(!hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "attr.tree", "", &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + } + + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "core.attributesfile", + "/tmp/attributes", &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + cl_assert(!hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "core.attributesfile", "", NULL); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "core.attributesfile", NULL, &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + + clean_status_config_init(&baseline, algo); + kvi.scope = CONFIG_SCOPE_LOCAL; + clean_status_config_add(&baseline, "attr.tree", "HEAD", &ctx); + clean_status_config_final(&baseline); + cl_assert(baseline.attribute_tree_configured); + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, "attr.tree", "HEAD", &ctx); + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_add(&digest, "attr.tree", "", &ctx); + clean_status_config_final(&digest); + cl_assert(digest.attribute_tree_configured); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + } +} + void test_clean_status_config__command_worktree_config_still_changes_proof(void) { static const char *const retained_keys[] = { From 17148f5045ee595dcbe26d643a2f5e1a15498a97 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 11:36:46 -0500 Subject: [PATCH 291/403] fsmonitor--daemon: target worktree hardlink events by inode 47d224659c (fsmonitor--daemon: invalidate globally for worktree hardlink events, 2026-07-10) protects tracked files from changes made through another name of the same inode. The Darwin listener does not read the index, so it invalidates the entire worktree for every hardlink event, even when that inode belongs only to ignored files. This is particularly expensive for ignored Cargo build directories. On an 8,193-entry index, creating 10,663 hardlinked artifacts forces a full tracked refresh and rebuilds the attribute manifest despite none of those inodes belonging to the index. Request extended FSEvents metadata and pass inode markers to clients through a negotiated query-v2 protocol. Collect all markers in each response and scan the index once, invalidating tracked entries whose stored 32-bit inode matches. Retain ordinary pathname events, handle inode collisions and missing stat data conservatively, and fall back to global invalidation when an event does not supply an inode. Continue accepting query-v1 requests and translate inode markers into global invalidations for older clients. New clients replace an older daemon once through the existing compatibility mechanism. Reject inode markers when restoring external checkpoints that cannot replay them safely. Exercise ignored artifacts, deleted and escaped tracked aliases, hardlinked attribute and ignore files, malformed inode markers, and unsafe checkpoint recovery. The ignored-artifact workload improves from 262 ms to 63 ms without global invalidation or an attribute scan. Signed-off-by: Taylor Blau --- builtin/fsmonitor--daemon.c | 19 ++- clean-status-history.c | 8 +- compat/fsmonitor/fsm-darwin-gcc.h | 15 +++ compat/fsmonitor/fsm-listen-darwin.c | 74 +++++++++-- fsmonitor-ipc.c | 12 +- fsmonitor-ipc.h | 6 + fsmonitor-ll.h | 2 + fsmonitor.c | 105 ++++++++++++++- t/helper/test-simple-ipc.c | 9 +- t/t7527-builtin-fsmonitor.sh | 187 ++++++++++++++++++++++++++- t/unit-tests/u-fsmonitor-response.c | 31 +++++ 11 files changed, 442 insertions(+), 26 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 6adef864a65a7c..d96d7e8fb9d0e5 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -417,7 +417,7 @@ static struct fsmonitor_token_data *fsmonitor_new_token_data(void) #ifdef __APPLE__ strbuf_addstr(&token->token_id, - FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX); + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX); #endif gettimeofday(&tv, NULL); secs = tv.tv_sec; @@ -710,6 +710,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, int do_flush = 0; int do_cookie = 0; int invalid_binding = 0; + int hardlink_aware_query = 0; enum fsmonitor_cookie_item_result cookie_result; if (strcmp(command, "quit") && @@ -719,8 +720,14 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, const char *identity; const char *query; - if (!skip_prefix(command, FSMONITOR_IPC_QUERY_PREFIX, - &identity) || + if (skip_prefix(command, FSMONITOR_IPC_HARDLINK_QUERY_PREFIX, + &identity)) + hardlink_aware_query = 1; + else if (!skip_prefix(command, FSMONITOR_IPC_QUERY_PREFIX, + &identity)) + identity = NULL; + + if (!identity || !(query = strchr(identity, '\n')) || query - identity != FSMONITOR_IPC_WORKTREE_ID_HEX || state->worktree_identity.len != FSMONITOR_IPC_WORKTREE_ID_HEX || @@ -748,7 +755,9 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, static const char capabilities[] = FSMONITOR_IPC_QUERY_VERSION "\n" #ifdef __APPLE__ + FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" + FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n" #endif ; @@ -951,6 +960,10 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, const char *s = batch->interned_paths[k]; size_t s_len; + if (!hardlink_aware_query && + starts_with(s, FSMONITOR_PATH_HARDLINK_INODE_PREFIX)) + s = FSMONITOR_PATH_GLOBAL_INVALIDATE; + if (!strset_add(&shown, s)) duplicates++; else { diff --git a/clean-status-history.c b/clean-status-history.c index 719b262dcaba81..0617a76da3d677 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -712,7 +712,8 @@ static int missing_fsmonitor_token_is_replayable( const char *base = find_last_dir_sep(path); base = base ? base + 1 : path; - if (!strcmp(path, FSMONITOR_PATH_GLOBAL_INVALIDATE)) + if (!strcmp(path, FSMONITOR_PATH_GLOBAL_INVALIDATE) || + starts_with(path, FSMONITOR_PATH_HARDLINK_INODE_PREFIX)) goto done; if (!fspathcmp(base, ".gitattributes")) { struct index_state witness = *istate; @@ -777,7 +778,9 @@ static int external_semantic_delta_is_safe( const char *base = find_last_dir_sep(path); base = base ? base + 1 : path; - if (!len || !fspathcmp(base, ".gitattributes") || + if (!len || + starts_with(path, FSMONITOR_PATH_HARDLINK_INODE_PREFIX) || + !fspathcmp(base, ".gitattributes") || !fspathcmp(base, ".gitignore")) return 0; if (path[len - 1] == '/') { @@ -1371,6 +1374,7 @@ static int restore_external_bootstrap_manifest( base = base ? base + 1 : changed; if (!len || !strcmp(changed, FSMONITOR_PATH_GLOBAL_INVALIDATE) || + starts_with(changed, FSMONITOR_PATH_HARDLINK_INODE_PREFIX) || changed[len - 1] == '/' || (!fspathcmp(base, ".gitattributes") && strcmp(changed, ".gitattributes"))) diff --git a/compat/fsmonitor/fsm-darwin-gcc.h b/compat/fsmonitor/fsm-darwin-gcc.h index 3496e29b3a1f1b..959bc88f8f765a 100644 --- a/compat/fsmonitor/fsm-darwin-gcc.h +++ b/compat/fsmonitor/fsm-darwin-gcc.h @@ -40,9 +40,13 @@ typedef const FSEventStreamRef ConstFSEventStreamRef; typedef unsigned int CFStringEncoding; #define kCFStringEncodingUTF8 0x08000100 +typedef long CFIndex; typedef const struct __CFString *CFStringRef; typedef const struct __CFArray *CFArrayRef; +typedef const struct __CFDictionary *CFDictionaryRef; +typedef const struct __CFNumber *CFNumberRef; typedef const struct __CFRunLoop *CFRunLoopRef; +#define kCFNumberSInt64Type 4 struct FSEventStreamContext { long long version; @@ -51,9 +55,11 @@ struct FSEventStreamContext { typedef struct FSEventStreamContext FSEventStreamContext; typedef unsigned int FSEventStreamEventFlags; +#define kFSEventStreamCreateFlagUseCFTypes 0x01 #define kFSEventStreamCreateFlagNoDefer 0x02 #define kFSEventStreamCreateFlagWatchRoot 0x04 #define kFSEventStreamCreateFlagFileEvents 0x10 +#define kFSEventStreamCreateFlagUseExtendedData 0x40 typedef unsigned long long FSEventStreamEventId; #define kFSEventStreamEventIdSinceNow 0xFFFFFFFFFFFFFFFFULL @@ -74,8 +80,17 @@ FSEventStreamRef FSEventStreamCreate(void *allocator, FSEventStreamCreateFlags flags); CFStringRef CFStringCreateWithCString(void *allocator, const char *string, CFStringEncoding encoding); +CFIndex CFStringGetLength(CFStringRef string); +CFIndex CFStringGetMaximumSizeForEncoding(CFIndex length, + CFStringEncoding encoding); +unsigned char CFStringGetCString(CFStringRef string, char *buffer, + CFIndex buffer_size, + CFStringEncoding encoding); CFArrayRef CFArrayCreate(void *allocator, const void **items, long long count, void *callbacks); +const void *CFArrayGetValueAtIndex(CFArrayRef array, CFIndex index); +const void *CFDictionaryGetValue(CFDictionaryRef dictionary, const void *key); +unsigned char CFNumberGetValue(CFNumberRef number, CFIndex type, void *value); void CFRunLoopRun(void); void CFRunLoopStop(CFRunLoopRef run_loop); CFRunLoopRef CFRunLoopGetCurrent(void); diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index 41e47c4ac17d77..57f27faefa3eb2 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -37,6 +37,8 @@ struct fsm_listen_data { CFStringRef cfsr_worktree_path; CFStringRef cfsr_gitdir_path; + CFStringRef cfsr_event_path_key; + CFStringRef cfsr_event_inode_key; CFArrayRef cfar_paths_to_watch; int nr_paths_watching; @@ -221,13 +223,14 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, { struct fsmonitor_daemon_state *state = ctx; struct fsm_listen_data *data = state->listen_data; - char **paths = (char **)event_paths; + CFArrayRef events = event_paths; struct fsmonitor_batch *batch = NULL; struct string_list cookie_list = STRING_LIST_INIT_DUP; const char *path_k; const char *slash; char *resolved = NULL; struct strbuf tmp = STRBUF_INIT; + struct strbuf event_path = STRBUF_INIT; enum fsmonitor_path_type path_type; /* @@ -235,15 +238,39 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, * list and without holding any locks. */ for (size_t k = 0; k < num_of_events; k++) { + CFDictionaryRef event = CFArrayGetValueAtIndex(events, k); + CFStringRef path = event ? + CFDictionaryGetValue(event, data->cfsr_event_path_key) : + NULL; + CFNumberRef inode = event ? + CFDictionaryGetValue(event, data->cfsr_event_inode_key) : + NULL; + CFIndex path_size; + int64_t file_id = 0; + /* - * On Mac, we receive an array of absolute paths. + * Extended events retain their inode even when their pathname has + * already been removed by the time this callback runs. */ + if (!path) + goto invalid_event; + path_size = CFStringGetMaximumSizeForEncoding( + CFStringGetLength(path), kCFStringEncodingUTF8); + if (path_size < 0) + goto invalid_event; + strbuf_reset(&event_path); + strbuf_grow(&event_path, path_size + 1); + if (!CFStringGetCString(path, event_path.buf, path_size + 1, + kCFStringEncodingUTF8)) + goto invalid_event; + strbuf_setlen(&event_path, strlen(event_path.buf)); + free(resolved); - resolved = fsmonitor__resolve_alias(paths[k], &state->alias); + resolved = fsmonitor__resolve_alias(event_path.buf, &state->alias); if (resolved) path_k = resolved; else - path_k = paths[k]; + path_k = event_path.buf; /* * If you want to debug FSEvents, log them to GIT_TRACE_FSMONITOR. @@ -315,16 +342,24 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, if (ef_is_hardlink(event_flags[k]) && path_type == IS_WORKDIR_PATH) { /* - * An event for one name does not prove that all names of the - * inode are in this watch cone. Make the client content-check - * the entire tracked set rather than trusting path-local stats. + * The daemon never reads the index. Let an inode-aware client + * invalidate every tracked alias without disturbing unrelated + * ignored hardlinks. Missing inode data must remain fail-closed. */ if (trace_pass_fl(&trace_fsmonitor)) log_flags_set(path_k, event_flags[k]); if (!batch) batch = fsmonitor_batch__new(); - my_add_path(batch, FSMONITOR_PATH_GLOBAL_INVALIDATE); - continue; + if (!inode || !CFNumberGetValue(inode, kCFNumberSInt64Type, + &file_id) || !file_id) { + my_add_path(batch, FSMONITOR_PATH_GLOBAL_INVALIDATE); + } else { + strbuf_reset(&tmp); + strbuf_addf(&tmp, "%s%016"PRIx64, + FSMONITOR_PATH_HARDLINK_INODE_PREFIX, + (uint64_t)file_id); + my_add_path(batch, tmp.buf); + } } switch (path_type) { @@ -415,18 +450,28 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, "ignoring '%s'", path_k); break; } + continue; + +invalid_event: + fsmonitor_force_resync(state); + fsmonitor_batch__free_list(batch); + string_list_clear(&cookie_list, 0); + batch = NULL; } free(resolved); fsmonitor_publish(state, batch, &cookie_list); string_list_clear(&cookie_list, 0); strbuf_release(&tmp); + strbuf_release(&event_path); return; force_shutdown: free(resolved); fsmonitor_batch__free_list(batch); string_list_clear(&cookie_list, 0); + strbuf_release(&tmp); + strbuf_release(&event_path); pthread_mutex_lock(&data->dq_lock); data->shutdown_style = FORCE_SHUTDOWN; @@ -458,7 +503,9 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) { FSEventStreamCreateFlags flags = kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagWatchRoot | - kFSEventStreamCreateFlagFileEvents; + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagUseExtendedData; FSEventStreamContext ctx = { 0, state, @@ -472,6 +519,13 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) CALLOC_ARRAY(data, 1); state->listen_data = data; + data->cfsr_event_path_key = CFStringCreateWithCString( + NULL, "path", kCFStringEncodingUTF8); + data->cfsr_event_inode_key = CFStringCreateWithCString( + NULL, "fileID", kCFStringEncodingUTF8); + if (!data->cfsr_event_path_key || !data->cfsr_event_inode_key) + goto failed; + data->cfsr_worktree_path = CFStringCreateWithCString( NULL, state->path_worktree_watch.buf, kCFStringEncodingUTF8); dir_array[data->nr_paths_watching++] = data->cfsr_worktree_path; diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 7c60d7b59193af..867a0c975f3dc3 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -445,7 +445,11 @@ static int server_supports_required_capabilities(void) &answer, NULL, 1) && has_capability(&answer, FSMONITOR_IPC_QUERY_VERSION) && has_capability(&answer, - FSMONITOR_IPC_DIR_METADATA_CAPABILITY); + FSMONITOR_IPC_HARDLINK_QUERY_VERSION) && + has_capability(&answer, + FSMONITOR_IPC_DIR_METADATA_CAPABILITY) && + has_capability(&answer, + FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY); strbuf_release(&answer); return ret; #else @@ -458,7 +462,7 @@ static int query_identifies_filtered_daemon(const char *token, const struct strbuf *answer) { static const char prefix[] = - "builtin:" FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX; + "builtin:" FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX; const char *end = memchr(answer->buf, '\0', answer->len); return starts_with(token, prefix) && end && @@ -901,7 +905,11 @@ int fsmonitor_ipc__send_query(const char *since_token, "query/worktree-identity-error", 1); goto done; } +#ifdef __APPLE__ + strbuf_addstr(&command, FSMONITOR_IPC_HARDLINK_QUERY_PREFIX); +#else strbuf_addstr(&command, FSMONITOR_IPC_QUERY_PREFIX); +#endif strbuf_addbuf(&command, &identity); strbuf_addch(&command, '\n'); strbuf_addstr(&command, tok); diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index d52fcf8cf96f2f..39d8d9cdb6c5f8 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -7,9 +7,15 @@ struct repository; #define FSMONITOR_IPC_QUERY_VERSION "query-v1" #define FSMONITOR_IPC_QUERY_PREFIX FSMONITOR_IPC_QUERY_VERSION " " +#define FSMONITOR_IPC_HARDLINK_QUERY_VERSION "query-v2" +#define FSMONITOR_IPC_HARDLINK_QUERY_PREFIX \ + FSMONITOR_IPC_HARDLINK_QUERY_VERSION " " #define FSMONITOR_IPC_CAPABILITY_COMMAND "get-capabilities" #define FSMONITOR_IPC_DIR_METADATA_CAPABILITY "dir-metadata-filter-v1" #define FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX "dirmeta-v1." +#define FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "hardlink-inode-v1" +#define FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX \ + FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX "inode-v1." #define FSMONITOR_IPC_WORKTREE_ID_HEX 64 /* Hash the canonical worktree root and its stable filesystem identity. */ diff --git a/fsmonitor-ll.h b/fsmonitor-ll.h index 339a21078c98ff..a458fc4fa3788a 100644 --- a/fsmonitor-ll.h +++ b/fsmonitor-ll.h @@ -6,6 +6,8 @@ struct strbuf; /* A provider-only marker; worktree-relative paths cannot begin with '/'. */ #define FSMONITOR_PATH_GLOBAL_INVALIDATE "//" +#define FSMONITOR_PATH_HARDLINK_INODE_PREFIX "//inode:" +#define FSMONITOR_PATH_HARDLINK_INODE_HEX 16 enum fsmonitor_token_result { FSMONITOR_TOKEN_NOT_PENDING = 0, diff --git a/fsmonitor.c b/fsmonitor.c index 072a8dcf909d54..c7ec20ded11c1a 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -12,6 +12,8 @@ #include "ewah/ewok_rlw.h" #include "fsmonitor.h" #include "fsmonitor-ipc.h" +#include "hashmap.h" +#include "hex-ll.h" #include "name-hash.h" #include "repository.h" #include "run-command.h" @@ -813,6 +815,32 @@ static int fsmonitor_valid_worktree_path(const char *path, size_t len) return valid; } +static int fsmonitor_parse_hardlink_inode(const char *path, size_t len, + uint32_t *inode) +{ + const char *hex; + uint64_t value = 0; + size_t i; + + if (!skip_prefix(path, FSMONITOR_PATH_HARDLINK_INODE_PREFIX, &hex)) + return 0; + if (len != strlen(FSMONITOR_PATH_HARDLINK_INODE_PREFIX) + + FSMONITOR_PATH_HARDLINK_INODE_HEX) + return -1; + for (i = 0; i < FSMONITOR_PATH_HARDLINK_INODE_HEX; i++) { + unsigned int digit = hexval(hex[i]); + + if (digit > 0xf) + return -1; + value = (value << 4) | digit; + } + if (!value) + return -1; + if (inode) + *inode = (uint32_t)value; + return 1; +} + enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( const struct strbuf *raw, struct fsmonitor_query_result *result) { @@ -845,6 +873,7 @@ enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( if (!nul || nul == p) goto malformed; if (strcmp(p, FSMONITOR_PATH_GLOBAL_INVALIDATE) && + fsmonitor_parse_hardlink_inode(p, nul - p, NULL) <= 0 && !fsmonitor_valid_worktree_path(p, nul - p)) goto malformed; p = nul + 1; @@ -913,20 +942,90 @@ enum fsmonitor_query_outcome query_builtin_fsmonitor( return result->outcome; } +struct fsmonitor_hardlink_inode { + struct hashmap_entry ent; + uint32_t inode; +}; + +static int fsmonitor_hardlink_inode_cmp(const void *unused UNUSED, + const struct hashmap_entry *eptr, + const struct hashmap_entry *entry_or_key, + const void *keydata) +{ + const struct fsmonitor_hardlink_inode *entry = + container_of(eptr, const struct fsmonitor_hardlink_inode, ent); + const uint32_t *inode = keydata; + + if (inode) + return entry->inode != *inode; + return entry->inode != + container_of(entry_or_key, + const struct fsmonitor_hardlink_inode, ent)->inode; +} + static int apply_fsmonitor_paths(struct index_state *istate, const struct strbuf *paths) { const char *p = paths->buf; const char *end = paths->buf + paths->len; + struct hashmap inodes = HASHMAP_INIT(fsmonitor_hardlink_inode_cmp, NULL); + struct fsmonitor_hardlink_inode *entry; + unsigned int matches = 0; int count = 0; while (p < end) { size_t len = strlen(p); - - fsmonitor_refresh_callback(istate, (char *)p); - count++; + uint32_t inode; + int parsed = fsmonitor_parse_hardlink_inode(p, len, &inode); + + if (parsed < 0) { + fsmonitor_refresh_callback( + istate, (char *)FSMONITOR_PATH_GLOBAL_INVALIDATE); + count++; + goto done; + } + if (!parsed) { + fsmonitor_refresh_callback(istate, (char *)p); + count++; + } else if (!hashmap_get_entry_from_hash( + &inodes, memhash(&inode, sizeof(inode)), &inode, + struct fsmonitor_hardlink_inode, ent)) { + CALLOC_ARRAY(entry, 1); + entry->inode = inode; + hashmap_entry_init(&entry->ent, + memhash(&inode, sizeof(inode))); + hashmap_add(&inodes, &entry->ent); + } p += len + 1; } + + if (hashmap_get_size(&inodes)) { + unsigned int i; + + trace2_data_intmax("fsmonitor", istate->repo, + "apply/hardlink-inode-events", + hashmap_get_size(&inodes)); + for (i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + uint32_t inode = ce->ce_stat_data.sd_ino; + + if (inode && + !hashmap_get_entry_from_hash( + &inodes, memhash(&inode, sizeof(inode)), &inode, + struct fsmonitor_hardlink_inode, ent)) + continue; + fsmonitor_refresh_callback(istate, ce->name); + matches++; + count++; + } + trace2_data_intmax("fsmonitor", istate->repo, + "apply/hardlink-index-scan", 1); + trace2_data_intmax("fsmonitor", istate->repo, + "apply/hardlink-matches", matches); + } + +done: + hashmap_clear_and_free(&inodes, struct fsmonitor_hardlink_inode, ent); return count; } diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index d06b94b01d41a4..d1f2149740fbcc 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -175,10 +175,10 @@ static int app__fsmonitor_capability_superset( static const char capabilities[] = "query-v1\nquery-v2\n" #ifdef __APPLE__ "dir-metadata-filter-v1\n" + "hardlink-inode-v1\n" #endif ; static const char pre_dir_metadata_capabilities[] = "query-v1\n"; - static const char query_prefix[] = "query-v1 "; static const char token[] = "builtin:test-capable:0"; const char *query; size_t query_len; @@ -198,7 +198,8 @@ static int app__fsmonitor_capability_superset( query_len = query ? command_len - (query + 1 - command) : 0; ret = reply_cb(reply_data, token, sizeof(token)); if (!ret && - (!starts_with(command, query_prefix) || + ((!starts_with(command, "query-v1 ") && + !starts_with(command, "query-v2 ")) || query_len != sizeof(token) - 1 || memcmp(query + 1, token, query_len))) ret = reply_cb(reply_data, "/", 2); @@ -225,7 +226,9 @@ static int test_app_cb(void *application_data, BUG("application_cb: application_data pointer wrong"); /* Exit before the server can flush a response to this bound query. */ - if (fsmonitor_disconnect_first && starts_with(command, "query-v1 ")) + if (fsmonitor_disconnect_first && + (starts_with(command, "query-v1 ") || + starts_with(command, "query-v2 "))) _exit(0); if (command_len == 4 && !strncmp(command, "quit", 4)) { diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index d793adc4bc20a9..e33cbf002d4df8 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1414,7 +1414,7 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep -q " M dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-try3.out" ' -test_expect_success MACOS,HARDLINKS 'hardlink events invalidate all tracked paths' ' +test_expect_success MACOS,HARDLINKS 'hardlink inode events invalidate tracked aliases' ' test_when_finished "git -C hardlink-event fsmonitor--daemon stop 2>/dev/null || :" && test_create_repo hardlink-event && ( @@ -1444,7 +1444,167 @@ test_expect_success MACOS,HARDLINKS 'hardlink events invalidate all tracked path touch -r .git/mtime-reference alias && GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 >.git/actual && test_cmp .git/expect .git/actual && - test_grep "^event: //$" ../hardlink-event.trace && + test_grep "^event: //inode:" ../hardlink-event.trace && + test_grep ! "^event: //$" ../hardlink-event.trace && + git fsmonitor--daemon stop + ) +' + +test_expect_success MACOS,HARDLINKS 'ignored hardlinks do not invalidate unrelated tracked paths' ' + test_when_finished "git -C ignored-hardlink fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo ignored-hardlink && + ( + cd ignored-hardlink && + printf "target/\\n" >.gitignore && + printf "tracked\\n" >tracked && + git add .gitignore tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + start_daemon --tf "$PWD/../ignored-hardlink.trace" && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + mkdir target && + printf "build artifact\\n" >target/object && + ln target/object target/object-link && + GIT_TRACE2_EVENT="$PWD/.git/ignored-hardlink.trace2" \ + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep ! "^event: //$" ../ignored-hardlink.trace && + test_grep ! "apply/global-invalidation" \ + .git/ignored-hardlink.trace2 && + git fsmonitor--daemon stop + ) +' + +test_expect_success MACOS,HARDLINKS \ + 'deleted ignored hardlink aliases still invalidate tracked inodes' ' + test_when_finished "git -C deleted-hardlink fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo deleted-hardlink && + ( + cd deleted-hardlink && + printf "target/\\n" >.gitignore && + printf "AAAA\\n" >tracked && + git add .gitignore tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + git config core.trustctime false && + git config core.checkStat minimal && + cp -p tracked .git/mtime-reference && + start_daemon --tf "$PWD/../deleted-hardlink.trace" && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + mkdir target && + ln tracked target/alias && + printf "BBBB\\n" >target/alias && + touch -r .git/mtime-reference target/alias && + rm target/alias && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/deleted-hardlink.trace2" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \\.M .* tracked$" .git/actual && + test_grep "^event: //inode:" ../deleted-hardlink.trace && + test_grep ! "^event: //$" ../deleted-hardlink.trace && + test_trace2_data fsmonitor apply/hardlink-matches 1 \ + <.git/deleted-hardlink.trace2 && + git fsmonitor--daemon stop + ) +' + +test_expect_success MACOS,HARDLINKS \ + 'hardlink aliases moved outside the worktree remain tracked' ' + test_when_finished "git -C escaped-hardlink fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished "rm -f outside-hardlink" && + test_create_repo escaped-hardlink && + ( + cd escaped-hardlink && + printf "target/\\n" >.gitignore && + printf "AAAA\\n" >tracked && + git add .gitignore tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + git config core.trustctime false && + git config core.checkStat minimal && + cp -p tracked .git/mtime-reference && + start_daemon --tf "$PWD/../escaped-hardlink.trace" && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + mkdir target && + ln tracked target/alias && + mv target/alias ../outside-hardlink && + printf "BBBB\\n" >../outside-hardlink && + touch -r .git/mtime-reference ../outside-hardlink && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 >.git/actual && + test_grep "^1 \\.M .* tracked$" .git/actual && + test_grep "^event: //inode:" ../escaped-hardlink.trace && + test_grep ! "^event: //$" ../escaped-hardlink.trace && + git fsmonitor--daemon stop + ) +' + +test_expect_success MACOS,HARDLINKS \ + 'ignored hardlink aliases invalidate tracked attribute sources' ' + test_when_finished "git -C attribute-hardlink fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo attribute-hardlink && + ( + cd attribute-hardlink && + printf "target/\\n" >.gitignore && + printf "*.txt -text\\n" >.gitattributes && + printf "tracked\\r\\n" >tracked.txt && + git add .gitignore .gitattributes tracked.txt && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + git config core.trustctime false && + git config core.checkStat minimal && + cp -p .gitattributes .git/mtime-reference && + start_daemon --tf "$PWD/../attribute-hardlink.trace" && + git status --porcelain=v2 >/dev/null && + git status --porcelain=v2 >/dev/null && + mkdir target && + ln .gitattributes target/alias && + printf "*.txt text\\n" >target/alias && + touch -r .git/mtime-reference target/alias && + rm target/alias && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 >.git/actual && + test_grep "^1 \\.M .* \\.gitattributes$" .git/actual && + test_grep "^1 \\.M .* tracked.txt$" .git/actual && + test_grep ! "^event: //$" ../attribute-hardlink.trace && + git fsmonitor--daemon stop + ) +' + +test_expect_success MACOS,HARDLINKS \ + 'ignored hardlink aliases invalidate tracked ignore sources' ' + test_when_finished "git -C ignore-hardlink fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo ignore-hardlink && + ( + cd ignore-hardlink && + printf "target/\\n*.log\\n" >.gitignore && + printf "tracked\\n" >tracked && + git add .gitignore tracked && + git commit -m base && + printf "visible\\n" >visible.log && + git config core.fsmonitor true && + git config core.untrackedCache true && + git config core.trustctime false && + git config core.checkStat minimal && + cp -p .gitignore .git/mtime-reference && + start_daemon --tf "$PWD/../ignore-hardlink.trace" && + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + git status --porcelain=v2 >/dev/null && + mkdir target && + ln .gitignore target/alias && + printf "target/\\n*.tmp\\n" >target/alias && + touch -r .git/mtime-reference target/alias && + rm target/alias && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 >.git/actual && + test_grep "^1 \\.M .* \\.gitignore$" .git/actual && + test_grep "^? visible.log$" .git/actual && + test_grep ! "^event: //$" ../ignore-hardlink.trace && git fsmonitor--daemon stop ) ' @@ -3964,6 +4124,17 @@ test_expect_success FOREIGN_FSMONITOR_GIT,HARDLINKS,UNTRACKED_CACHE,SEMANTIC_VER git status --porcelain=v2 >.git/hardlink.actual && test_cmp .git/hardlink.expect .git/hardlink.actual && + cp .git/foreign-before.index .git/index && + rm -f .git/index.csts && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=//inode:0000000000000001 \ + GIT_TRACE2_EVENT="$PWD/.git/hardlink-inode.trace" \ + git status --porcelain=v2 >.git/hardlink-inode.actual && + test_cmp .git/hardlink.expect .git/hardlink-inode.actual && + ! test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/hardlink-inode.trace && + test_write_lines "*.txt text" >.gitattributes && cp .git/foreign-before.index .git/index && rm -f .git/index.csts && @@ -5760,7 +5931,17 @@ test_expect_success UNTRACKED_CACHE,HARDLINKS,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TEST_FSMONITOR_QUERY_PATH=ignored/alias \ GIT_TRACE2_EVENT="$PWD/.git/dirty.trace" \ git status --porcelain=v2 >.git/dirty.actual && - test_grep "^1 \\.M .* tracked$" .git/dirty.actual + test_grep "^1 \\.M .* tracked$" .git/dirty.actual && + + cp .git/missing.index .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=//inode:0000000000000001 \ + GIT_TRACE2_EVENT="$PWD/.git/inode.trace" \ + git status --porcelain=v2 >.git/inode.actual && + test_grep "^1 \\.M .* tracked$" .git/inode.actual && + ! test_trace2_data fsmonitor history/external-fsmn-recovered 1 \ + <.git/inode.trace ) ' diff --git a/t/unit-tests/u-fsmonitor-response.c b/t/unit-tests/u-fsmonitor-response.c index dda747aa764097..770b8ecea17487 100644 --- a/t/unit-tests/u-fsmonitor-response.c +++ b/t/unit-tests/u-fsmonitor-response.c @@ -84,3 +84,34 @@ void test_fsmonitor_response__accepts_valid_builtin_responses(void) check_response(trivial, sizeof(trivial) - 1, FSMONITOR_QUERY_TRIVIAL, "builtin:4", NULL, 0); } + +void test_fsmonitor_response__validates_hardlink_inode_markers(void) +{ + static const char inode[] = + "builtin:5\0//inode:f123456789abcdef\0tracked\0"; + static const char zero_low_bits[] = + "builtin:6\0//inode:0000000100000000\0"; + static const char zero[] = + "builtin:7\0//inode:0000000000000000\0"; + static const char short_inode[] = + "builtin:8\0//inode:123456789abcdef\0"; + static const char long_inode[] = + "builtin:9\0//inode:0123456789abcdef0\0"; + static const char invalid_hex[] = + "builtin:10\0//inode:0123456789abcdeg\0"; + static const char embedded_path[] = + "builtin:11\0//inode:0123456789abcde/\0"; + + check_response(inode, sizeof(inode) - 1, FSMONITOR_QUERY_DELTA, + "builtin:5", inode + sizeof("builtin:5"), + sizeof(inode) - 1 - sizeof("builtin:5")); + check_response(zero_low_bits, sizeof(zero_low_bits) - 1, + FSMONITOR_QUERY_DELTA, "builtin:6", + zero_low_bits + sizeof("builtin:6"), + sizeof(zero_low_bits) - 1 - sizeof("builtin:6")); + check_malformed(zero, sizeof(zero) - 1); + check_malformed(short_inode, sizeof(short_inode) - 1); + check_malformed(long_inode, sizeof(long_inode) - 1); + check_malformed(invalid_hex, sizeof(invalid_hex) - 1); + check_malformed(embedded_path, sizeof(embedded_path) - 1); +} From 5af6c7743a9fb248f9be23850b636e3d025744f5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 12:22:47 -0500 Subject: [PATCH 292/403] t7530: expect targeted hardlink invalidations The inode-aware fsmonitor protocol invalidates only tracked entries sharing a reported hardlink inode. Two clean-sidecar tests still expect every hardlink event to invalidate the entire index, causing the macOS jobs to fail even though their correctness checks otherwise pass. Check the exact one- and two-entry inode match counts instead, and require that neither case triggers global invalidation. Signed-off-by: Taylor Blau --- t/t7530-status-clean-sidecar.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 8896cf7e55d72a..ab90b7c32b8397 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -552,7 +552,9 @@ test_expect_success DURABLE_FSMONITOR \ git -C sidecar-hardlink-racy status --porcelain=v2 \ >hardlink-racy-rebaseline.actual && test_must_be_empty hardlink-racy-rebaseline.actual && - test_trace2_data fsmonitor apply/global-invalidation 1 \ + test_trace2_data fsmonitor apply/hardlink-matches 1 \ + hardlink-stale-stat-rebaseline.actual && test_must_be_empty hardlink-stale-stat-rebaseline.actual && - test_trace2_data fsmonitor apply/global-invalidation 1 \ + test_trace2_data fsmonitor apply/hardlink-matches 2 \ + Date: Fri, 14 Aug 2026 12:24:16 -0500 Subject: [PATCH 293/403] status: reuse authenticated history after provider reset An external checkpoint may preserve a complete authenticated untracked cache even when its provider token differs from the one in the index. Normally, an expired checkpoint must not replace an index token that can still replay filesystem changes. When both tokens independently receive a trivial provider response, neither boundary is replayable. Reuse the checkpoint only after checking its logical-index identity, complete proof, paired untracked state, and strong stat assumptions. The ordinary reset path still invalidates the tracked index and revalidates every cached directory in parallel. On a private clone of a 1.16-million-entry monorepo index, that parallel reset takes 7.9 seconds with bulk preload, compared with 24.0 seconds for the serial untracked-cache rebuild. Cover newly created untracked files and changed ignore rules while preserving the existing safeguard against replacing a still-replayable index token. Signed-off-by: Taylor Blau --- clean-status-history.c | 54 +++++++++++++++++++++++++-------- t/t7530-status-clean-sidecar.sh | 52 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index 0617a76da3d677..9a2a0d8454e056 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -679,16 +679,16 @@ static int has_usable_on_index_builtin_token( strcmp(istate->fsmonitor_last_update, "builtin:fake"); } -static int external_token_is_replayable(const char *token) +static enum fsmonitor_query_outcome external_token_query_outcome( + const char *token) { struct fsmonitor_query_result result = FSMONITOR_QUERY_RESULT_INIT; - int replayable = - query_builtin_fsmonitor(token, &result) == - FSMONITOR_QUERY_DELTA; + enum fsmonitor_query_outcome outcome = + query_builtin_fsmonitor(token, &result); fsmonitor_query_result_release(&result); - return replayable; + return outcome; } static int missing_fsmonitor_token_is_replayable( @@ -1432,6 +1432,7 @@ int clean_status_restore_external_history(struct index_state *istate) int missing_fsmonitor_recovery = 0; int owned_index = 0; int preserve_witness = 0; + int provider_reset_recovery = 0; int restored = 0; if (!clean_status_external_history_enabled(istate) || !state || @@ -1555,18 +1556,44 @@ int clean_status_restore_external_history(struct index_state *istate) * crossed. Probe a differing checkpoint token before replacing a * usable on-index boundary when builtin IPC can answer that question. * A successful delta is queried again by the normal refresh path; a - * trivial or failed probe leaves the named index intact so its token - * can take the forward-baseline fallback. + * failed probe leaves the named index intact. If both tokens receive a + * trivial response, neither boundary can be replayed: a complete, + * authenticated checkpoint can still seed the existing forward + * baseline and parallel untracked-directory revalidation. */ if (fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && has_usable_on_index_builtin_token(istate) && starts_with(parsed.fsmonitor_last_update, "builtin:") && strcmp(istate->fsmonitor_last_update, - parsed.fsmonitor_last_update) && - !external_token_is_replayable(parsed.fsmonitor_last_update)) { - trace2_data_intmax("fsmonitor", istate->repo, - "history/external-token-unreplayable", 1); - goto done; + parsed.fsmonitor_last_update)) { + enum fsmonitor_query_outcome outcome = + external_token_query_outcome( + parsed.fsmonitor_last_update); + + if (outcome != FSMONITOR_QUERY_DELTA) { + if (outcome != FSMONITOR_QUERY_TRIVIAL || + !fstat_is_reliable() || + !record.checkpoint.source_alias_valid || + istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + !parsed.untracked || !parsed.untracked->root || + !parsed.untracked->root->valid || + !parsed.untracked->root->valid_recursive || + !parsed.fsmonitor_untracked_valid || + !parsed.fsmonitor_untracked_extension_seen || + parsed.fsmonitor_untracked_extension_invalid || + !istate->repo->config_values_private_.trust_ctime || + !istate->repo->config_values_private_.check_stat || + external_token_query_outcome( + istate->fsmonitor_last_update) != + FSMONITOR_QUERY_TRIVIAL) { + trace2_data_intmax( + "fsmonitor", istate->repo, + "history/external-token-unreplayable", 1); + goto done; + } + provider_reset_recovery = 1; + } } if (!clean_status_index_snapshot_still_matches_proof_epoch( &snapshot, istate)) @@ -1632,6 +1659,9 @@ int clean_status_restore_external_history(struct index_state *istate) parsed.fsmonitor_untracked_valid; trace2_data_intmax("fsmonitor", istate->repo, "history/external-restored", 1); + if (provider_reset_recovery) + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-reset-restored", 1); if (missing_fsmonitor_recovery) trace2_data_intmax("fsmonitor", istate->repo, "history/external-fsmn-recovered", 1); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index ab90b7c32b8397..c6d9b2330df9ab 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2290,6 +2290,58 @@ test_expect_success DURABLE_FSMONITOR \ external-token.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'expired index and checkpoint tokens preserve paired untracked history' ' + repo=sidecar-provider-reset && + test_when_finished "stop_daemon $repo" && + setup_repo "$repo" && + mkdir -p "$repo/cached/deep" && + test_write_lines tracked >"$repo/cached/deep/tracked" && + test_write_lines hidden >"$repo/cached/deep/visible.ignored" && + test_write_lines "*.ignored" >"$repo/.gitignore" && + test-tool chmtime -120 "$repo/cached/deep/tracked" \ + "$repo/.gitignore" && + git -C "$repo" add .gitignore cached/deep/tracked && + git -C "$repo" commit -qm nested && + git -C "$repo" config core.untrackedCache true && + git -C "$repo" config status.renameLimit 100 && + git -C "$repo" status --porcelain=v2 >reset.prime && + test_env GIT_INDEX_FILE="$PWD/$repo/.git/index" \ + git -C "$repo" status --porcelain=v2 >reset.prime && + test_must_be_empty reset.prime && + test_grep FSMN "$repo/.git/index" && + test_grep FSUC "$repo/.git/index" && + cp "$repo/.git/index" reset.namespace-a.index && + test-tool -C "$repo" fsmonitor-client flush >reset.flush && + git -C "$repo" config status.renameLimit 200 && + git -C "$repo" status --porcelain=v2 >reset.namespace-b && + test_must_be_empty reset.namespace-b && + cp reset.namespace-a.index "$repo/.git/index" && + rm -f "$repo/.git/index.csts" && + stop_daemon "$repo" && + test_write_lines "*.other" >"$repo/.gitignore" && + test_write_lines new >"$repo/cached/deep/new" && + git -C "$repo" fsmonitor--daemon start --start-timeout=10 && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/external-provider-reset.trace" \ + git -C "$repo" status --porcelain=v2 >reset.actual && + test_grep "^1 \\.M .* \\.gitignore$" reset.actual && + test_grep "^? cached/deep/new$" reset.actual && + test_grep "^? cached/deep/visible\\.ignored$" reset.actual && + test_trace2_data fsmonitor history/external-reset-restored 1 \ + Date: Fri, 14 Aug 2026 12:48:01 -0500 Subject: [PATCH 294/403] fsmonitor: preserve attribute history for removed directory cones A directory event that matches indexed entries may conceal a change to a nested .gitattributes file, so invalidate the attribute manifest conservatively. Unfortunately, removing an ordinary staged directory without any attribute sources takes the same path. In the OpenAI monorepo, deleting one staged file consequently rebuilds a manifest covering more than 236,000 directories. Recognize a removed directory when its current, complete manifest is token-bound, contains no attribute sources beneath the directory, and an anchored, no-follow lookup confirms that the directory is absent. Validate each affected entry with the existing attribute and filter checks while retaining normal tracked and untracked invalidation. Reject weak stat settings, split or sparse indexes, unsafe filters, and cones containing more than 64 entries. Cover ordinary staged-directory deletion, tracked and untracked nested attribute sources, and replacement symlinks. Signed-off-by: Taylor Blau --- clean-status.c | 109 ++++++++++++++++++++++---- t/t7527-builtin-fsmonitor.sh | 143 +++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 13 deletions(-) diff --git a/clean-status.c b/clean-status.c index b8754350d58765..d771bc7e84e65d 100644 --- a/clean-status.c +++ b/clean-status.c @@ -379,6 +379,87 @@ static unsigned int clean_status_directory_lower_bound( return low; } +static int clean_status_removed_directory_is_semantically_safe( + const struct index_state *istate, const char *name) +{ + const struct clean_status_state *state = istate->clean_status; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + struct attr_manifest_cursor cursor; + struct attr_manifest_entry entry; + struct strbuf candidate = STRBUF_INIT; + const char *basename; + unsigned int first, i, namespace_unstable = 0; + size_t len; + int parent_fd, next, safe = 0; + + if (!state || !fstat_is_reliable() || + !state->current_config_valid || !state->current_attr_valid || + !state->config_enforced || !state->config_revalidated || + !clean_status_revalidated_token_matches(istate) || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.current_invalidated || + (state->manifest.current_flags & FSMONITOR_CLEAN_PROOF_ALL) != + FSMONITOR_CLEAN_PROOF_ALL || + (state->filter_configured && !state->filter_scope_valid) || + istate->split_index || istate->sparse_index || + !istate->repo->config_values_private_.trust_ctime || + !istate->repo->config_values_private_.check_stat) + return 0; + + len = strlen(name); + if (!len || name[len - 1] != '/') + return 0; + first = clean_status_directory_lower_bound(istate, name); + if (first >= istate->cache_nr || + !starts_with(istate->cache[first]->name, name)) + return 0; + /* Each descendant independently authenticates its attribute ancestry. */ + if (istate->cache_nr - first > 64 && + starts_with(istate->cache[first + 64]->name, name)) + return 0; + + if (attr_manifest_cursor_init(&cursor, + state->manifest.current.buf, + state->manifest.current.len, + istate->repo->hash_algo)) + return 0; + while ((next = attr_manifest_cursor_next(&cursor, &entry)) > 0) + if (entry.path_len >= len && + !memcmp(entry.path, name, len)) + return 0; + if (next < 0 || semantic_verify_root_init(istate->repo, &root)) + return 0; + + path = semantic_verify_path_new(root); + strbuf_addstr(&candidate, name); + strbuf_addstr(&candidate, ".gitattributes"); + if (!semantic_verify_resolve_parent(path, candidate.buf, 0, + &parent_fd, &basename) || + errno != ENOENT) + goto done; + + for (i = first; i < istate->cache_nr && + starts_with(istate->cache[i]->name, name); i++) + if (!clean_status_index_entry_is_semantically_safe( + istate, istate->cache[i], NULL)) + goto done; + + semantic_verify_path_free(path, &namespace_unstable, NULL); + path = NULL; + safe = !namespace_unstable && semantic_verify_root_stable(root); + if (safe) + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/authenticated-removed-directory", 1); + +done: + if (path) + semantic_verify_path_free(path, &namespace_unstable, NULL); + semantic_verify_root_clear(root); + strbuf_release(&candidate); + return safe; +} + void clean_status_set_authenticated_new_directories( struct index_state *istate, const struct index_state *old_index, const struct strbuf *paths) @@ -427,22 +508,24 @@ int clean_status_directory_event_is_semantically_safe( const struct clean_status_state *state = istate->clean_status; const char *path, *end; - if (!state || !state->authenticated_new_directories_token || - !clean_status_revalidated_token_matches(istate) || - strcmp(state->authenticated_new_directories_token, - istate->fsmonitor_last_update)) + if (!state || !clean_status_revalidated_token_matches(istate)) return 0; - path = state->authenticated_new_directories.buf; - end = path + state->authenticated_new_directories.len; - while (path < end) { - if (!strcmp(path, name)) { - trace2_data_intmax("fsmonitor", istate->repo, - "semantic/authenticated-new-directory", 1); - return 1; + if (state->authenticated_new_directories_token && + !strcmp(state->authenticated_new_directories_token, + istate->fsmonitor_last_update)) { + path = state->authenticated_new_directories.buf; + end = path + state->authenticated_new_directories.len; + while (path < end) { + if (!strcmp(path, name)) { + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/authenticated-new-directory", 1); + return 1; + } + path += strlen(path) + 1; } - path += strlen(path) + 1; } - return 0; + return clean_status_removed_directory_is_semantically_safe( + istate, name); } int clean_status_capture_attr_snapshot( diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index e33cbf002d4df8..17fec9a0af02e4 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2793,6 +2793,149 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'deleting a staged directory preserves unrelated attribute history' ' + test_when_finished "rm -rf deleted-staged-directory" && + test_create_repo deleted-staged-directory && + ( + cd deleted-staged-directory && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "* text=auto" >.gitattributes && + test_write_lines base >tracked && + git add .gitattributes tracked && + git commit -qm base && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + mkdir staged && + test_write_lines one >staged/one && + test_write_lines two >staged/two && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/one \ + git add staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 A\\..* staged/one$" .git/staged && + test_grep "^1 A\\..* staged/two$" .git/staged && + rm -rf staged && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/ \ + GIT_TRACE2_EVENT="$PWD/.git/removed.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 AD .* staged/one$" .git/actual && + test_grep "^1 AD .* staged/two$" .git/actual && + test_trace2_data fsmonitor \ + semantic/authenticated-removed-directory 1 \ + <.git/removed.trace && + ! have_t2_data_event fsmonitor semantic/attributes-cone \ + <.git/removed.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/removed.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'deleted staged directories never discard nested attribute sources' ' + test_when_finished \ + "rm -rf deleted-staged-attrs-tracked deleted-staged-attrs-untracked" && + for source in tracked untracked + do + repo=deleted-staged-attrs-$source && + test_create_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + mkdir staged && + test_write_lines "*.txt text eol=lf" \ + >staged/.gitattributes && + test_write_lines staged >staged/file.txt && + if test "$source" = tracked + then + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/.gitattributes \ + git add staged + else + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/file.txt \ + git add staged/file.txt + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/staged && + rm -rf staged && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/ \ + GIT_TRACE2_EVENT="$PWD/.git/removed.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/removed.trace && + ! have_t2_data_event fsmonitor \ + semantic/authenticated-removed-directory \ + <.git/removed.trace + ) || return 1 + done +' + +test_expect_success SYMLINKS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'a replacement symlink cannot impersonate a removed staged directory' ' + test_when_finished "rm -rf deleted-staged-symlink" && + test_create_repo deleted-staged-symlink && + ( + cd deleted-staged-symlink && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + mkdir staged replacement && + test_write_lines staged >staged/file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/file \ + git add staged/file && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/staged && + rm -rf staged && + ln -s replacement staged && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/ \ + GIT_TRACE2_EVENT="$PWD/.git/removed.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/removed.trace && + ! have_t2_data_event fsmonitor \ + semantic/authenticated-removed-directory \ + <.git/removed.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'command-scoped transport config preserves staged worktree proofs' ' test_when_finished "rm -rf command-transport-history" && From 8f5cc696c98834ed2f60a60f5dda89152d28ff43 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 12:57:24 -0500 Subject: [PATCH 295/403] fsmonitor: retain attribute history when restoring directory cones The existing authenticated directory proof avoids rebuilding the attribute manifest when an indexed directory disappears, but rejects the opposite transition. Restoring the directory with git checkout therefore causes the next status to rebuild every attribute candidate, refresh the index, and enumerate untracked files unnecessarily. Accept an existing directory when anchored, no-follow resolution confirms its namespace and every indexed descendant passes the existing new-entry attribute proof. That proof checks each relevant ancestor for new attribute sources. Preserve the complete token-bound manifest, filter and strong-stat requirements, 64-entry limit, and ordinary tracked and untracked invalidation. Extend the staged-directory regression through checkout restoration, and verify that a newly introduced nested attribute source still forces conservative revalidation. Signed-off-by: Taylor Blau --- clean-status.c | 25 +++++++++++++++--------- t/t7527-builtin-fsmonitor.sh | 38 +++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/clean-status.c b/clean-status.c index d771bc7e84e65d..2c17894e949baf 100644 --- a/clean-status.c +++ b/clean-status.c @@ -379,7 +379,7 @@ static unsigned int clean_status_directory_lower_bound( return low; } -static int clean_status_removed_directory_is_semantically_safe( +static int clean_status_changed_directory_is_semantically_safe( const struct index_state *istate, const char *name) { const struct clean_status_state *state = istate->clean_status; @@ -391,7 +391,7 @@ static int clean_status_removed_directory_is_semantically_safe( const char *basename; unsigned int first, i, namespace_unstable = 0; size_t len; - int parent_fd, next, safe = 0; + int parent_fd, next, removed, safe = 0; if (!state || !fstat_is_reliable() || !state->current_config_valid || !state->current_attr_valid || @@ -434,15 +434,20 @@ static int clean_status_removed_directory_is_semantically_safe( path = semantic_verify_path_new(root); strbuf_addstr(&candidate, name); strbuf_addstr(&candidate, ".gitattributes"); - if (!semantic_verify_resolve_parent(path, candidate.buf, 0, - &parent_fd, &basename) || - errno != ENOENT) - goto done; + if (semantic_verify_resolve_parent(path, candidate.buf, 0, + &parent_fd, &basename)) { + if (errno != ENOENT) + goto done; + removed = 1; + } else { + removed = 0; + } for (i = first; i < istate->cache_nr && starts_with(istate->cache[i]->name, name); i++) if (!clean_status_index_entry_is_semantically_safe( - istate, istate->cache[i], NULL)) + istate, removed ? istate->cache[i] : NULL, + removed ? NULL : istate->cache[i])) goto done; semantic_verify_path_free(path, &namespace_unstable, NULL); @@ -450,7 +455,9 @@ static int clean_status_removed_directory_is_semantically_safe( safe = !namespace_unstable && semantic_verify_root_stable(root); if (safe) trace2_data_intmax("fsmonitor", istate->repo, - "semantic/authenticated-removed-directory", 1); + removed ? + "semantic/authenticated-removed-directory" : + "semantic/authenticated-restored-directory", 1); done: if (path) @@ -524,7 +531,7 @@ int clean_status_directory_event_is_semantically_safe( path += strlen(path) + 1; } } - return clean_status_removed_directory_is_semantically_safe( + return clean_status_changed_directory_is_semantically_safe( istate, name); } diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 17fec9a0af02e4..c2d6a533db08d2 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2839,7 +2839,43 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ! have_t2_data_event fsmonitor semantic/attributes-cone \ <.git/removed.trace && ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ - <.git/removed.trace + <.git/removed.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git checkout -- staged && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/restored.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/ \ + GIT_TRACE2_EVENT="$PWD/.git/restored.trace" \ + git status --porcelain=v2 >.git/restored.actual && + test_cmp .git/restored.expect .git/restored.actual && + test_grep "^1 A\\..* staged/one$" .git/restored.actual && + test_grep "^1 A\\..* staged/two$" .git/restored.actual && + test_trace2_data fsmonitor \ + semantic/authenticated-restored-directory 1 \ + <.git/restored.trace && + ! have_t2_data_event fsmonitor semantic/attributes-cone \ + <.git/restored.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/restored.trace && + + test_write_lines "* text eol=lf" >staged/.gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/new-attributes.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=staged/ \ + GIT_TRACE2_EVENT="$PWD/.git/new-attributes.trace" \ + git status --porcelain=v2 >.git/new-attributes.actual && + test_cmp .git/new-attributes.expect \ + .git/new-attributes.actual && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/new-attributes.trace && + ! have_t2_data_event fsmonitor \ + semantic/authenticated-restored-directory \ + <.git/new-attributes.trace ) ' From fe7dfc03d303f63ac3ce842367204f171f1e5988 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 13:31:15 -0500 Subject: [PATCH 296/403] fsmonitor: persist revalidated history after provider resets After a provider reset, status can validate the existing untracked cache against a new provider token without rewriting the physical index. When an external history checkpoint accepts the refreshed proof, the existing status path may roll back its index lock instead. The resulting index still contains an internally coherent FSMN/FSUC pair for the old provider token. A later index-writing command consequently skips the newer checkpoint, receives another trivial provider response, and drops FSUC because it cannot complete untracked-cache revalidation. Mark fully revalidated provider state as requiring physical persistence. This reuses the existing status write guard without changing extension formats or allowing optional-lock-disabled commands to write the index. Extend the reset regression to verify that read-only status preserves the index, writable status persists the new FSMN/FSUC pair, and a subsequent ordinary add keeps the untracked-cache proof intact. --- fsmonitor.c | 4 +++- t/t7519-status-fsmonitor.sh | 24 +++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index c7ec20ded11c1a..e63ea68691f8a0 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1628,9 +1628,11 @@ void fsmonitor_accept_pending_token(struct index_state *istate, istate->fsmonitor_untracked_valid = !!untracked_cache_valid; if (istate->untracked) { if (istate->untracked->fsmonitor_revalidation && - untracked_cache_valid) + untracked_cache_valid) { + istate->fsmonitor_untracked_must_persist = 1; trace2_data_intmax("fsmonitor", istate->repo, "untracked/provider-reset-revalidated", 1); + } istate->untracked->fsmonitor_revalidation = 0; istate->untracked->use_fsmonitor = !!untracked_cache_valid; } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 3ce5f332241ae7..764e7b91b2a9bb 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -756,6 +756,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep FSUC .git/index && test_grep FSCF .git/index && rm -f .git/index.csts && + cp .git/index .git/reset.index && GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ @@ -773,7 +774,28 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_trace2_data fsmonitor \ untracked/provider-reset-revalidated 1 <.git/reset.trace && test_trace2_data fsmonitor token_closure/accepted 1 \ - <.git/reset.trace + <.git/reset.trace && + test_cmp .git/reset.index .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/reset-write.trace" \ + git status --porcelain=v2 >.git/reset-write.actual && + test_cmp .git/prime .git/reset-write.actual && + test_trace2_data fsmonitor \ + untracked/provider-reset-revalidated 1 \ + <.git/reset-write.trace && + test_region index do_write_index .git/reset-write.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_write_lines changed >cached/deep/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reset-add.trace" \ + git add cached/deep/tracked && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/reset-add.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index ) ' From 49d03b6235eaaca8e5c31883e560c9569f232f4a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 14:33:37 -0500 Subject: [PATCH 297/403] fsmonitor: retain untracked candidates across expired provider tokens An ordinary index-writing command can receive a trivial fsmonitor response after the daemon evicts its previous event batches. Although the reset preserves an authenticated untracked cache in memory, staging an existing tracked path invalidates its parent directories and discards their cached names. The writer then omits FSUC because it cannot complete directory revalidation or close a new provider token. Consequently, a later status must rebuild the untracked cache instead of revalidating its existing directories in parallel. Preserve cached directory candidates only when replacing a semantically safe, existing tracked entry under an already authenticated reset. Serialize their required revalidation using the existing version-one FSUC payload, replacing its "builtin:" token prefix with "pending:". Existing readers see the unmatched FSMN/FSUC tokens and fail closed. Authenticate the pending state against the index, configuration, attribute manifest, tracked policy, and strong stat settings. Require the normal provider query, fresh anchored attribute manifest, complete parallel directory and exclude revalidation, and token closure before publishing another valid FSUC. Reject alternate indexes, sparse or split indexes, changed membership, unsafe filters, and changed attribute sources. Cover on-disk compatibility and recovery through an existing-path add, configured inactive filters, retained root and nested candidates, alternate-index rejection, and a newly activated clean filter. Signed-off-by: Taylor Blau --- clean-status-history.c | 55 +++++++++++++++- clean-status.h | 2 + dir.c | 33 +++++++--- fsmonitor.c | 40 ++++++++++-- read-cache-ll.h | 1 + read-cache.c | 44 ++++++++++++- t/t7519-status-fsmonitor.sh | 122 ++++++++++++++++++++++++++++++++++++ 7 files changed, 283 insertions(+), 14 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index 9a2a0d8454e056..fada018843f4de 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -95,6 +95,7 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) int manifest_reusable; int coherent; + istate->fsmonitor_untracked_revalidation_authenticated = 0; if (!state || !state->current_config_valid) return 0; token_coherent = state->disk_config_valid && @@ -144,6 +145,39 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) state->manifest.disk_valid && (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == FSMONITOR_CLEAN_PROOF_ALL; + istate->fsmonitor_untracked_revalidation_authenticated = + token_coherent && config_coherent && + state->disk_semantic_valid && state->current_semantic_valid && + !semantic_changed && state->disk_attr_valid && + state->current_attr_valid && !attr_changed && + state->disk_tracked_policy_valid && + state->current_tracked_policy_valid && + state->manifest.disk_valid && + state->manifest.disk_flags == + (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX) && + !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && + istate == istate->repo->index && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && fstat_is_reliable() && + istate->repo->config_values_private_.trust_ctime && + istate->repo->config_values_private_.check_stat && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + istate->untracked && istate->untracked->root && + istate->untracked->root->valid && + istate->untracked->fsmonitor_revalidation && + istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + !istate->fsmonitor_untracked_valid && + istate->fsmonitor_untracked_token && + starts_with(istate->fsmonitor_last_update, "builtin:") && + istate->fsmonitor_last_update[strlen("builtin:")] && + strcmp(istate->fsmonitor_last_update, "builtin:fake") && + starts_with(istate->fsmonitor_untracked_token, "pending:") && + !strcmp(istate->fsmonitor_last_update + strlen("builtin:"), + istate->fsmonitor_untracked_token + strlen("pending:")); manifest_reusable = token_coherent && !config_coherent && state->disk_semantic_valid && state->current_semantic_valid && !semantic_changed && state->disk_attr_valid && @@ -151,7 +185,9 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) !state->filter_configured && state->manifest.disk_valid && (state->manifest.disk_flags & FSMONITOR_CLEAN_PROOF_ALL) == FSMONITOR_CLEAN_PROOF_ALL; - state->filter_scope_valid = coherent && state->filter_configured; + state->filter_scope_valid = + (coherent || istate->fsmonitor_untracked_revalidation_authenticated) && + state->filter_configured; state->config_revalidated = coherent; state->initial_coherent = coherent; FREE_AND_NULL(state->config_revalidated_token); @@ -198,6 +234,23 @@ int clean_status_probe_fsmonitor_config(struct index_state *istate) return prepare_fsmonitor_config(istate, 0); } +int clean_status_pending_revalidation_manifest_unchanged( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + const uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + return state && istate->fsmonitor_untracked_revalidation_authenticated && + state->manifest.disk_valid && state->manifest.current_valid && + state->manifest.checked && !state->manifest.current_invalidated && + !state->manifest.global_fallback && !state->manifest.changed && + (state->manifest.disk_flags & required) == required && + (state->manifest.current_flags & required) == required && + !memcmp(state->manifest.disk_hash, state->manifest.current_hash, + istate->repo->hash_algo->rawsz); +} + int clean_status_try_preserve_tracked_config_epoch( struct index_state *istate) { diff --git a/clean-status.h b/clean-status.h index ac5d5ba19f4423..47fbeacd430918 100644 --- a/clean-status.h +++ b/clean-status.h @@ -79,6 +79,8 @@ void clean_status_begin_fsmonitor_semantic_baseline( int clean_status_refresh_worktree_manifest(struct index_state *istate); int clean_status_manifest_global_fallback(const struct index_state *istate); +int clean_status_pending_revalidation_manifest_unchanged( + const struct index_state *istate); int clean_status_has_authenticated_worktree_manifest( const struct index_state *istate); int clean_status_has_authenticated_bootstrap_manifest( diff --git a/dir.c b/dir.c index 9634d2b900323b..8c9e26d295d447 100644 --- a/dir.c +++ b/dir.c @@ -2104,17 +2104,34 @@ static void clear_untracked_cache_validation(struct untracked_cache_dir *dir) int untracked_cache_preserve_for_revalidation(struct index_state *istate) { struct untracked_cache *uc = istate->untracked; + const char *builtin_suffix, *pending_suffix; + int paired, pending; if (!uc || !uc->root || !uc->root->valid || - !uc->root->valid_recursive || uc->fsmonitor_dirty_paths.len || + uc->fsmonitor_dirty_paths.len || !istate->fsmonitor_token_valid || - !istate->fsmonitor_untracked_valid || !istate->fsmonitor_untracked_extension_seen || istate->fsmonitor_untracked_extension_invalid || !istate->fsmonitor_last_update || - !istate->fsmonitor_untracked_token || - strcmp(istate->fsmonitor_last_update, - istate->fsmonitor_untracked_token)) + !istate->fsmonitor_untracked_token) + return 0; + + paired = istate->fsmonitor_untracked_valid && + uc->root->valid_recursive && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token); + pending = !istate->fsmonitor_untracked_valid && + istate == istate->repo->index && + !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + skip_prefix(istate->fsmonitor_last_update, + "builtin:", &builtin_suffix) && + *builtin_suffix && strcmp(builtin_suffix, "fake") && + skip_prefix(istate->fsmonitor_untracked_token, + "pending:", &pending_suffix) && + !strcmp(builtin_suffix, pending_suffix); + if (!paired && !pending) return 0; /* @@ -2126,13 +2143,15 @@ int untracked_cache_preserve_for_revalidation(struct index_state *istate) clear_untracked_cache_validation(uc->root); uc->use_fsmonitor = 0; uc->fsmonitor_revalidation = 1; - trace2_data_intmax("fsmonitor", istate->repo, - "untracked/provider-reset-preserved", 1); + if (paired || istate->fsmonitor_untracked_revalidation_authenticated) + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/provider-reset-preserved", 1); return 1; } void untracked_cache_invalidate_all(struct index_state *istate) { + istate->fsmonitor_untracked_revalidation_authenticated = 0; if (!istate->untracked || !istate->untracked->root) return; invalidate_gitignore(istate->untracked, istate->untracked->root); diff --git a/fsmonitor.c b/fsmonitor.c index e63ea68691f8a0..1d2420f20ba22c 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -246,11 +246,24 @@ int read_fsmonitor_untracked_extension(struct index_state *istate, void write_fsmonitor_untracked_extension(struct strbuf *sb, struct index_state *istate) { + const char *suffix; uint32_t version; put_be32(&version, FSMONITOR_UNTRACKED_EXTENSION_VERSION); strbuf_add(sb, &version, sizeof(version)); - strbuf_addstr(sb, istate->fsmonitor_last_update); + if (!istate->fsmonitor_untracked_valid && istate->untracked && + istate->untracked->fsmonitor_revalidation) { + if (!skip_prefix(istate->fsmonitor_last_update, + "builtin:", &suffix) || + !*suffix || !strcmp(suffix, "fake")) + BUG("cannot serialize unauthenticated fsmonitor cache"); + strbuf_addstr(sb, "pending:"); + strbuf_addstr(sb, suffix); + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/provider-reset-pending", 1); + } else { + strbuf_addstr(sb, istate->fsmonitor_last_update); + } strbuf_addch(sb, '\0'); } @@ -267,6 +280,9 @@ void prepare_fsmonitor_untracked(struct index_state *istate) if (istate->fsmonitor_untracked_valid) untracked_cache_recompute_fsmonitor_valid_recursive( istate->untracked); + else if (istate->fsmonitor_untracked_token && + starts_with(istate->fsmonitor_untracked_token, "pending:")) + untracked_cache_preserve_for_revalidation(istate); } static struct ewah_bitmap *fsmonitor_bitmap_from_index( @@ -1083,6 +1099,7 @@ static void invalidate_all_fsmonitor(struct index_state *istate) unsigned int i; int changed = 0; + istate->fsmonitor_untracked_revalidation_authenticated = 0; for (i = 0; i < istate->cache_nr; i++) { if (istate->cache[i]->ce_flags & CE_FSMONITOR_VALID) changed = 1; @@ -1160,6 +1177,8 @@ static void invalidate_fsmonitor_for_bootstrap( if (physical_history_unavailable) { int authenticated_manifest = clean_status_has_authenticated_worktree_manifest(istate); + int pending_revalidation = + istate->fsmonitor_untracked_revalidation_authenticated; int preserve_untracked = 0; if (istate->fsmonitor_legacy_untracked_fallback) { @@ -1169,19 +1188,30 @@ static void invalidate_fsmonitor_for_bootstrap( return; } manifest_refresh_failed = - !clean_status_has_authenticated_bootstrap_manifest(istate) && + (pending_revalidation || + !clean_status_has_authenticated_bootstrap_manifest(istate)) && clean_status_refresh_worktree_manifest(istate) < 0; if (provider_query_success && !manifest_refresh_failed && !clean_status_manifest_global_fallback(istate) && !clean_status_fsmonitor_strong_mismatch(istate) && !clean_status_filter_scope_needs_validation(istate) && + (!pending_revalidation || + (istate->fsmonitor_untracked_revalidation_authenticated && + istate->untracked && istate->untracked->root && + istate->untracked->root->valid && + clean_status_pending_revalidation_manifest_unchanged( + istate))) && istate->repo->config_values_private_.trust_ctime && istate->repo->config_values_private_.check_stat) { /* Strong stat identity survives a lost provider boundary. */ - if (authenticated_manifest && - !clean_status_fsmonitor_config_mismatch(istate)) + if ((authenticated_manifest && + !clean_status_fsmonitor_config_mismatch(istate)) || + pending_revalidation) preserve_untracked = untracked_cache_preserve_for_revalidation(istate); + if (pending_revalidation && preserve_untracked) + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/provider-reset-resumed", 1); clean_status_begin_fsmonitor_semantic_baseline(istate); invalidate_all_fsmonitor_for_baseline(istate); trace2_data_intmax("fsmonitor", istate->repo, @@ -1620,6 +1650,7 @@ void fsmonitor_accept_pending_token(struct index_state *istate, BUG("valid untracked cache without a complete proof"); if (!fsmonitor_pending_token_from_provider(istate)) return; + istate->fsmonitor_untracked_revalidation_authenticated = 0; FREE_AND_NULL(istate->fsmonitor_last_update); istate->fsmonitor_last_update = istate->fsmonitor_last_update_pending; istate->fsmonitor_last_update_pending = NULL; @@ -1742,6 +1773,7 @@ void remove_fsmonitor(struct index_state *istate) { istate->fsmonitor_token_valid = 0; istate->fsmonitor_untracked_valid = 0; + istate->fsmonitor_untracked_revalidation_authenticated = 0; FREE_AND_NULL(istate->fsmonitor_last_update_pending); istate->fsmonitor_pending_token_from_provider = 0; FREE_AND_NULL(istate->fsmonitor_untracked_token); diff --git a/read-cache-ll.h b/read-cache-ll.h index 8d637a558cb717..409e4a9ca23c44 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -194,6 +194,7 @@ struct index_state { fsmonitor_untracked_must_persist : 1, fsmonitor_untracked_extension_seen : 1, fsmonitor_untracked_extension_invalid : 1, + fsmonitor_untracked_revalidation_authenticated : 1, fsmonitor_legacy_untracked_adopted : 1, fsmonitor_legacy_untracked_fallback : 1, fsmonitor_pending_token_from_provider : 1, diff --git a/read-cache.c b/read-cache.c index 985cadedce6f25..200a4c1b43b1cb 100644 --- a/read-cache.c +++ b/read-cache.c @@ -146,6 +146,11 @@ static void set_index_entry(struct index_state *istate, int nr, struct cache_ent static void replace_index_entry(struct index_state *istate, int nr, struct cache_entry *ce) { struct cache_entry *old = istate->cache[nr]; + int preserve_untracked = istate->untracked && + istate->untracked->fsmonitor_revalidation && + istate->untracked->root && istate->untracked->root->valid && + S_ISREG(old->ce_mode) && S_ISREG(ce->ce_mode) && + clean_status_index_entry_is_semantically_safe(istate, old, ce); replace_index_entry_in_base(istate, old, ce); remove_name_hash(istate, old); @@ -153,7 +158,10 @@ static void replace_index_entry(struct index_state *istate, int nr, struct cache ce->ce_flags &= ~CE_HASHED; set_index_entry(istate, nr, ce); ce->ce_flags |= CE_UPDATE_IN_BASE; - mark_fsmonitor_invalid(istate, ce); + if (preserve_untracked) + ce->ce_flags &= ~CE_FSMONITOR_VALID; + else + mark_fsmonitor_invalid(istate, ce); istate->cache_changed |= CE_ENTRY_CHANGED; } @@ -3055,6 +3063,37 @@ enum write_extensions { }; #define WRITE_ALL_EXTENSIONS ((enum write_extensions)-1) +static int fsmonitor_can_persist_untracked_revalidation( + const struct index_state *istate) +{ + return istate->untracked && istate->untracked->root && + istate->untracked->root->valid && + istate->untracked->fsmonitor_revalidation && + istate->fsmonitor_token_valid && + istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + istate->fsmonitor_last_update && + starts_with(istate->fsmonitor_last_update, "builtin:") && + istate->fsmonitor_last_update[strlen("builtin:")] && + strcmp(istate->fsmonitor_last_update, "builtin:fake") && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token) && + !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && + istate == istate->repo->index && + !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + !(istate->cache_changed & + (CE_ENTRY_ADDED | CE_ENTRY_REMOVED)) && + istate->repo->config_values_private_.trust_ctime && + istate->repo->config_values_private_.check_stat && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + clean_status_fsmonitor_semantic_baseline_pending(istate); +} + /* * On success, `tempfile` is closed. If it is the temporary file * of a `struct lock_file`, we will therefore effectively perform @@ -3306,7 +3345,8 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, if (write_extensions & WRITE_FSMONITOR_EXTENSION && istate->untracked && istate->fsmonitor_last_update && - istate->fsmonitor_untracked_valid && + (istate->fsmonitor_untracked_valid || + fsmonitor_can_persist_untracked_revalidation(istate)) && !istate->fsmonitor_legacy_untracked_fallback) { strbuf_reset(&sb); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 764e7b91b2a9bb..224af64f261a12 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -799,6 +799,128 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'expired add preserves untracked candidates until revalidation' ' + test_when_finished "rm -rf pending-untracked-revalidation" && + test_create_repo pending-untracked-revalidation && + ( + cd pending-untracked-revalidation && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep sibling && + test_write_lines base >cached/deep/tracked && + test_write_lines sibling >sibling/tracked && + git add cached/deep/tracked sibling/tracked && + git commit -m base && + test_write_lines visible >root-visible-unique && + test_write_lines nested >cached/deep/nested-visible-unique && + test_write_lines sibling >sibling/sibling-visible-unique && + git config filter.inactive.clean cat && + git config filter.inactive.process cat && + git config filter.hostile.clean "tr a-z A-Z" && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime =-60 cached/deep cached sibling . && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/prime.trace && + test_grep FSUC .git/index && + test_grep root-visible-unique .git/index && + test_grep nested-visible-unique .git/index && + test_grep sibling-visible-unique .git/index && + + test_write_lines changed >cached/deep/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=T \ + GIT_TRACE2_EVENT="$PWD/.git/add.trace" \ + git add cached/deep/tracked && + test_trace2_data fsmonitor \ + untracked/provider-reset-preserved 1 <.git/add.trace && + test_trace2_data fsmonitor \ + untracked/provider-reset-pending 1 <.git/add.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_grep "pending:" .git/index && + test_grep root-visible-unique .git/index && + test_grep nested-visible-unique .git/index && + test_grep sibling-visible-unique .git/index && + + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + test_grep "^1 M\\. .* cached/deep/tracked$" .git/expect && + test_grep "^? root-visible-unique$" .git/expect && + test_grep "^? cached/deep/nested-visible-unique$" .git/expect && + if test -n "${GIT_TEST_FSMONITOR_LEGACY-}" && + test -x "$GIT_TEST_FSMONITOR_LEGACY" + then + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + "$GIT_TEST_FSMONITOR_LEGACY" \ + status --porcelain=v2 >.git/legacy && + test_cmp .git/expect .git/legacy + else + : + fi && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/revalidated.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data status \ + untracked/provider-reset-preload 1 \ + <.git/revalidated.trace && + test_trace2_data dir preload_untracked_cache/valid 1 \ + <.git/revalidated.trace && + test_trace2_data read_directory opendir 0 \ + <.git/revalidated.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/revalidated.trace && + test_trace2_data fsmonitor \ + untracked/provider-reset-resumed 1 \ + <.git/revalidated.trace && + test_grep FSUC .git/index && + test_grep ! "pending:" .git/index && + + cp .git/index .git/pending-alternate.index && + test_write_lines alternate >cached/deep/tracked && + GIT_INDEX_FILE="$PWD/.git/pending-alternate.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=T \ + GIT_TRACE2_EVENT="$PWD/.git/alternate.trace" \ + git add cached/deep/tracked && + test_grep ! "pending:" .git/pending-alternate.index && + test_grep FSUC .git/index && + test_grep ! "pending:" .git/index && + ! test_trace2_data fsmonitor untracked/provider-reset-pending 1 \ + <.git/alternate.trace && + + test_write_lines changed-again >cached/deep/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=T \ + git add cached/deep/tracked && + test_grep "pending:" .git/index && + test_write_lines "tracked filter=hostile" \ + >cached/deep/.gitattributes && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/hostile.expect && + test_grep "^1 MM .* cached/deep/tracked$" .git/hostile.expect && + test_grep "^? cached/deep/.gitattributes$" .git/hostile.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/hostile.trace" \ + git status --porcelain=v2 >.git/hostile.actual && + test_cmp .git/hostile.expect .git/hostile.actual && + ! test_trace2_data status untracked/provider-reset-preload 1 \ + <.git/hostile.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'scoped provider resets validate the complete cache in parallel' ' test_when_finished "rm -rf builtin-reset-scoped" && From d8abc88d0c2e998aa2dfceb47bd124ddbc361b32 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 15:01:13 -0500 Subject: [PATCH 298/403] fsmonitor: authenticate explicit worktrees for pending state The status2 daemon runs Git with an explicit GIT_WORK_TREE pointing at its repository. Rejecting every worktree override therefore prevents a pending untracked-cache proof from being resumed by its primary reader, forcing repeated full directory scans instead. Do not compare the override with repo_get_work_tree(): repository setup initializes that value from the override itself. Authenticate the effective worktree against the location recorded in the existing UNTR identity before preserving pending directory candidates instead. Keep alternate indexes, alternate object stores, and explicit common directories excluded. Cover a matching read-only status2 worktree, a different hostile worktree, and physical-index immutability. Signed-off-by: Taylor Blau --- clean-status-history.c | 1 - dir.c | 4 ++++ t/t7519-status-fsmonitor.sh | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index fada018843f4de..0cac4500c4db50 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -157,7 +157,6 @@ static int prepare_fsmonitor_config(struct index_state *istate, int trace) (FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | FSMONITOR_CLEAN_PROOF_FULL_INDEX) && !getenv(INDEX_ENVIRONMENT) && - !getenv(GIT_WORK_TREE_ENVIRONMENT) && !getenv(GIT_COMMON_DIR_ENVIRONMENT) && !getenv(ALTERNATE_DB_ENVIRONMENT) && istate == istate->repo->index && !istate->split_index && diff --git a/dir.c b/dir.c index 8c9e26d295d447..4ef901ca2d66bf 100644 --- a/dir.c +++ b/dir.c @@ -2101,6 +2101,8 @@ static void clear_untracked_cache_validation(struct untracked_cache_dir *dir) clear_untracked_cache_validation(dir->dirs[i]); } +static int ident_in_untracked(const struct untracked_cache *uc); + int untracked_cache_preserve_for_revalidation(struct index_state *istate) { struct untracked_cache *uc = istate->untracked; @@ -2125,6 +2127,8 @@ int untracked_cache_preserve_for_revalidation(struct index_state *istate) !istate->split_index && istate->sparse_index == INDEX_EXPANDED && fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + (!getenv(GIT_WORK_TREE_ENVIRONMENT) || + ident_in_untracked(uc)) && skip_prefix(istate->fsmonitor_last_update, "builtin:", &builtin_suffix) && *builtin_suffix && strcmp(builtin_suffix, "fake") && diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 224af64f261a12..5e871ce97f290b 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -801,7 +801,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'expired add preserves untracked candidates until revalidation' ' - test_when_finished "rm -rf pending-untracked-revalidation" && + test_when_finished "rm -rf pending-untracked-revalidation pending-untracked-hostile" && test_create_repo pending-untracked-revalidation && ( cd pending-untracked-revalidation && @@ -866,6 +866,38 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ : fi && + cp .git/index .git/worktree-pending.index && + GIT_WORK_TREE="$PWD" \ + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ + GIT_TRACE2_EVENT="$PWD/.git/worktree.trace" \ + git status --porcelain=v2 >.git/worktree.actual && + test_cmp .git/expect .git/worktree.actual && + test_trace2_data fsmonitor \ + untracked/provider-reset-resumed 1 <.git/worktree.trace && + test_trace2_data read_directory opendir 0 <.git/worktree.trace && + test_cmp .git/worktree-pending.index .git/index && + test_grep "pending:" .git/index && + + mkdir ../pending-untracked-hostile && + GIT_WORK_TREE="$PWD/../pending-untracked-hostile" \ + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/worktree-hostile.expect && + GIT_WORK_TREE="$PWD/../pending-untracked-hostile" \ + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/worktree-hostile.trace" \ + git status --porcelain=v2 >.git/worktree-hostile.actual && + test_cmp .git/worktree-hostile.expect \ + .git/worktree-hostile.actual && + ! test_trace2_data fsmonitor \ + untracked/provider-reset-resumed 1 \ + <.git/worktree-hostile.trace && + test_cmp .git/worktree-pending.index .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ GIT_TEST_UNTRACKED_CACHE_AUTO_PRELOAD=1 \ GIT_TEST_UNTRACKED_CACHE_THREADS=4 \ From 062e636055e94838993962231cfb2612b4ab4abf Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 15:08:35 -0500 Subject: [PATCH 299/403] status: preserve proofs across guarded Codex invocations Codex protects Git commands with command-scoped safe.bareRepository, core.hooksPath, and redundant core.fsmonitor settings. It also disables LFS using a complete four-part filter override. Including those guards in the clean-status configuration fingerprint makes otherwise identical commands reject their existing worktree proof. On an 858,001-entry checkout, the resulting mismatch turns guarded tracked status into an 8.46-second scan, untracked status into an 18.22-second scan, and temporary-index staging into a 54.66-second refresh of every tracked entry. Exclude only the exact command-scoped protection values and redundant boolean fsmonitor settings. Buffer disabled-filter entries per driver and omit them only after receiving the complete contiguous bundle of empty clean, smudge, and process values plus required=false. Keep every partial, duplicated, interleaved, unsafe, or persistent override in the proof, and preserve the existing active-filter path checks. Cover filtered-path staging, standalone required=false, reverse filter priming, all incomplete override subsets, both object hash algorithms, and unsafe fsmonitor helper transitions. Signed-off-by: Taylor Blau --- clean-status-config.c | 184 +++++++++++++++++++++++++-- clean-status-config.h | 5 + t/t7527-builtin-fsmonitor.sh | 144 +++++++++++++++++++++ t/unit-tests/u-clean-status-config.c | 144 +++++++++++++++++++++ 4 files changed, 465 insertions(+), 12 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index 076d56651350ed..c5ba5dc4ec5da7 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -5,6 +5,7 @@ #include "config.h" #include "environment.h" #include "hash-framing.h" +#include "parse.h" #include "path-namespace.h" #include "read-cache-ll.h" #include "repository.h" @@ -14,6 +15,29 @@ #define CLEAN_STATUS_FILTER_PROOF_DOMAIN \ "clean-status-configured-filter-scope-v1" +#define CLEAN_STATUS_FILTER_CLEAN (1U << 0) +#define CLEAN_STATUS_FILTER_SMUDGE (1U << 1) +#define CLEAN_STATUS_FILTER_PROCESS (1U << 2) +#define CLEAN_STATUS_FILTER_REQUIRED (1U << 3) +#define CLEAN_STATUS_FILTER_COMPLETE \ + (CLEAN_STATUS_FILTER_CLEAN | CLEAN_STATUS_FILTER_SMUDGE | \ + CLEAN_STATUS_FILTER_PROCESS | CLEAN_STATUS_FILTER_REQUIRED) + +struct clean_status_pending_filter_entry { + char *key; + char *value; + char *filename; + enum config_scope scope; + enum config_origin_type origin_type; +}; + +struct clean_status_pending_filter { + char *driver; + struct clean_status_pending_filter_entry entries[4]; + unsigned nr; + unsigned mask; +}; + void clean_status_config_init(struct clean_status_config_digest *digest, const struct git_hash_algo *algo) { @@ -95,6 +119,59 @@ static int config_is_command_empty_attributes(const char *key, !digest->attribute_tree_configured)); } +static int config_is_command_status_guard( + const char *key, const char *value, + const struct config_context *ctx, + struct clean_status_config_digest *digest) +{ + int command = ctx && ctx->kvi && + ctx->kvi->scope == CONFIG_SCOPE_COMMAND; + + if (!strcmp(key, "core.fsmonitor")) { + int boolean = git_parse_maybe_bool(value); + int redundant = command && boolean >= 0 && + digest->fsmonitor_value_seen && + digest->fsmonitor_value_boolean && + !!boolean == !!digest->fsmonitor_value_enabled; + + digest->fsmonitor_value_seen = 1; + digest->fsmonitor_value_boolean = boolean >= 0; + digest->fsmonitor_value_enabled = boolean > 0; + return redundant; + } + + return command && value && + ((!strcmp(key, "safe.barerepository") && + !strcmp(value, "explicit")) || + (!strcmp(key, "core.hookspath") && + !strcmp(value, "/dev/null"))); +} + +static unsigned config_command_disabled_filter_part( + const char *key, const char *value, + const struct config_context *ctx, + const char **driver, size_t *driver_len) +{ + const char *subkey; + + if (!ctx || !ctx->kvi || ctx->kvi->scope != CONFIG_SCOPE_COMMAND || + !value || parse_config_key(key, "filter", driver, driver_len, + &subkey) || !*driver || !*driver_len) + return 0; + if (!strcmp(subkey, "required")) + return git_parse_maybe_bool(value) == 0 ? + CLEAN_STATUS_FILTER_REQUIRED : 0; + if (*value) + return 0; + if (!strcmp(subkey, "clean")) + return CLEAN_STATUS_FILTER_CLEAN; + if (!strcmp(subkey, "smudge")) + return CLEAN_STATUS_FILTER_SMUDGE; + if (!strcmp(subkey, "process")) + return CLEAN_STATUS_FILTER_PROCESS; + return 0; +} + static int config_is_tracked_policy(const char *key) { return !strcmp(key, "core.filemode") || @@ -112,22 +189,14 @@ static int config_is_tracked_policy(const char *key) !strcmp(key, "core.attributesfile"); } -void clean_status_config_add(struct clean_status_config_digest *digest, - const char *key, const char *value, - const struct config_context *ctx) +static void hash_retained_config_entry( + struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx) { const char *suffix; int semantic; - if (!digest->initialized || digest->finalized) - BUG("invalid clean-status config digest state"); - if (!strcmp(key, "attr.tree") && value && *value) - digest->attribute_tree_configured = 1; - /* Independent attribute fingerprints guard empty source overrides. */ - if (config_is_command_transport(key, ctx) || - config_is_command_acceleration(key, ctx) || - config_is_command_empty_attributes(key, value, ctx, digest)) - return; hash_config_entry(&digest->ctx, key, value, ctx); if (config_is_tracked_policy(key)) hash_effective_config_entry(&digest->tracked_policy_ctx, @@ -147,10 +216,101 @@ void clean_status_config_add(struct clean_status_config_digest *digest, } } +static void flush_pending_filter(struct clean_status_config_digest *digest) +{ + struct clean_status_pending_filter *pending = digest->pending_filter; + + if (!pending) + return; + for (unsigned i = 0; i < pending->nr; i++) { + struct clean_status_pending_filter_entry *entry = + &pending->entries[i]; + + if (pending->mask != CLEAN_STATUS_FILTER_COMPLETE) { + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + + kvi.scope = entry->scope; + kvi.origin_type = entry->origin_type; + kvi.filename = entry->filename; + hash_retained_config_entry(digest, entry->key, + entry->value, &ctx); + } + free(entry->key); + free(entry->value); + free(entry->filename); + } + free(pending->driver); + free(pending); + digest->pending_filter = NULL; +} + +static void queue_disabled_filter_part( + struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx, + const char *driver, size_t driver_len, unsigned part) +{ + struct clean_status_pending_filter *pending = digest->pending_filter; + struct clean_status_pending_filter_entry *entry; + + if (pending && + (strlen(pending->driver) != driver_len || + memcmp(pending->driver, driver, driver_len) || + (pending->mask & part))) { + flush_pending_filter(digest); + pending = NULL; + } + if (!pending) { + CALLOC_ARRAY(pending, 1); + pending->driver = xstrndup(driver, driver_len); + digest->pending_filter = pending; + } + entry = &pending->entries[pending->nr++]; + entry->key = xstrdup(key); + entry->value = xstrdup(value); + entry->filename = xstrdup_or_null(ctx->kvi->filename); + entry->scope = ctx->kvi->scope; + entry->origin_type = ctx->kvi->origin_type; + pending->mask |= part; + if (pending->mask == CLEAN_STATUS_FILTER_COMPLETE) + flush_pending_filter(digest); +} + +void clean_status_config_add(struct clean_status_config_digest *digest, + const char *key, const char *value, + const struct config_context *ctx) +{ + const char *driver = NULL; + size_t driver_len = 0; + unsigned filter_part; + + if (!digest->initialized || digest->finalized) + BUG("invalid clean-status config digest state"); + filter_part = config_command_disabled_filter_part( + key, value, ctx, &driver, &driver_len); + if (filter_part) { + queue_disabled_filter_part(digest, key, value, ctx, + driver, driver_len, filter_part); + return; + } + flush_pending_filter(digest); + if (!strcmp(key, "attr.tree") && value && *value) + digest->attribute_tree_configured = 1; + /* Independent attribute fingerprints guard empty source overrides. */ + if (config_is_command_transport(key, ctx) || + config_is_command_acceleration(key, ctx) || + config_is_command_empty_attributes(key, value, ctx, digest) || + config_is_command_status_guard(key, value, ctx, digest)) + return; + hash_retained_config_entry(digest, key, value, ctx); +} + void clean_status_config_final(struct clean_status_config_digest *digest) { if (!digest->initialized || digest->finalized) BUG("invalid clean-status config digest state"); + flush_pending_filter(digest); if (digest->filter_configured) { /* * Leave repositories without configured clean filters in their diff --git a/clean-status-config.h b/clean-status-config.h index aa26a009d02612..7e6fe8189a61a3 100644 --- a/clean-status-config.h +++ b/clean-status-config.h @@ -6,6 +6,7 @@ struct config_context; struct index_state; struct repository; +struct clean_status_pending_filter; struct clean_status_config_digest { struct git_hash_ctx ctx; @@ -14,11 +15,15 @@ struct clean_status_config_digest { unsigned char hash[GIT_MAX_RAWSZ]; unsigned char semantic_hash[GIT_MAX_RAWSZ]; unsigned char tracked_policy_hash[GIT_MAX_RAWSZ]; + struct clean_status_pending_filter *pending_filter; unsigned initialized : 1; unsigned finalized : 1; unsigned filter_configured : 1; unsigned semantic_config_explicit : 1; unsigned attribute_tree_configured : 1; + unsigned fsmonitor_value_seen : 1; + unsigned fsmonitor_value_boolean : 1; + unsigned fsmonitor_value_enabled : 1; }; void clean_status_config_init(struct clean_status_config_digest *digest, diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index c2d6a533db08d2..b2a61c64c3a9fb 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -6498,6 +6498,24 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ! test_trace2_data index refresh/sum_lstat \ "[1-9][0-9]*" <.git/warm-filter-scope.trace && + cp .git/index .git/disabled-filter.index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/disabled-filter.trace" \ + git -c filter.demo.clean= \ + -c filter.demo.smudge= \ + -c filter.demo.process= \ + -c filter.demo.required=false \ + status --porcelain=v2 --untracked-files=no \ + >.git/disabled-filter.out && + test_must_be_empty .git/disabled-filter.out && + test_cmp .git/disabled-filter.index .git/index && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/disabled-filter.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <.git/disabled-filter.trace && + test_write_lines "tracked filter=demo" >.git/info/attributes && GIT_TEST_PRELOAD_INDEX=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ @@ -6516,6 +6534,132 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'disabled filters cannot publish a proof for active filtered paths' ' + test_when_finished "rm -rf disabled-filter-active-path" && + test_create_repo disabled-filter-active-path && + ( + cd disabled-filter-active-path && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "*.filtered filter=demo" >.gitattributes && + test_write_lines base >tracked && + git config filter.demo.clean "sed s/raw/converted/" && + git config filter.demo.required true && + git add .gitattributes tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/prime.trace && + + test_write_lines raw >active.filtered && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=active.filtered \ + git -c filter.demo.clean= \ + -c filter.demo.smudge= \ + -c filter.demo.process= \ + -c filter.demo.required=false \ + add active.filtered && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=no \ + >.git/expected && + test_grep "^1 AM .* active.filtered$" .git/expected && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/actual.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/actual && + test_cmp .git/expected .git/actual + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'partial required-filter overrides cannot hide a missing clean helper' ' + test_when_finished "rm -rf partial-required-filter" && + test_create_repo partial-required-filter && + ( + cd partial-required-filter && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "*.filtered filter=demo" >.gitattributes && + test_write_lines base >tracked && + git config filter.demo.required true && + git add .gitattributes tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_trace2_data fsmonitor filter-scope/valid 1 \ + <.git/prime.trace && + + test_write_lines raw >active.filtered && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=active.filtered \ + git -c filter.demo.required=false add active.filtered && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + test_must_fail git status --porcelain=v2 \ + --untracked-files=no >.git/actual 2>.git/error && + test_grep "clean filter .demo. failed" .git/error + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'disabled filters cannot prime a reusable proof for active clean filters' ' + test_when_finished "rm -rf disabled-filter-prime" && + test_create_repo disabled-filter-prime && + ( + cd disabled-filter-prime && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "tracked filter=demo" >.gitattributes && + test_write_lines raw >tracked && + git config filter.demo.clean "sed s/raw/converted/" && + git config filter.demo.required true && + git -c filter.demo.clean= -c filter.demo.required=false \ + add .gitattributes tracked && + git -c filter.demo.clean= -c filter.demo.required=false \ + commit -m raw && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/disabled-prime.trace" \ + git -c filter.demo.clean= \ + -c filter.demo.smudge= \ + -c filter.demo.process= \ + -c filter.demo.required=false \ + status --porcelain=v2 --untracked-files=no \ + >.git/disabled && + test_must_be_empty .git/disabled && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=no \ + >.git/expected && + test_grep "^1 \\.M .* tracked$" .git/expected && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/normal.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/actual && + test_cmp .git/expected .git/actual + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'unused configured filters preserve staged and dry-run history' ' test_when_finished "rm -rf configured-filter-staged" && diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 0807c0c0010fd0..0449c15ea643d0 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -187,6 +187,150 @@ void test_clean_status_config__command_preload_config_does_not_change_proof(void } } +void test_clean_status_config__only_complete_disabled_filters_are_normalized(void) +{ + static const char *const keys[] = { + "filter.demo.clean", "filter.demo.smudge", + "filter.demo.process", "filter.demo.required", + }; + static const char *const values[] = { "", "", "", "false" }; + static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + for (size_t a = 0; a < ARRAY_SIZE(algorithms); a++) { + const struct git_hash_algo *algo = &hash_algos[algorithms[a]]; + struct clean_status_config_digest baseline, digest; + + kvi.scope = CONFIG_SCOPE_LOCAL; + clean_status_config_init(&baseline, algo); + clean_status_config_add(&baseline, keys[0], "configured", &ctx); + clean_status_config_final(&baseline); + + for (unsigned mask = 0; mask < (1U << ARRAY_SIZE(keys)); mask++) { + clean_status_config_init(&digest, algo); + kvi.scope = CONFIG_SCOPE_LOCAL; + clean_status_config_add(&digest, keys[0], "configured", &ctx); + kvi.scope = CONFIG_SCOPE_COMMAND; + for (size_t part = 0; part < ARRAY_SIZE(keys); part++) { + if (mask & (1U << part)) + clean_status_config_add(&digest, keys[part], + values[part], &ctx); + } + clean_status_config_final(&digest); + cl_assert_equal_i(hasheq(digest.hash, baseline.hash, algo), + !mask || mask == 15); + if (mask == 15) { + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + } + } + + for (unsigned hostile = 0; hostile < 5; hostile++) { + clean_status_config_init(&digest, algo); + kvi.scope = CONFIG_SCOPE_LOCAL; + clean_status_config_add(&digest, keys[0], "configured", &ctx); + kvi.scope = CONFIG_SCOPE_COMMAND; + for (size_t part = 0; part < ARRAY_SIZE(keys); part++) { + const char *key = keys[part]; + const char *value = values[part]; + + if (hostile == 0 && part == 1) + clean_status_config_add(&digest, keys[0], "", &ctx); + if (hostile == 1 && part == 1) + clean_status_config_add(&digest, "core.hookspath", + "/dev/null", &ctx); + if (hostile == 2 && part == 2) + key = "filter.other.process"; + if (hostile == 3 && part == 0) + value = "unsafe-helper"; + if (hostile == 4 && part == 3) + value = "true"; + clean_status_config_add(&digest, key, value, &ctx); + } + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + } + } +} + +void test_clean_status_config__only_safe_command_guards_are_normalized(void) +{ + static const char *const keys[] = { + "core.hookspath", "safe.barerepository", + }; + static const char *const safe[] = { "/dev/null", "explicit" }; + static const char *const unsafe[] = { "/tmp/unsafe-hook", "all" }; + static const char *const previous[] = { + "true", "false", "true", "/tmp/hook", "true", NULL, + }; + static const char *const next[] = { + "true", "false", "false", "true", "/tmp/hook", "true", + }; + static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + for (size_t a = 0; a < ARRAY_SIZE(algorithms); a++) { + const struct git_hash_algo *algo = &hash_algos[algorithms[a]]; + struct clean_status_config_digest baseline, digest; + + clean_status_config_init(&baseline, algo); + clean_status_config_final(&baseline); + for (size_t guard = 0; guard < ARRAY_SIZE(keys); guard++) { + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, keys[guard], safe[guard], &ctx); + clean_status_config_final(&digest); + cl_assert(hasheq(digest.hash, baseline.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + + kvi.scope = CONFIG_SCOPE_LOCAL; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, keys[guard], safe[guard], &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, keys[guard], unsafe[guard], &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, keys[guard], safe[guard], NULL); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + } + + for (size_t state = 0; state < ARRAY_SIZE(previous); state++) { + kvi.scope = CONFIG_SCOPE_LOCAL; + clean_status_config_init(&baseline, algo); + if (previous[state]) + clean_status_config_add(&baseline, "core.fsmonitor", + previous[state], &ctx); + clean_status_config_final(&baseline); + + clean_status_config_init(&digest, algo); + if (previous[state]) + clean_status_config_add(&digest, "core.fsmonitor", + previous[state], &ctx); + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_add(&digest, "core.fsmonitor", next[state], &ctx); + clean_status_config_final(&digest); + cl_assert_equal_i(hasheq(digest.hash, baseline.hash, algo), + state < 2); + } + } +} + void test_clean_status_config__command_empty_attributes_do_not_change_proof(void) { static const enum config_scope persistent_scopes[] = { From ca4c58162ac59d6b9b9053444ff7c018dcf5f7ce Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 15:13:37 -0500 Subject: [PATCH 300/403] t7527: run failing filter probe with portable environment setup The required-filter regression invokes test_must_fail with temporary environment overrides. Prefixing shell-function calls with assignments is not portable, so the macOS shell linter rejects the entire suite before executing any tests. Use the existing test_must_fail env convention instead. This preserves the exact failing command and both environment overrides while passing the portable shell syntax checks. Signed-off-by: Taylor Blau --- t/t7527-builtin-fsmonitor.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index b2a61c64c3a9fb..4aaf8da578136b 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -6609,9 +6609,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ GIT_TEST_FSMONITOR_QUERY_PATH=active.filtered \ git -c filter.demo.required=false add active.filtered && - GIT_OPTIONAL_LOCKS=0 \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ - test_must_fail git status --porcelain=v2 \ + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 \ --untracked-files=no >.git/actual 2>.git/error && test_grep "clean filter .demo. failed" .git/error ) From 288a9d2b8f7380fe9c02f013623fa5124f65b405 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 16:15:45 -0500 Subject: [PATCH 301/403] fsmonitor: report index writes without untracked proofs An index writer can retain FSMN and UNTR while dropping their authenticated FSUC proof. This occurs during hard resets and the child reset inside stash, neither of which necessarily observes a provider reset. Emit a narrow Trace2 event for canonical IPC-backed primary index writes that cannot serialize a valid or pending proof. Cover healthy writes, reset, stash, and alternate indexes so the og wrapper can schedule exactly one trusted writable repair. --- read-cache.c | 14 +++++++ t/t7519-status-fsmonitor.sh | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/read-cache.c b/read-cache.c index 200a4c1b43b1cb..a161f720a54826 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3359,6 +3359,20 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, ret = -1; goto out; } + } else if ((write_extensions & WRITE_FSMONITOR_EXTENSION) && + (write_extensions & WRITE_UNTRACKED_CACHE_EXTENSION) && + istate == istate->repo->index && + !istate->split_index && + !getenv(INDEX_ENVIRONMENT) && + istate->untracked && + istate->fsmonitor_last_update && + starts_with(istate->fsmonitor_last_update, "builtin:") && + istate->fsmonitor_last_update[strlen("builtin:")] && + strcmp(istate->fsmonitor_last_update, "builtin:fake") && + !istate->fsmonitor_legacy_untracked_fallback && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC) { + trace2_data_intmax("fsmonitor", istate->repo, + "untracked/proof-missing", 1); } if (write_extensions & WRITE_FSCF_EXTENSION && !istate->fsmonitor_legacy_untracked_fallback && diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 5e871ce97f290b..5575536cba2aa5 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -799,6 +799,80 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'index writers report missing authenticated untracked proofs' ' + test_when_finished "rm -rf missing-untracked-proof" && + test_create_repo missing-untracked-proof && + ( + cd missing-untracked-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/healthy.trace" \ + git add tracked && + test_grep FSUC .git/index && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <.git/healthy.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git reset --hard HEAD >.git/reset.out && + test_region index do_write_index .git/reset.trace && + test_trace2_data fsmonitor untracked/proof-missing 1 \ + <.git/reset.trace && + test_grep FSMN .git/index && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/repair.trace" \ + git status --porcelain=v2 >.git/repair && + test_must_be_empty .git/repair && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + + cp .git/index .git/alternate.index && + GIT_INDEX_FILE="$PWD/.git/alternate.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/alternate.trace" \ + git reset --hard HEAD >.git/alternate.out && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <.git/alternate.trace && + test_grep FSUC .git/index && + + test_write_lines stashed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=T \ + git add tracked && + test_grep "pending:" .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCC \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 M\\. .* tracked$" .git/staged && + test_grep FSUC .git/index && + test_grep ! "pending:" .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ + git stash push -m proof-missing >.git/stash.out && + test_region index do_write_index .git/stash.trace && + test_trace2_data fsmonitor untracked/proof-missing 1 \ + <.git/stash.trace && + test_grep FSMN .git/index && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'expired add preserves untracked candidates until revalidation' ' test_when_finished "rm -rf pending-untracked-revalidation pending-untracked-hostile" && From d04b9c39483743e3e0483770d9cdb2d2ad63947a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 16:15:54 -0500 Subject: [PATCH 302/403] status: normalize disabled post-index-change hooks A trusted writable fsmonitor repair must disable configured post-index-change hooks as well as traditional hooks. Hashing that command-only false override as a semantic configuration change needlessly invalidates the authenticated status proof. Treat only command-scope hook.post-index-change.enabled=false as a harmless status guard. Preserve invalidation for true, persistent configuration, and unrelated hooks, with existing guard coverage extended for both hash algorithms. --- clean-status-config.c | 4 +++- t/unit-tests/u-clean-status-config.c | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index c5ba5dc4ec5da7..547359faee2130 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -144,7 +144,9 @@ static int config_is_command_status_guard( ((!strcmp(key, "safe.barerepository") && !strcmp(value, "explicit")) || (!strcmp(key, "core.hookspath") && - !strcmp(value, "/dev/null"))); + !strcmp(value, "/dev/null")) || + (!strcmp(key, "hook.post-index-change.enabled") && + !strcmp(value, "false"))); } static unsigned config_command_disabled_filter_part( diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 0449c15ea643d0..07065b3fbe6bac 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -261,9 +261,10 @@ void test_clean_status_config__only_safe_command_guards_are_normalized(void) { static const char *const keys[] = { "core.hookspath", "safe.barerepository", + "hook.post-index-change.enabled", }; - static const char *const safe[] = { "/dev/null", "explicit" }; - static const char *const unsafe[] = { "/tmp/unsafe-hook", "all" }; + static const char *const safe[] = { "/dev/null", "explicit", "false" }; + static const char *const unsafe[] = { "/tmp/unsafe-hook", "all", "true" }; static const char *const previous[] = { "true", "false", "true", "/tmp/hook", "true", NULL, }; From f3cb59da3069c551fe4e1dd2fb600fc6ff7ee172 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 16:34:15 -0500 Subject: [PATCH 303/403] t7519: explicitly publish the repaired index proof The breaking-changes Linux configuration can satisfy a clean full status from external history without rewriting the physical index. The new proof-loss regression then incorrectly assumes that its post-reset status has already republished FSUC. Point the repair status at the primary index explicitly, matching the existing priming step and preserving every reset, stash, healthy-writer, and alternate-index assertion. --- t/t7519-status-fsmonitor.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 5575536cba2aa5..eed49349b9977c 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -836,6 +836,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep UNTR .git/index && test_grep ! FSUC .git/index && + GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/repair.trace" \ git status --porcelain=v2 >.git/repair && From 9b689dfb57573b3ce9a0b2fdbd2444b4b7ae2ec3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 20:06:12 -0500 Subject: [PATCH 304/403] diff: close fsmonitor tokens after complete tracked refresh After a built-in fsmonitor daemon resets, its replacement token stays pending until a complete scan closes the provider epoch. A porcelain diff already refreshes and opportunistically rewrites its index after a stat-only change, but never performs that closing query. It therefore writes the obsolete token back to disk, causing later diffs to repeat a full worktree scan. Close the pending token after the existing complete tracked refresh while the optional index lock remains held. Accept it only when a second provider query reports no intervening changes and the repository can authenticate its index, stat settings, and configuration proof. Rebind that configuration proof to the new token without claiming an untracked-file proof that diff did not actually verify. Exercise daemon resets in both main and linked worktrees. Verify that a read-only diff does not modify the index and that the next writable diff needs no tracked-file stats. --- builtin/diff.c | 28 ++++++++++++++-- t/t7519-status-fsmonitor.sh | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/builtin/diff.c b/builtin/diff.c index d397463cde2b0c..666e39497e3b2d 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -17,6 +17,7 @@ #include "commit.h" #include "environment.h" #include "gettext.h" +#include "fsmonitor-ll.h" #include "fsmonitor-settings.h" #include "tag.h" #include "diff.h" @@ -242,7 +243,10 @@ static void builtin_diff_combined(struct rev_info *revs, static void refresh_index_quietly(void) { struct lock_file lock_file = LOCK_INIT; + struct index_state *istate = the_repository->index; + int can_close_token; int fd; + int refreshed; if (!use_optional_locks()) return; @@ -250,10 +254,28 @@ static void refresh_index_quietly(void) fd = repo_hold_locked_index(the_repository, &lock_file, 0); if (fd < 0) return; - discard_index(the_repository->index); + discard_index(istate); repo_read_index(the_repository); - refresh_index(the_repository->index, REFRESH_QUIET|REFRESH_UNMERGED, NULL, NULL, - NULL); + can_close_token = fstat_is_reliable() && + the_repository->config_values_private_.trust_ctime && + the_repository->config_values_private_.check_stat && + !getenv(INDEX_ENVIRONMENT) && + !istate->split_index && istate->sparse_index == INDEX_EXPANDED && + !unmerged_index(istate) && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + fsmonitor_pending_token_from_provider(istate); + refreshed = refresh_index(istate, + REFRESH_QUIET | REFRESH_UNMERGED | + (can_close_token ? REFRESH_IN_PROOF_EPOCH : 0), + NULL, NULL, NULL); + /* A complete tracked refresh cannot also authenticate untracked files. */ + if (!refreshed && can_close_token && + fsmonitor_pending_token_from_provider(istate) && + fsmonitor_query_pending_token(istate, 0) == FSMONITOR_TOKEN_CLEAN) { + clean_status_mark_fsmonitor_config_valid( + istate, istate->fsmonitor_last_update_pending); + fsmonitor_accept_pending_token(istate, 0, 0); + } repo_update_index_if_able(the_repository, &lock_file); } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index eed49349b9977c..8005cd0c26fa17 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1289,6 +1289,70 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'diff closes reset fsmonitor tokens in main and linked worktrees' ' + test_when_finished "rm -rf builtin-diff-reset builtin-diff-reset-linked" && + test_create_repo builtin-diff-reset && + ( + cd builtin-diff-reset && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + git config core.untrackedCache true && + git config core.fsmonitor true && + git -c core.fsmonitor=false worktree add --detach \ + ../builtin-diff-reset-linked HEAD && + for worktree in "$PWD" "$PWD/../builtin-diff-reset-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + test_grep FSMN "$gitdir/index" && + test_grep FSUC "$gitdir/index" && + test-tool chmtime =-60 "$worktree/tracked" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/readonly.trace" \ + git -C "$worktree" diff \ + >"$gitdir/readonly.actual" && + test_must_be_empty "$gitdir/readonly.actual" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_trace2_data fsm_client query/trivial-response 1 \ + <"$gitdir/readonly.trace" && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TTCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/reset.trace" \ + git -C "$worktree" diff \ + >"$gitdir/reset.actual" && + test_must_be_empty "$gitdir/reset.actual" && + test_trace2_data fsm_client query/trivial-response 1 \ + <"$gitdir/reset.trace" && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <"$gitdir/reset.trace" && + test_region index do_write_index "$gitdir/reset.trace" && + test_grep FSMN "$gitdir/index" && + test_grep ! FSUC "$gitdir/index" && + test_grep "builtin:test:[2-9]" "$gitdir/index" && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/next.trace" \ + git -C "$worktree" diff \ + >"$gitdir/next.actual" && + test_must_be_empty "$gitdir/next.actual" && + test_trace2_data index preload/sum_lstat 0 \ + <"$gitdir/next.trace" || return 1 + done + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin trivial closure can rescan and accept' ' test_when_finished "rm -rf builtin-closure-trivial" && From 4b4ca0e10d36c162c7fb3a2e7665805b8c17a840 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 20:34:48 -0500 Subject: [PATCH 305/403] status: replace existing proofs when restoring external history An index can already contain a valid FSCF extension when external history restores a checkpoint for a semantically equivalent index. Both proof-transfer paths decode the checkpoint proof directly into the destination, whose ordinary extension decoder correctly rejects a second FSCF record. This internal duplicate invalidates an otherwise valid proof and forces later status requests through a complete attributes and tracked-file scan. Decode replacement proofs into a temporary index state instead, then copy the validated history into the existing destination. Preserve its pinned source descriptor and identity, and continue rejecting real duplicate extensions in physical indexes. When the original index has a coherent complete FSCF proof but lacks FSUC, durably persist an authenticated untracked proof recovered from external history. The existing optional index writer then repairs a linked worktree without writing for explicit no-lock callers or for legacy indexes that deliberately lack a clean proof. Exercise both transfer paths under SHA-1 and SHA-256. Also preserve the actual stash-create proof-loss sequence and show that one trusted writable repair restores fast, read-only status while attribute changes continue to invalidate their old proof. --- clean-status-history.c | 62 +++++++++++------- t/t7519-status-fsmonitor.sh | 93 ++++++++++++++++++++++++++- t/unit-tests/u-clean-status-history.c | 84 ++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 24 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index 0cac4500c4db50..53adf902044184 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1027,14 +1027,14 @@ static int external_untracked_membership_needs_root_invalidation( static void restore_external_untracked_history( struct index_state *istate, struct index_state *witness, const struct clean_status_history_checkpoint *checkpoint, - const struct strbuf *paths, const struct fsmonitor_clean_proof *proof) + const struct strbuf *paths, const struct fsmonitor_clean_proof *proof, + int persist_recovered_proof) { struct index_state parsed = INDEX_STATE_INIT(istate->repo); const char *path = paths->buf; const char *end = paths->buf + paths->len; unsigned int old_pos = 0, new_pos = 0; unsigned int targeted_membership = 0, rooted_membership = 0; - if (istate->fsmonitor_untracked_valid || !checkpoint->untracked_cache_len || !checkpoint->fsmonitor_untracked_len) @@ -1062,6 +1062,10 @@ static void restore_external_untracked_history( istate->fsmonitor_untracked_extension_seen = 1; istate->fsmonitor_untracked_extension_invalid = 0; istate->fsmonitor_untracked_valid = 1; + if (persist_recovered_proof) { + istate->cache_changed |= UNTRACKED_CHANGED; + istate->fsmonitor_untracked_must_persist = 1; + } while (old_pos < witness->cache_nr || new_pos < istate->cache_nr) { const struct cache_entry *old_entry = old_pos < witness->cache_nr ? @@ -1135,6 +1139,10 @@ static int restore_external_semantic_history( char *path = NULL; int fd = -1, transferred = 0; int missing_current = 0, seeded_current = 0; + int persist_recovered_proof = + !istate->fsmonitor_untracked_extension_seen && state && + state->initial_coherent && + clean_status_has_persistent_fsmonitor_semantic_history(istate); if (!checkpoint->source_alias_valid || fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC) @@ -1268,7 +1276,8 @@ static int restore_external_semantic_history( ewah_each_bit(istate->fsmonitor_dirty, invalidate_unwatched_recovered_entry, istate); restore_external_untracked_history( - istate, &witness, checkpoint, &old.paths, &proof); + istate, &witness, checkpoint, &old.paths, &proof, + persist_recovered_proof); trace2_data_intmax("fsmonitor", istate->repo, "history/external-semantic-restored", 1); if (missing_current) @@ -1863,12 +1872,34 @@ static int same_persistent_index_contents(const struct index_state *a, return 1; } -int clean_status_transfer_current_proof_if_same_index( - struct index_state *dst, const struct index_state *src) +static int replace_current_fsmonitor_proof(struct index_state *dst, + const struct index_state *src) { + struct index_state replacement = INDEX_STATE_INIT(dst->repo); struct strbuf proof = STRBUF_INIT; - int transferred; + int transferred = 0; + + /* Preserve the destination's source identity and retained index fd. */ + clean_status_write_fsmonitor_config(&proof, src); + clean_status_read_fsmonitor_config(&replacement, proof.buf, proof.len); + if (replacement.clean_status && + replacement.clean_status->disk_config_valid && + !replacement.clean_status->disk_config_invalid) { + dst->fsmonitor_token_valid = src->fsmonitor_token_valid; + clean_status_copy_fsmonitor_history(dst, &replacement); + clean_status_attach_config(dst); + clean_status_prepare_fsmonitor_config(dst); + transferred = current_proof_is_writable(dst); + } + clean_status_release(&replacement); + strbuf_release(&proof); + + return transferred; +} +int clean_status_transfer_current_proof_if_same_index( + struct index_state *dst, const struct index_state *src) +{ if (!current_proof_is_writable(src) || !src->fsmonitor_last_update || !dst->fsmonitor_last_update || @@ -1881,15 +1912,7 @@ int clean_status_transfer_current_proof_if_same_index( * reattach the current command's digest. This copies only a proof * which the destination's identical logical entries can support. */ - clean_status_write_fsmonitor_config(&proof, src); - dst->fsmonitor_token_valid = src->fsmonitor_token_valid; - clean_status_read_fsmonitor_config(dst, proof.buf, proof.len); - clean_status_attach_config(dst); - clean_status_prepare_fsmonitor_config(dst); - transferred = current_proof_is_writable(dst); - strbuf_release(&proof); - - return transferred; + return replace_current_fsmonitor_proof(dst, src); } int clean_status_transfer_current_proof_if_semantically_same_index( @@ -1897,7 +1920,6 @@ int clean_status_transfer_current_proof_if_semantically_same_index( { const unsigned int semantic_flags = CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; - struct strbuf proof = STRBUF_INIT; unsigned int src_pos = 0, dst_pos = 0; int transferred; @@ -1969,16 +1991,10 @@ int clean_status_transfer_current_proof_if_semantically_same_index( return 1; } - clean_status_write_fsmonitor_config(&proof, src); - dst->fsmonitor_token_valid = src->fsmonitor_token_valid; - clean_status_read_fsmonitor_config(dst, proof.buf, proof.len); - clean_status_attach_config(dst); - clean_status_prepare_fsmonitor_config(dst); - transferred = current_proof_is_writable(dst); + transferred = replace_current_fsmonitor_proof(dst, src); if (transferred) trace2_data_intmax("fsmonitor", dst->repo, "history/semantic-transferred", 1); - strbuf_release(&proof); return transferred; } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 8005cd0c26fa17..678973c10f5ae9 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -874,6 +874,93 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'stash creation reports and repairs an unbound clean-status proof' ' + test_when_finished "rm -rf stash-unbound-proof" && + test_create_repo stash-unbound-proof && + ( + cd stash-unbound-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_write_lines staged >staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git add staged && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_grep FSCF .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ + git stash create "cmux last turn baseline" >.git/stash && + test_file_not_empty .git/stash && + test_region index do_write_index .git/stash.trace && + test_trace2_data fsmonitor untracked/proof-missing 1 \ + <.git/stash.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_grep FSCF .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/unbound.trace" \ + git status --porcelain=v2 >.git/unbound && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/unbound.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/unbound.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/repair.trace" \ + git -c core.hooksPath=/dev/null \ + -c hook.post-index-change.enabled=false \ + status --porcelain=v2 --untracked-files=normal \ + --no-ahead-behind >.git/repair && + test_cmp .git/unbound .git/repair && + test_region index do_write_index .git/repair.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_grep FSCF .git/index && + for run in first second + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$run.trace" \ + git status --porcelain=v2 >".git/$run" && + test_cmp .git/repair ".git/$run" && + test_trace2_data fsmonitor config/coherent 1 \ + <".git/$run.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <".git/$run.trace" && + test_grep ! \ + "\\\"key\\\":\\\"preload/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ + ".git/$run.trace" && + test_grep ! \ + "\\\"key\\\":\\\"refresh/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ + ".git/$run.trace" || return 1 + done && + test_write_lines "*.asset text" >.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git add .gitattributes && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git status --porcelain=v2 >.git/attributes && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/attributes.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/attributes.trace && + test_grep "^1 A\\. .* \\.gitattributes$" .git/attributes + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'expired add preserves untracked candidates until revalidation' ' test_when_finished "rm -rf pending-untracked-revalidation pending-untracked-hostile" && @@ -2597,7 +2684,8 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO test_trace2_data fsmonitor \ history/untracked-paired-new-directory-deferred 1 \ <".git/switch-$branch.trace" && - test_grep ! FSUC .git/index + test_grep ! FSUC .git/index && + test_grep FSCF .git/index else test_trace2_data fsmonitor \ history/untracked-paired-transfer 1 \ @@ -2633,6 +2721,9 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO test_trace2_data fsmonitor \ history/external-untracked-restored 1 \ <".git/status-$branch.trace" && + test_region index do_write_index \ + ".git/status-$branch.trace" && + test_grep FSUC .git/index && visited_dirs=$(sed -n \ "s/.*directories-visited:\\([0-9][0-9]*\\).*/\\1/p" \ ".git/status-$branch.perf") && diff --git a/t/unit-tests/u-clean-status-history.c b/t/unit-tests/u-clean-status-history.c index 3d196c73c8236e..49443932a637f3 100644 --- a/t/unit-tests/u-clean-status-history.c +++ b/t/unit-tests/u-clean-status-history.c @@ -101,6 +101,90 @@ void test_clean_status_history__reads_valid_history_once(void) fixture_release(&fixture); } +static void check_existing_proof_is_replaced( + const struct git_hash_algo *algo, int semantic) +{ + struct history_fixture fixture; + struct index_state dst; + struct clean_status_state *src_state, *dst_state; + struct stat st; + int source_fd; + + fixture_init(&fixture, algo); + index_state_init(&dst, &fixture.repo); + clean_status_read_fsmonitor_config( + &fixture.istate, fixture.encoded.buf, fixture.encoded.len); + src_state = install_current(&fixture); + clean_status_prepare_fsmonitor_config(&fixture.istate); + cl_assert(src_state->config_revalidated); + + clean_status_read_fsmonitor_config( + &dst, fixture.encoded.buf, fixture.encoded.len); + dst_state = dst.clean_status; + memcpy(dst_state->current_config_hash, + src_state->current_config_hash, algo->rawsz); + memcpy(dst_state->current_semantic_hash, + src_state->current_semantic_hash, algo->rawsz); + memcpy(dst_state->current_attr_hash, + src_state->current_attr_hash, algo->rawsz); + dst_state->current_config_valid = 1; + dst_state->current_semantic_valid = 1; + dst_state->current_attr_valid = 1; + dst_state->config_enforced = 1; + dst.fsmonitor_last_update = xstrdup("builtin:1:2"); + dst.fsmonitor_token_valid = 1; + clean_status_prepare_fsmonitor_config(&dst); + clean_status_invalidate_current_proof(&dst); + cl_assert(dst_state->disk_config_seen); + cl_assert(dst_state->disk_config_valid); + + source_fd = dup(1); + cl_assert(source_fd >= 0); + dst_state->source_index_fd = source_fd; + dst_state->source_logical_hash_valid = 1; + dst_state->external_history_restored = 1; + memset(dst_state->source_logical_hash, 0x5a, algo->rawsz); + + if (semantic) + cl_assert(clean_status_transfer_current_proof_if_semantically_same_index( + &dst, &fixture.istate)); + else + cl_assert(clean_status_transfer_current_proof_if_same_index( + &dst, &fixture.istate)); + cl_assert(dst.clean_status == dst_state); + cl_assert(dst_state->disk_config_seen); + cl_assert(dst_state->disk_config_valid); + cl_assert(!dst_state->disk_config_invalid); + cl_assert(dst_state->config_revalidated); + cl_assert_equal_s(dst_state->disk_config_token, "builtin:1:2"); + cl_assert_equal_i(dst_state->source_index_fd, source_fd); + cl_assert_equal_i(fstat(source_fd, &st), 0); + cl_assert(dst_state->source_logical_hash_valid); + cl_assert_equal_i(dst_state->source_logical_hash[0], 0x5a); + cl_assert(dst_state->external_history_restored); + + clean_status_read_fsmonitor_config( + &dst, fixture.encoded.buf, fixture.encoded.len); + cl_assert(dst_state->disk_config_invalid); + cl_assert(!dst_state->disk_config_valid); + + clean_status_release(&dst); + free(dst.fsmonitor_last_update); + fixture_release(&fixture); +} + +void test_clean_status_history__replaces_existing_identical_index_proofs(void) +{ + check_existing_proof_is_replaced(&hash_algos[GIT_HASH_SHA1], 0); + check_existing_proof_is_replaced(&hash_algos[GIT_HASH_SHA256], 0); +} + +void test_clean_status_history__replaces_existing_semantic_index_proofs(void) +{ + check_existing_proof_is_replaced(&hash_algos[GIT_HASH_SHA1], 1); + check_existing_proof_is_replaced(&hash_algos[GIT_HASH_SHA256], 1); +} + void test_clean_status_history__adopts_only_coherent_proofs(void) { struct history_fixture fixture; From 28dac0af162f61b8fb94fe2b6dc290dbde5c84d3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:12:52 -0500 Subject: [PATCH 306/403] stash: preserve authenticated worktree proofs during creation The create subcommand reads and rewrites the worktree index without installing the configuration digest already used by other stash writers. Its rewrite therefore downgrades a valid FSCF proof to an unbound manifest, forcing subsequent read-only status calls to rescan the whole worktree. Repeated prompt snapshots outlast the automatic repair cooldown and reproduce the slowdown immediately after a successful fix. Install the finalized stash configuration digest before create first reads the index. Existing authenticated proofs then remain bound through each ordinary primary-index rewrite. Also suppress proof-loss diagnostics when the index writer is redirected to an alternate output. Stash creation uses that path for a disposable scratch index; treating it as the primary index needlessly schedules a background repair and consumes the repair cooldown. Exercise consecutive stashes in main and linked worktrees, verify matching full FSCF/FSMN/FSUC proofs and fast immutable read-only status, and retain genuine primary proof-loss diagnostics and attribute guards. --- builtin/stash.c | 1 + read-cache.c | 1 + t/t7519-status-fsmonitor.sh | 160 ++++++++++++++++++++---------------- 3 files changed, 90 insertions(+), 72 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 898dc41007cfc1..5da3e82adc83f9 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1657,6 +1657,7 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED, strbuf_join_argv(&stash_msg_buf, argc - 1, ++argv, ' '); memset(&ps, 0, sizeof(ps)); + clean_status_set_config_digest(the_repository, &stash_clean_digest); if (!check_changes_tracked_files(&ps)) return 0; diff --git a/read-cache.c b/read-cache.c index a161f720a54826..7cda0ff5a8f9db 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3362,6 +3362,7 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, } else if ((write_extensions & WRITE_FSMONITOR_EXTENSION) && (write_extensions & WRITE_UNTRACKED_CACHE_EXTENSION) && istate == istate->repo->index && + !alternate_index_output && !istate->split_index && !getenv(INDEX_ENVIRONMENT) && istate->untracked && diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 678973c10f5ae9..a57254685e8e45 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -875,89 +875,105 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'stash creation reports and repairs an unbound clean-status proof' ' - test_when_finished "rm -rf stash-unbound-proof" && + 'repeated stash creation preserves bound worktree proofs' ' + test_when_finished "rm -rf stash-unbound-proof stash-unbound-linked" && test_create_repo stash-unbound-proof && ( cd stash-unbound-proof && sane_unset GIT_TEST_SPLIT_INDEX && test_commit base tracked && test_commit sibling sibling && + git worktree add --detach ../stash-unbound-linked HEAD && git config core.untrackedCache true && git config core.fsmonitor true && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ - git update-index --fsmonitor && - GIT_INDEX_FILE="$PWD/.git/index" \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - git status --porcelain=v2 >.git/prime && - test_must_be_empty .git/prime && - test_write_lines staged >staged && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - git add staged && - test_grep FSMN .git/index && - test_grep FSUC .git/index && - test_grep FSCF .git/index && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ - git stash create "cmux last turn baseline" >.git/stash && - test_file_not_empty .git/stash && - test_region index do_write_index .git/stash.trace && - test_trace2_data fsmonitor untracked/proof-missing 1 \ - <.git/stash.trace && - test_grep FSMN .git/index && - test_grep FSUC .git/index && - test_grep FSCF .git/index && - GIT_OPTIONAL_LOCKS=0 \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - GIT_TRACE2_EVENT="$PWD/.git/unbound.trace" \ - git status --porcelain=v2 >.git/unbound && - test_trace2_data fsmonitor config/coherent 0 \ - <.git/unbound.trace && - test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ - <.git/unbound.trace && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - GIT_TRACE2_EVENT="$PWD/.git/repair.trace" \ - git -c core.hooksPath=/dev/null \ - -c hook.post-index-change.enabled=false \ - status --porcelain=v2 --untracked-files=normal \ - --no-ahead-behind >.git/repair && - test_cmp .git/unbound .git/repair && - test_region index do_write_index .git/repair.trace && - test_grep FSMN .git/index && - test_grep FSUC .git/index && - test_grep FSCF .git/index && - for run in first second + cat >.git/check-stash-proof.pl <<-\EOF && + binmode STDIN; + local $/; + my $index = ; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + die "invalid $name token\n" if $end < 0; + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched provider tokens\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"} && + $tokens{"FSMN"} eq $tokens{"FSCF"}; + EOF + for worktree in "$PWD" "$PWD/../stash-unbound-linked" do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + test_write_lines staged >"$worktree/staged" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" add staged && + for run in first second + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/stash-$run.trace" \ + git -C "$worktree" stash create \ + "cmux last turn baseline" \ + >"$gitdir/stash-$run" && + test_file_not_empty "$gitdir/stash-$run" && + ! test_trace2_data fsmonitor \ + untracked/proof-missing 1 \ + <"$gitdir/stash-$run.trace" && + perl "$PWD/.git/check-stash-proof.pl" \ + <"$gitdir/index" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/status-$run.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status-$run" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/status-$run.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/status-$run.trace" && + test_grep ! \ + "\\\"key\\\":\\\"preload/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ + "$gitdir/status-$run.trace" && + test_grep ! \ + "\\\"key\\\":\\\"refresh/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ + "$gitdir/status-$run.trace" || return 1 + done && + test_write_lines "*.asset text" \ + >"$worktree/.gitattributes" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" add .gitattributes && GIT_OPTIONAL_LOCKS=0 \ - GIT_TEST_PRELOAD_INDEX=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - GIT_TRACE2_EVENT="$PWD/.git/$run.trace" \ - git status --porcelain=v2 >".git/$run" && - test_cmp .git/repair ".git/$run" && - test_trace2_data fsmonitor config/coherent 1 \ - <".git/$run.trace" && - ! test_trace2_data fsmonitor \ - semantic/manifest-scan-count 1 \ - <".git/$run.trace" && - test_grep ! \ - "\\\"key\\\":\\\"preload/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ - ".git/$run.trace" && - test_grep ! \ - "\\\"key\\\":\\\"refresh/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ - ".git/$run.trace" || return 1 - done && - test_write_lines "*.asset text" >.gitattributes && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - git add .gitattributes && - GIT_OPTIONAL_LOCKS=0 \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ - git status --porcelain=v2 >.git/attributes && - test_trace2_data fsmonitor config/coherent 0 \ - <.git/attributes.trace && - test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ - <.git/attributes.trace && - test_grep "^1 A\\. .* \\.gitattributes$" .git/attributes + GIT_TRACE2_EVENT="$gitdir/attributes.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/attributes" && + test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/attributes.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/attributes.trace" && + test_grep "^1 A\\. .* \\.gitattributes$" \ + "$gitdir/attributes" || return 1 + done ) ' From 184d31876d47ab14b705d9528f7ead6f466a5dc5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:22:07 -0500 Subject: [PATCH 307/403] stash: avoid rebuilding clean proofs for scratch indexes Binding the stash configuration before its first primary-index read preserves the physical worktree proof, but also applies that binding to the temporary index used while assembling the stash tree. When both staged and unstaged changes are present, the disposable index has no matching clean proof and needlessly rebuilds the complete attribute manifest. A monorepo with 1.16 million tracked paths spends 10-12 seconds and more than 100 CPU-seconds in every stash create as a result, even though the following read-only status remains fast. Temporarily clear the configuration digest only while reading the scratch index into its private index state, and restore it before handling the tree-write result. Scope this suspension to the same whole-worktree cases that already enable clean-history preservation; pathspec and untracked stash modes retain their previous behavior. Exercise staged and unstaged changes across repeated main and linked worktree stashes. Require both the stash and its subsequent read-only status to avoid full manifest scans while preserving bound proofs. --- builtin/stash.c | 15 +++++++++++---- t/t7519-status-fsmonitor.sh | 23 ++++++++++++++++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 5da3e82adc83f9..f7bf01d51bb68c 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1443,7 +1443,8 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps, return ret; } -static int stash_working_tree(struct stash_info *info, const struct pathspec *ps) +static int stash_working_tree(struct stash_info *info, const struct pathspec *ps, + int preserve_clean_history) { int ret = 0; struct rev_info rev; @@ -1487,8 +1488,13 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps goto done; } - if (write_index_as_tree(&info->w_tree, &istate, stash_index_path.buf, 0, - NULL)) { + if (preserve_clean_history) + clean_status_set_config_digest(the_repository, NULL); + ret = write_index_as_tree(&info->w_tree, &istate, stash_index_path.buf, + 0, NULL); + if (preserve_clean_history) + clean_status_set_config_digest(the_repository, &stash_clean_digest); + if (ret) { ret = -1; goto done; } @@ -1605,7 +1611,8 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b goto done; } } else { - if (stash_working_tree(info, ps)) { + if (stash_working_tree(info, ps, + !ps->nr && !include_untracked)) { if (!quiet) fprintf_ln(stderr, _("Cannot save the current " "worktree state")); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index a57254685e8e45..e57eb8aa1b5d1d 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -925,9 +925,11 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_write_lines staged >"$worktree/staged" && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ git -C "$worktree" add staged && + test_write_lines modified >"$worktree/tracked" && for run in first second do - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ GIT_TRACE2_EVENT="$gitdir/stash-$run.trace" \ git -C "$worktree" stash create \ "cmux last turn baseline" \ @@ -936,6 +938,11 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ! test_trace2_data fsmonitor \ untracked/proof-missing 1 \ <"$gitdir/stash-$run.trace" && + ! test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/stash-$run.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/stash-$run.trace" && perl "$PWD/.git/check-stash-proof.pl" \ <"$gitdir/index" && cp "$gitdir/index" "$gitdir/readonly.index" && @@ -946,17 +953,19 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git -C "$worktree" status --porcelain=v2 \ >"$gitdir/status-$run" && test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_grep "^1 A\\. .* staged$" \ + "$gitdir/status-$run" && + test_grep "^1 \\.M .* tracked$" \ + "$gitdir/status-$run" && test_trace2_data fsmonitor config/coherent 1 \ <"$gitdir/status-$run.trace" && ! test_trace2_data fsmonitor \ semantic/manifest-scan-count 1 \ <"$gitdir/status-$run.trace" && - test_grep ! \ - "\\\"key\\\":\\\"preload/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ - "$gitdir/status-$run.trace" && - test_grep ! \ - "\\\"key\\\":\\\"refresh/sum_lstat\\\",\\\"value\\\":\\\"[1-9]" \ - "$gitdir/status-$run.trace" || return 1 + test_trace2_data index preload/sum_lstat 1 \ + <"$gitdir/status-$run.trace" && + test_trace2_data index refresh/sum_lstat 1 \ + <"$gitdir/status-$run.trace" || return 1 done && test_write_lines "*.asset text" \ >"$worktree/.gitattributes" && From 5d885ffc6dd1beeaa49e2746d8df0dd8189c8e11 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:33:37 -0500 Subject: [PATCH 308/403] stash: restore authenticated history before creating a stash A linked worktree can keep its clean fsmonitor proof entirely in an authenticated external checkpoint while its physical index contains only FSMN and UNTR. Status restores that checkpoint before inspecting the worktree, but stash create installs its configuration digest without enabling the same external-history recovery. Each prompt snapshot consequently treats the linked index as unproven and rebuilds the complete attribute manifest. A real 1.05 million-entry worktree spends nearly a minute scanning 238,680 candidates on every stash even though its following status calls remain fast. Enable authenticated external history before the first create-side index read, matching the existing whole-worktree stash push path. Its existing namespace, index, attribute, provider, and optional-lock guards continue to reject unsafe or mismatched checkpoints. --- builtin/stash.c | 1 + 1 file changed, 1 insertion(+) diff --git a/builtin/stash.c b/builtin/stash.c index f7bf01d51bb68c..38712cafba13bc 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1664,6 +1664,7 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED, strbuf_join_argv(&stash_msg_buf, argc - 1, ++argv, ' '); memset(&ps, 0, sizeof(ps)); + clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &stash_clean_digest); if (!check_changes_tracked_files(&ps)) return 0; From 5f2a52ecb8c0510c3ae225baae4c8b7d75f3228a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:36:48 -0500 Subject: [PATCH 309/403] t7519: cover stash creation from linked external history A linked worktree can retain its authenticated clean proof entirely in an external checkpoint after a foreign index writer removes FSCF and FSUC. Existing stash coverage begins with both extensions present, so it misses the linked-only regression where every prompt snapshot rebuilds the entire attribute manifest. Start a real filesystem-monitor daemon, install authenticated linked history, and remove both physical proof extensions without changing the tracked index. Verify that the first staged-and-unstaged stash restores the external proof and republishes both extensions without a manifest scan. Repeat the stash and confirm read-only status remains accurate, fast, and physically immutable under both object formats. --- t/t7519-status-fsmonitor.sh | 108 ++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index e57eb8aa1b5d1d..68ecc40f5b969b 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2824,6 +2824,114 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'stash creation restores linked external clean history' ' + test_when_finished "rm -rf stash-linked-history stash-linked-worktree" && + test_when_finished \ + "git -C stash-linked-worktree fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo stash-linked-history && + ( + cd stash-linked-history && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir existing && + test_commit base existing/tracked && + test_commit sibling existing/sibling && + git worktree add --detach ../stash-linked-worktree HEAD && + worktree="$PWD/../stash-linked-worktree" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + git -C "$worktree" config core.autocrlf false && + git -C "$worktree" config core.untrackedCache true && + git -C "$worktree" config core.fsmonitor true && + git -C "$worktree" config index.recordEndOfIndexEntries false && + test-tool chmtime -120 \ + "$worktree/existing/tracked" "$worktree/existing/sibling" && + git -C "$worktree" fsmonitor--daemon start --start-timeout=10 && + git -C "$worktree" update-index --refresh && + git -C "$worktree" update-index --fsmonitor && + git -C "$worktree" status --porcelain=v2 >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + find "$gitdir" -maxdepth 1 -type f -name "index.csh1.*" \ + >"$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/checkpoints" && + find "$gitdir" -maxdepth 1 -type f -name "index.cswi.*" \ + >"$gitdir/witnesses" && + test_line_count = 1 "$gitdir/witnesses" && + test_write_lines staged >"$worktree/existing/staged" && + git -C "$worktree" add existing/staged && + GIT_INDEX_FILE="$gitdir/index" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/physical-prime" && + test_grep FSMN "$gitdir/index" && + test_grep UNTR "$gitdir/index" && + test_grep FSUC "$gitdir/index" && + test_grep FSCF "$gitdir/index" && + cat >"$gitdir/remove-proofs.pl" <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $rawsz = $ARGV[0] eq "sha256" ? 32 : 20; + for my $extension ("FSUC", "FSCF") { + my $offset = index($index, $extension); + next if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, $rawsz == 32 ? sha256($payload) : sha1($payload); + EOF + perl "$gitdir/remove-proofs.pl" "$(test_oid algo)" \ + <"$gitdir/index" >"$gitdir/index.foreign" && + mv "$gitdir/index.foreign" "$gitdir/index" && + test_grep FSMN "$gitdir/index" && + test_grep UNTR "$gitdir/index" && + test_grep ! FSUC "$gitdir/index" && + test_grep ! FSCF "$gitdir/index" && + test_write_lines dirty >"$worktree/existing/tracked" && + for run in first second + do + GIT_TRACE2_EVENT="$gitdir/stash-$run.trace" \ + git -C "$worktree" stash create \ + "cmux last turn baseline" \ + >"$gitdir/stash-$run" && + test_file_not_empty "$gitdir/stash-$run" && + ! test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/stash-$run.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/stash-$run.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/stash-$run.trace" && + if test "$run" = first + then + { + test_trace2_data fsmonitor history/external-restored 1 \ + <"$gitdir/stash-$run.trace" || + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 \ + <"$gitdir/stash-$run.trace" + } && + test_region index do_write_index \ + "$gitdir/stash-$run.trace" + fi && + test_grep FSUC "$gitdir/index" && + test_grep FSCF "$gitdir/index" && + cp "$gitdir/index" "$gitdir/index.before-status" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/status-$run.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status-$run" && + test_cmp_bin "$gitdir/index.before-status" "$gitdir/index" && + test_grep "^1 A\\. .* existing/staged$" \ + "$gitdir/status-$run" && + test_grep "^1 \\.M .* existing/tracked$" \ + "$gitdir/status-$run" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/status-$run.trace" || return 1 + done + ) +' + test_expect_success PTHREADS,UNTRACKED_CACHE,SHA1 'load cache-tree and untracked-cache extensions in parallel' ' test_create_repo parallel-extensions && ( From 53d3e44314f9384e8f9f0241d42501423df56884 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 23:38:02 -0500 Subject: [PATCH 310/403] write-tree: preserve authenticated fsmonitor proofs A cache-tree miss makes write-tree rewrite the physical index. Unlike other proof-preserving writers, it never initialized the clean-status configuration digest or restored authenticated external history. Consequently, an otherwise valid FSCF extension was rewritten without its token and stat bindings while FSMN and FSUC remained valid. Subsequent read-only status calls reject the weakened proof and rebuild the entire attribute manifest. Snapshot commands copying that index repeat the same scan, and no proof-loss event is emitted because FSUC survived the rewrite. Initialize the configuration digest and authenticated history before write-tree reads the index, using the existing default configuration callback. This preserves complete proofs for both primary and linked worktrees without weakening alternate-index, filter, or attribute checks. Cover physical cache-tree rewrites, paired provider tokens, read-only follow-up status, and hostile staged attribute changes under both object formats. --- builtin/write-tree.c | 16 +++++- t/t7519-status-fsmonitor.sh | 99 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/builtin/write-tree.c b/builtin/write-tree.c index e3bd1a40dbf389..02f1ab48ac53c6 100644 --- a/builtin/write-tree.c +++ b/builtin/write-tree.c @@ -5,6 +5,8 @@ */ #define USE_THE_REPOSITORY_VARIABLE #include "builtin.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -18,11 +20,19 @@ static const char * const write_tree_usage[] = { NULL }; +static int write_tree_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + int cmd_write_tree(int argc, const char **argv, const char *cmd_prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; int flags = 0, ret; const char *tree_prefix = NULL; struct object_id oid; @@ -44,7 +54,11 @@ int cmd_write_tree(int argc, OPT_END() }; - repo_config(the_repository, git_default_config, NULL); + clean_status_config_init(&clean_digest, the_repository->hash_algo); + repo_config(the_repository, write_tree_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); + clean_status_enable_external_history(the_repository); argc = parse_options(argc, argv, cmd_prefix, write_tree_options, write_tree_usage, 0); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 68ecc40f5b969b..c1feb4d29cb840 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -986,6 +986,105 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'write-tree preserves authenticated primary and linked index proofs' ' + test_when_finished "rm -rf write-tree-bound-proof write-tree-linked" && + test_create_repo write-tree-bound-proof && + ( + cd write-tree-bound-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + git worktree add --detach ../write-tree-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + cat >.git/check-write-tree-proof.pl <<-\EOF && + binmode STDIN; + local $/; + my $index = ; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + die "invalid $name token\n" if $end < 0; + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched provider tokens\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"} && + $tokens{"FSMN"} eq $tokens{"FSCF"}; + EOF + for worktree in "$PWD" "$PWD/../write-tree-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + test_write_lines changed >"$worktree/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git -C "$worktree" add tracked && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_grep "^1 M\\. .* tracked$" "$gitdir/prime" && + perl "$PWD/.git/check-write-tree-proof.pl" \ + <"$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/write-tree.trace" \ + git -C "$worktree" write-tree \ + >"$gitdir/tree" && + test_file_not_empty "$gitdir/tree" && + test_region index do_write_index \ + "$gitdir/write-tree.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/write-tree.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/write-tree.trace" && + perl "$PWD/.git/check-write-tree-proof.pl" \ + <"$gitdir/index" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_grep "^1 M\\. .* tracked$" "$gitdir/status" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/status.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/status.trace" && + test_write_lines "*.asset text" \ + >"$worktree/.gitattributes" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" add .gitattributes && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/attributes.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/attributes" && + test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/attributes.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/attributes.trace" && + test_grep "^1 A\\. .* \\.gitattributes$" \ + "$gitdir/attributes" || return 1 + done + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'expired add preserves untracked candidates until revalidation' ' test_when_finished "rm -rf pending-untracked-revalidation pending-untracked-hostile" && From 32095303dbc1ae590cebe3c8f5eee744c0e6c23a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 23:53:36 -0500 Subject: [PATCH 311/403] write-tree: avoid clean-proof work for temporary indexes Temporary snapshot indexes can legitimately lose clean-proof bindings when they stage new paths. Do not initialize physical-index clean-status history for an explicit GIT_INDEX_FILE; preserve the previous configuration-only behavior and avoid scanning the complete attribute manifest. Handle write-tree help before inspecting repository hash settings so -h and --help-all continue to work outside a repository. Extend the physical-index regression with a newly staged temporary path, verify its intentionally weakened proof remains isolated, and require write-tree to avoid a full manifest scan. --- builtin/write-tree.c | 18 +++++++++++++----- t/t7519-status-fsmonitor.sh | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/builtin/write-tree.c b/builtin/write-tree.c index 02f1ab48ac53c6..3a826add3459ce 100644 --- a/builtin/write-tree.c +++ b/builtin/write-tree.c @@ -54,11 +54,19 @@ int cmd_write_tree(int argc, OPT_END() }; - clean_status_config_init(&clean_digest, the_repository->hash_algo); - repo_config(the_repository, write_tree_config, &clean_digest); - clean_status_config_final(&clean_digest); - clean_status_set_config_digest(the_repository, &clean_digest); - clean_status_enable_external_history(the_repository); + show_usage_with_options_if_asked(argc, argv, + write_tree_usage, write_tree_options); + + if (!getenv(INDEX_ENVIRONMENT)) { + clean_status_config_init(&clean_digest, + the_repository->hash_algo); + repo_config(the_repository, write_tree_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); + clean_status_enable_external_history(the_repository); + } else { + repo_config(the_repository, git_default_config, NULL); + } argc = parse_options(argc, argv, cmd_prefix, write_tree_options, write_tree_usage, 0); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index c1feb4d29cb840..15beaf1b6e02c1 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1023,6 +1023,15 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ $tokens{"FSMN"} eq $tokens{"FSUC"} && $tokens{"FSMN"} eq $tokens{"FSCF"}; EOF + cat >.git/check-write-tree-unbound.pl <<-\EOF && + binmode STDIN; + local $/; + my $index = ; + my $offset = index($index, "FSCF"); + die "missing FSCF extension\n" if $offset < 0; + my $flags = unpack("N", substr($index, $offset + 16, 4)); + die "unexpected FSCF flags $flags\n" if $flags != 9; + EOF for worktree in "$PWD" "$PWD/../write-tree-linked" do gitdir=$(git -C "$worktree" \ @@ -1066,6 +1075,29 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <"$gitdir/status.trace" && ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <"$gitdir/status.trace" && + cp "$gitdir/index" "$gitdir/snapshot.index" && + test_write_lines snapshot >"$worktree/snapshot-new" && + printf "%s\n" snapshot-new | + GIT_INDEX_FILE="$gitdir/snapshot.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/snapshot-add.trace" \ + git -C "$worktree" add --sparse \ + --pathspec-from-file=- && + perl "$PWD/.git/check-write-tree-unbound.pl" \ + <"$gitdir/snapshot.index" && + GIT_INDEX_FILE="$gitdir/snapshot.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/snapshot-tree.trace" \ + git -C "$worktree" write-tree \ + >"$gitdir/snapshot-tree" && + test_file_not_empty "$gitdir/snapshot-tree" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/snapshot-tree.trace" && + git -C "$worktree" ls-tree \ + "$(cat "$gitdir/snapshot-tree")" snapshot-new \ + >"$gitdir/snapshot-entry" && + test_grep "snapshot-new$" "$gitdir/snapshot-entry" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && test_write_lines "*.asset text" \ >"$worktree/.gitattributes" && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ From 8abeda59ec029ebb428d12ad00173362bee779f4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 01:30:38 -0500 Subject: [PATCH 312/403] fsmonitor: preserve authenticated proofs across ordinary commands Ordinary index writers can discard authenticated FSUC/FSCF history even when their changes are semantically safe. This leaves status, snapshot generation, hooks, and pull/rebase chains rebuilding the complete attribute manifest on subsequent commands. Carry clean-status configuration and history through physical write-tree calls, rm/mv, mixed reset, fast-forward merge, rebase, and autostash. Authenticate every affected index entry, and do not preserve proofs for attributes, ignore rules, active filters, conflicts, sparse indexes, or genuine alternate indexes. Keep untracked snapshots explicitly pending when diff closes a restarted provider epoch, and refuse to certify stale untracked data from tracked- only status. Restore private extensions stripped by legacy writers and recognize the authenticated update-index invocation used by doctor. Treat truly distinct history-free snapshot indexes as a strongly invalidated stat-only fallback instead of rebuilding the whole manifest. Reject canonical primary-index, lockfile, symlink, and hardlink aliases. Exercise primary and linked worktrees under both object formats, native provider restarts, offline files and attributes, real pre-commit hooks, mixed writers, pull/rebase/autostash chains, doctor recovery, hostile filters, scratch indexes, and read-only index immutability. --- builtin/diff.c | 17 +- builtin/merge.c | 9 + builtin/mv.c | 47 +- builtin/pull.c | 19 +- builtin/rebase.c | 17 + builtin/reset.c | 3 +- builtin/rm.c | 35 +- builtin/stash.c | 25 +- builtin/update-index.c | 6 +- builtin/write-tree.c | 30 +- fsmonitor.c | 36 ++ merge-ort.c | 9 +- merge.c | 3 + reset.c | 6 + reset.h | 3 + sequencer.c | 3 +- t/t7519-status-fsmonitor.sh | 887 +++++++++++++++++++++++++++++++++++- wt-status.c | 1 + 18 files changed, 1136 insertions(+), 20 deletions(-) diff --git a/builtin/diff.c b/builtin/diff.c index 666e39497e3b2d..35f9a17f59f853 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -23,6 +23,7 @@ #include "diff.h" #include "diff-merges.h" #include "diffcore.h" +#include "dir.h" #include "preload-index.h" #include "read-cache-ll.h" #include "revision.h" @@ -245,6 +246,7 @@ static void refresh_index_quietly(void) struct lock_file lock_file = LOCK_INIT; struct index_state *istate = the_repository->index; int can_close_token; + int preserve_untracked; int fd; int refreshed; @@ -268,6 +270,8 @@ static void refresh_index_quietly(void) REFRESH_QUIET | REFRESH_UNMERGED | (can_close_token ? REFRESH_IN_PROOF_EPOCH : 0), NULL, NULL, NULL); + preserve_untracked = istate->untracked && + istate->untracked->fsmonitor_revalidation; /* A complete tracked refresh cannot also authenticate untracked files. */ if (!refreshed && can_close_token && fsmonitor_pending_token_from_provider(istate) && @@ -275,6 +279,17 @@ static void refresh_index_quietly(void) clean_status_mark_fsmonitor_config_valid( istate, istate->fsmonitor_last_update_pending); fsmonitor_accept_pending_token(istate, 0, 0); + if (preserve_untracked && + clean_status_revalidated_token_matches(istate)) { + /* + * Preserve directory snapshots only as candidates. Their + * pending proof requires a full untracked revalidation. + */ + istate->untracked->fsmonitor_revalidation = 1; + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + clean_status_begin_fsmonitor_semantic_baseline(istate); + } } repo_update_index_if_able(the_repository, &lock_file); } @@ -437,7 +452,7 @@ void prepare_diff_external_history(struct repository *repo) repo_config_values(repo)->apply_sparse_checkout) goto done; worktree = get_current_worktree(repo); - if (!worktree || !is_main_worktree(worktree) || + if (!worktree || clean_status_config_read_repository(repo, &digest) || digest.filter_configured) goto done; diff --git a/builtin/merge.c b/builtin/merge.c index 58d1b7bb07d90f..c7e8a31208a386 100644 --- a/builtin/merge.c +++ b/builtin/merge.c @@ -13,6 +13,8 @@ #include "abspath.h" #include "advice.h" +#include "clean-status-config.h" +#include "clean-status.h" #include "config.h" #include "editor.h" #include "environment.h" @@ -1373,6 +1375,7 @@ int cmd_merge(int argc, struct commit_list *common = NULL; const char *best_strategy = NULL, *wt_strategy = NULL; struct commit_list *remoteheads = NULL, *p; + struct clean_status_config_digest clean_digest; void *branch_to_free; int orig_argc = argc; int merge_log_config = -1; @@ -1469,6 +1472,12 @@ int cmd_merge(int argc, goto done; } + if (fast_forward != FF_NO && !getenv(INDEX_ENVIRONMENT) && + !clean_status_config_read_repository(the_repository, &clean_digest)) { + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, &clean_digest); + } + if (repo_read_index_unmerged(the_repository)) die_resolve_conflict("merge"); diff --git a/builtin/mv.c b/builtin/mv.c index a82fc97a19f6ee..150c77846a65c3 100644 --- a/builtin/mv.c +++ b/builtin/mv.c @@ -10,6 +10,8 @@ #include "builtin.h" #include "abspath.h" #include "advice.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -205,11 +207,20 @@ static int pathmap_cmp(const void *cmp_data UNUSED, return fspathcmp(e1->path, e2->path); } +static int mv_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + int cmd_mv(int argc, const char **argv, const char *prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; + int preserve_clean_history = !getenv(INDEX_ENVIRONMENT); int i, flags, gitmodules_modified = 0; int verbose = 0, show_only = 0, force = 0, ignore_errors = 0, ignore_sparse = 0; struct option builtin_mv_options[] = { @@ -240,7 +251,19 @@ int cmd_mv(int argc, int ret; struct repo_config_values *cfg = repo_config_values(the_repository); - repo_config(the_repository, git_default_config, NULL); + show_usage_with_options_if_asked(argc, argv, + builtin_mv_usage, builtin_mv_options); + + if (preserve_clean_history) { + clean_status_config_init(&clean_digest, + the_repository->hash_algo); + repo_config(the_repository, mv_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); + clean_status_enable_external_history(the_repository); + } else { + repo_config(the_repository, git_default_config, NULL); + } argc = parse_options(argc, argv, prefix, builtin_mv_options, builtin_mv_usage, 0); @@ -571,6 +594,28 @@ int cmd_mv(int argc, the_repository->index->cache[pos], &st, 0); + if (preserve_clean_history) { + struct cache_entry *old_entry = + the_repository->index->cache[pos]; + struct cache_entry *new_entry; + size_t dstlen = strlen(dst); + int safe; + + new_entry = make_empty_cache_entry( + the_repository->index, dstlen); + copy_cache_entry(new_entry, old_entry); + new_entry->ce_namelen = dstlen; + new_entry->index = 0; + memcpy(new_entry->name, dst, dstlen + 1); + safe = clean_status_index_entry_is_semantically_safe( + the_repository->index, old_entry, NULL) && + clean_status_index_entry_is_semantically_safe( + the_repository->index, NULL, new_entry); + discard_cache_entry(new_entry); + if (!safe) + clean_status_invalidate_current_proof( + the_repository->index); + } rename_index_entry_at(the_repository->index, pos, dst); if (ignore_sparse && diff --git a/builtin/pull.c b/builtin/pull.c index db3ee0aab3ed91..daaf273f0da9a5 100644 --- a/builtin/pull.c +++ b/builtin/pull.c @@ -10,6 +10,8 @@ #include "builtin.h" #include "advice.h" +#include "clean-status-config.h" +#include "clean-status.h" #include "config.h" #include "environment.h" #include "gettext.h" @@ -226,6 +228,9 @@ static enum rebase_type config_get_rebase(int *rebase_unspecified) static int git_pull_config(const char *var, const char *value, const struct config_context *ctx, void *cb) { + if (cb) + clean_status_config_add(cb, var, value, ctx); + if (!strcmp(var, "rebase.autostash")) { /* * run_rebase() also reads this option. The reason we handle it here is @@ -248,7 +253,7 @@ static int git_pull_config(const char *var, const char *value, check_trust_level = 0; } - return git_default_config(var, value, ctx, cb); + return git_default_config(var, value, ctx, NULL); } /** @@ -862,6 +867,7 @@ int cmd_pull(int argc, struct oid_array merge_heads = OID_ARRAY_INIT; struct object_id orig_head, curr_head; struct object_id rebase_fork_point; + struct clean_status_config_digest clean_digest; int rebase_unspecified = 0; int can_ff; int divergent; @@ -1015,7 +1021,16 @@ int cmd_pull(int argc, if (!getenv("GIT_REFLOG_ACTION")) set_reflog_message(argc, argv); - repo_config(the_repository, git_pull_config, NULL); + if (the_repository->gitdir && !getenv(INDEX_ENVIRONMENT)) { + clean_status_config_init(&clean_digest, + the_repository->hash_algo); + repo_config(the_repository, git_pull_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, &clean_digest); + } else { + repo_config(the_repository, git_pull_config, NULL); + } if (the_repository->gitdir) { prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; diff --git a/builtin/rebase.c b/builtin/rebase.c index 10a306310cd439..5ca096f48a20c7 100644 --- a/builtin/rebase.c +++ b/builtin/rebase.c @@ -10,6 +10,8 @@ #include "builtin.h" #include "abspath.h" +#include "clean-status-config.h" +#include "clean-status.h" #include "environment.h" #include "gettext.h" #include "hex.h" @@ -135,6 +137,8 @@ struct rebase_options { int config_autosquash; int config_rebase_merges; int config_update_refs; + struct clean_status_config_digest clean_digest; + unsigned clean_history_enabled : 1; }; #define REBASE_OPTIONS_INIT { \ @@ -795,6 +799,9 @@ static int rebase_config(const char *var, const char *value, { struct rebase_options *opts = data; + if (opts->clean_history_enabled) + clean_status_config_add(&opts->clean_digest, var, value, ctx); + if (!strcmp(var, "rebase.stat")) { if (git_config_bool(var, value)) opts->flags |= REBASE_DIFFSTAT; @@ -1261,7 +1268,17 @@ int cmd_rebase(int argc, prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; + options.clean_history_enabled = !getenv(INDEX_ENVIRONMENT); + if (options.clean_history_enabled) + clean_status_config_init(&options.clean_digest, + the_repository->hash_algo); repo_config(the_repository, rebase_config, &options); + if (options.clean_history_enabled) { + clean_status_config_final(&options.clean_digest); + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, + &options.clean_digest); + } /* options.gpg_sign_opt will be either "-S" or NULL */ gpg_sign = options.gpg_sign_opt ? "" : NULL; FREE_AND_NULL(options.gpg_sign_opt); diff --git a/builtin/reset.c b/builtin/reset.c index d4129d64aa9fc9..c7c3570b3cb81b 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -543,8 +543,7 @@ int cmd_reset(int argc, (the_repository->index->split_index || the_repository->index->sparse_index || (the_repository->index->cache_changed & - (CE_ENTRY_CHANGED | CE_ENTRY_ADDED | - RESOLVE_UNDO_CHANGED)))) + RESOLVE_UNDO_CHANGED))) clean_status_invalidate_current_proof( the_repository->index); the_repository->index->updated_skipworktree = 1; diff --git a/builtin/rm.c b/builtin/rm.c index 081d0bc3754c52..39636abb93aedd 100644 --- a/builtin/rm.c +++ b/builtin/rm.c @@ -8,6 +8,8 @@ #include "builtin.h" #include "advice.h" +#include "clean-status.h" +#include "clean-status-config.h" #include "config.h" #include "environment.h" #include "lockfile.h" @@ -262,17 +264,38 @@ static struct option builtin_rm_options[] = { OPT_END(), }; +static int rm_config(const char *key, const char *value, + const struct config_context *ctx, void *data) +{ + clean_status_config_add(data, key, value, ctx); + return git_default_config(key, value, ctx, NULL); +} + int cmd_rm(int argc, const char **argv, const char *prefix, struct repository *repo UNUSED) { + struct clean_status_config_digest clean_digest; struct lock_file lock_file = LOCK_INIT; + int preserve_clean_history = !getenv(INDEX_ENVIRONMENT); int i, ret = 0; struct pathspec pathspec; char *seen; - repo_config(the_repository, git_default_config, NULL); + show_usage_with_options_if_asked(argc, argv, + builtin_rm_usage, builtin_rm_options); + + if (preserve_clean_history) { + clean_status_config_init(&clean_digest, + the_repository->hash_algo); + repo_config(the_repository, rm_config, &clean_digest); + clean_status_config_final(&clean_digest); + clean_status_set_config_digest(the_repository, &clean_digest); + clean_status_enable_external_history(the_repository); + } else { + repo_config(the_repository, git_default_config, NULL); + } argc = parse_options(argc, argv, prefix, builtin_rm_options, builtin_rm_usage, 0); @@ -392,9 +415,19 @@ int cmd_rm(int argc, */ for (i = 0; i < list.nr; i++) { const char *path = list.entry[i].name; + int pos; if (!quiet) printf("rm '%s'\n", path); + pos = index_name_pos(the_repository->index, + path, strlen(path)); + if (preserve_clean_history && + (pos < 0 || + !clean_status_index_entry_is_semantically_safe( + the_repository->index, + the_repository->index->cache[pos], NULL))) + clean_status_invalidate_current_proof(the_repository->index); + if (remove_file_from_index(the_repository->index, path)) die(_("git rm: unable to remove %s"), path); } diff --git a/builtin/stash.c b/builtin/stash.c index 38712cafba13bc..f5bb8b37ac18c3 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -589,6 +589,7 @@ static void unstage_changes_unless_new(struct object_id *orig_tree) struct stat st; ce = the_repository->index->cache[pos]; + clean_status_invalidate_current_proof(the_repository->index); if (!lstat(ce->name, &st)) { /* Conflicting path present; relocate it */ struct strbuf new_path = STRBUF_INIT; @@ -629,6 +630,12 @@ static void unstage_changes_unless_new(struct object_id *orig_tree) &p->one->oid, p->one->path, 0, 0); + if (!clean_status_index_entry_is_semantically_safe( + the_repository->index, + pos >= 0 ? the_repository->index->cache[pos] : NULL, + ce)) + clean_status_invalidate_current_proof( + the_repository->index); add_index_entry(the_repository->index, ce, option); } } @@ -656,6 +663,12 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, struct tree *head, *merge, *merge_base; struct lock_file lock = LOCK_INIT; + if (!getenv(INDEX_ENVIRONMENT)) { + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, + &stash_clean_digest); + } + repo_read_index_preload(the_repository, NULL, 0); if (repo_refresh_and_write_index(the_repository, REFRESH_QUIET, 0, 0, NULL, NULL, NULL)) @@ -761,10 +774,14 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, */ cp.git_cmd = 1; cp.dir = prefix; - strvec_pushf(&cp.env, GIT_WORK_TREE_ENVIRONMENT"=%s", - absolute_path(repo_get_work_tree(the_repository))); - strvec_pushf(&cp.env, GIT_DIR_ENVIRONMENT"=%s", - absolute_path(repo_get_git_dir(the_repository))); + /* Keep discovered config origins stable unless discovery is overridden. */ + if (getenv(GIT_DIR_ENVIRONMENT) || + getenv(GIT_WORK_TREE_ENVIRONMENT)) { + strvec_pushf(&cp.env, GIT_WORK_TREE_ENVIRONMENT"=%s", + absolute_path(repo_get_work_tree(the_repository))); + strvec_pushf(&cp.env, GIT_DIR_ENVIRONMENT"=%s", + absolute_path(repo_get_git_dir(the_repository))); + } strvec_push(&cp.args, "status"); run_command(&cp); } diff --git a/builtin/update-index.c b/builtin/update-index.c index 66746d11352e67..1582d8ecc74b45 100644 --- a/builtin/update-index.c +++ b/builtin/update-index.c @@ -88,7 +88,11 @@ static int is_proof_preserving_rewrite(int argc, const char **argv) return (!strcmp(argv[1], "--refresh") && !strcmp(argv[2], "--force-write-index")) || (!strcmp(argv[1], "--force-write-index") && - !strcmp(argv[2], "--refresh")); + !strcmp(argv[2], "--refresh")) || + (!strcmp(argv[1], "--untracked-cache") && + !strcmp(argv[2], "--force-write-index")) || + (!strcmp(argv[1], "--force-write-index") && + !strcmp(argv[2], "--untracked-cache")); } static int is_fsmonitor_invalidation_rewrite(int argc, const char **argv) diff --git a/builtin/write-tree.c b/builtin/write-tree.c index 3a826add3459ce..338e4565c2fc29 100644 --- a/builtin/write-tree.c +++ b/builtin/write-tree.c @@ -5,12 +5,14 @@ */ #define USE_THE_REPOSITORY_VARIABLE #include "builtin.h" +#include "abspath.h" #include "clean-status.h" #include "clean-status-config.h" #include "config.h" #include "environment.h" #include "gettext.h" #include "hex.h" +#include "strbuf.h" #include "tree.h" #include "cache-tree.h" #include "parse-options.h" @@ -27,6 +29,32 @@ static int write_tree_config(const char *key, const char *value, return git_default_config(key, value, ctx, NULL); } +static int write_tree_uses_worktree_index(void) +{ + const char *index_file = getenv(INDEX_ENVIRONMENT); + struct strbuf worktree_index = STRBUF_INIT; + struct stat st; + char *expected = NULL, *actual = NULL; + int matches = 0; + + if (!index_file) + return 1; + if (lstat(index_file, &st) || S_ISLNK(st.st_mode)) + return 0; + + strbuf_addf(&worktree_index, "%s/index", + repo_get_git_dir(the_repository)); + expected = real_pathdup(worktree_index.buf, 0); + actual = real_pathdup(index_file, 0); + if (expected && actual && !strcmp(expected, actual)) + matches = 1; + + free(expected); + free(actual); + strbuf_release(&worktree_index); + return matches; +} + int cmd_write_tree(int argc, const char **argv, const char *cmd_prefix, @@ -57,7 +85,7 @@ int cmd_write_tree(int argc, show_usage_with_options_if_asked(argc, argv, write_tree_usage, write_tree_options); - if (!getenv(INDEX_ENVIRONMENT)) { + if (write_tree_uses_worktree_index()) { clean_status_config_init(&clean_digest, the_repository->hash_algo); repo_config(the_repository, write_tree_config, &clean_digest); diff --git a/fsmonitor.c b/fsmonitor.c index 1d2420f20ba22c..7026d2148d3fb0 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -2,6 +2,7 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "git-compat-util.h" +#include "abspath.h" #include "attr.h" #include "clean-status.h" #include "clean-status-manifest.h" @@ -1173,6 +1174,34 @@ static void invalidate_fsmonitor_for_bootstrap( invalidate_all_fsmonitor(istate); return; } + if (getenv(INDEX_ENVIRONMENT) && + !clean_status_has_persistent_fsmonitor_semantic_history(istate) && + !clean_status_has_worktree_manifest_history(istate)) { + char *physical = xstrfmt("%s/index", repo_get_git_dir(istate->repo)); + char *selected = real_pathdup(repo_get_index_file(istate->repo), 0); + char *canonical = real_pathdup(physical, 0); + char *physical_lock = canonical ? xstrfmt("%s.lock", canonical) : NULL; + struct stat selected_stat, physical_stat; + int temporary = selected && canonical && + fspathcmp(selected, canonical) && + fspathcmp(selected, physical_lock) && + !stat(selected, &selected_stat) && + !stat(canonical, &physical_stat) && + (selected_stat.st_dev != physical_stat.st_dev || + selected_stat.st_ino != physical_stat.st_ino); + + free(physical_lock); + free(canonical); + free(selected); + free(physical); + if (temporary) { + fsmonitor_invalidate_semantics(istate); + untracked_cache_invalidate_all(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/temporary-index-stat-fallback", 1); + return; + } + } if (physical_history_unavailable) { int authenticated_manifest = @@ -1664,6 +1693,13 @@ void fsmonitor_accept_pending_token(struct index_state *istate, trace2_data_intmax("fsmonitor", istate->repo, "untracked/provider-reset-revalidated", 1); } + if (untracked_cache_valid && + !istate->fsmonitor_untracked_extension_seen && + istate == istate->repo->index && + !getenv(INDEX_ENVIRONMENT) && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC) + istate->fsmonitor_untracked_must_persist = 1; istate->untracked->fsmonitor_revalidation = 0; istate->untracked->use_fsmonitor = !!untracked_cache_valid; } diff --git a/merge-ort.c b/merge-ort.c index c410a5d353234c..a090759bc4b0ff 100644 --- a/merge-ort.c +++ b/merge-ort.c @@ -24,6 +24,7 @@ #include "advice.h" #include "attr.h" #include "cache-tree.h" +#include "clean-status.h" #include "commit.h" #include "commit-reach.h" #include "config.h" @@ -4603,7 +4604,8 @@ static int process_entries(struct merge_options *opt, static int checkout(struct merge_options *opt, struct tree *prev, - struct tree *next) + struct tree *next, + int preserve_semantic_history) { /* Switch the index/working copy from old to new */ int ret; @@ -4629,6 +4631,9 @@ static int checkout(struct merge_options *opt, /* 2-way merge to the new branch */ unpack_opts.update = 1; unpack_opts.merge = 1; + unpack_opts.preserve_semantic_history = + preserve_semantic_history && + clean_status_revalidated_token_matches(opt->repo->index); unpack_opts.quiet = 0; /* FIXME: sequencer might want quiet? */ unpack_opts.verbose_update = (opt->verbosity > 2); unpack_opts.fn = twoway_merge; @@ -4933,7 +4938,7 @@ void merge_switch_to_result(struct merge_options *opt, assert(opt->priv == NULL); if (result->clean >= 0 && update_worktree_and_index) { trace2_region_enter("merge", "checkout", opt->repo); - if (checkout(opt, head, result->tree)) { + if (checkout(opt, head, result->tree, result->clean > 0)) { /* failure to function */ result->clean = -1; merge_finalize(opt, result); diff --git a/merge.c b/merge.c index 0f5e823e63ed5f..ac37e84ad87465 100644 --- a/merge.c +++ b/merge.c @@ -2,6 +2,7 @@ #include "git-compat-util.h" #include "gettext.h" +#include "clean-status.h" #include "hash.h" #include "hex.h" #include "lockfile.h" @@ -96,6 +97,8 @@ int checkout_fast_forward(struct repository *r, opts.update = 1; opts.verbose_update = 1; opts.merge = 1; + opts.preserve_semantic_history = + clean_status_revalidated_token_matches(r->index); opts.fn = twoway_merge; init_checkout_metadata(&opts.meta, NULL, remote, NULL); setup_unpack_trees_porcelain(&opts, "merge"); diff --git a/reset.c b/reset.c index 71254bde93fc51..6d284f80c622ef 100644 --- a/reset.c +++ b/reset.c @@ -1,5 +1,6 @@ #include "git-compat-util.h" #include "cache-tree.h" +#include "clean-status.h" #include "gettext.h" #include "hex.h" #include "lockfile.h" @@ -166,6 +167,11 @@ int reset_working_tree(struct repository *r, unpack_tree_opts.update = !dry_run; unpack_tree_opts.dry_run = dry_run; unpack_tree_opts.merge = 1; + unpack_tree_opts.preserve_semantic_history = + !dry_run && + (!reset_hard || + (opts->flags & RESET_WORKING_TREE_PRESERVE_SEMANTIC_HISTORY)) && + clean_status_revalidated_token_matches(istate); unpack_tree_opts.preserve_ignored = 0; /* FIXME: !overwrite_ignore */ init_checkout_metadata(&unpack_tree_opts.meta, switch_to_branch, oid, NULL); if (reset_hard) { diff --git a/reset.h b/reset.h index 4c992ba671c7f1..5d8a39b705ba25 100644 --- a/reset.h +++ b/reset.h @@ -30,6 +30,9 @@ enum reset_working_tree_flags { * any user-visible state. */ RESET_WORKING_TREE_DRY_RUN = (1 << 6), + + /* Preserve authenticated semantic history during an autostash reset. */ + RESET_WORKING_TREE_PRESERVE_SEMANTIC_HISTORY = (1 << 7), }; struct reset_working_tree_options { diff --git a/sequencer.c b/sequencer.c index 82ab3c536f94a2..6a8fcd1170bd98 100644 --- a/sequencer.c +++ b/sequencer.c @@ -4724,7 +4724,8 @@ static void create_autostash_internal(struct repository *r, struct child_process stash = CHILD_PROCESS_INIT; struct reset_working_tree_options ropts = { .flags = RESET_WORKING_TREE_HARD | - RESET_WORKING_TREE_UPDATE_HEAD, + RESET_WORKING_TREE_UPDATE_HEAD | + RESET_WORKING_TREE_PRESERVE_SEMANTIC_HISTORY, }; struct object_id oid; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 15beaf1b6e02c1..b384e03fe71e0d 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -598,6 +598,39 @@ prepare_builtin_closure_repo () { ) } +test_fsmonitor_pending_full_proof () { + perl - "$1" <<-\EOF + binmode STDIN; + open my $input, "<", $ARGV[0] or die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + die "invalid $name token\n" if $end < 0; + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched tracked provider token\n" unless + $tokens{"FSMN"} eq $tokens{"FSCF"}; + my ($suffix) = $tokens{"FSMN"} =~ /\Abuiltin:(.+)\z/; + die "missing provider token\n" unless defined $suffix; + die "mismatched pending untracked token\n" unless + $tokens{"FSUC"} eq "pending:$suffix"; + EOF +} + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'bare status reuses a current tracked fsmonitor proof' ' test_when_finished "rm -rf builtin-tracked-clean" && @@ -1032,6 +1065,11 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ my $flags = unpack("N", substr($index, $offset + 16, 4)); die "unexpected FSCF flags $flags\n" if $flags != 9; EOF + write_script .git/hooks/pre-commit <<-\EOF && + test -n "$GIT_INDEX_FILE" || exit 1 + git write-tree >"$HOOK_PROOF_OUTPUT" || exit 1 + perl "$HOOK_PROOF_HELPER" <"$GIT_INDEX_FILE" + EOF for worktree in "$PWD" "$PWD/../write-tree-linked" do gitdir=$(git -C "$worktree" \ @@ -1075,6 +1113,44 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <"$gitdir/status.trace" && ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <"$gitdir/status.trace" && + test_write_lines canonical >"$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=sibling \ + git -C "$worktree" add sibling && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/canonical-prime" && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/canonical-tree.trace" \ + git -C "$worktree" write-tree \ + >"$gitdir/canonical-tree" && + test_region index do_write_index \ + "$gitdir/canonical-tree.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/canonical-tree.trace" && + perl "$PWD/.git/check-write-tree-proof.pl" \ + <"$gitdir/index" && + test_write_lines hooked >"$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=sibling \ + git -C "$worktree" add sibling && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/hook-prime" && + HOOK_PROOF_HELPER="$PWD/.git/check-write-tree-proof.pl" \ + HOOK_PROOF_OUTPUT="$gitdir/hook-tree" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/hook.trace" \ + git -C "$worktree" commit -qm hooked && + test_file_not_empty "$gitdir/hook-tree" && + test_grep "\"name\":\"write-tree\"" \ + "$gitdir/hook.trace" && + perl "$PWD/.git/check-write-tree-proof.pl" \ + <"$gitdir/index" && + cp "$gitdir/index" "$gitdir/readonly.index" && cp "$gitdir/index" "$gitdir/snapshot.index" && test_write_lines snapshot >"$worktree/snapshot-new" && printf "%s\n" snapshot-new | @@ -1098,6 +1174,37 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ >"$gitdir/snapshot-entry" && test_grep "snapshot-new$" "$gitdir/snapshot-entry" && test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + GIT_INDEX_FILE="$gitdir/manifestless.index" \ + git -C "$worktree" read-tree HEAD && + test_grep ! FSCF "$gitdir/manifestless.index" && + test_write_lines manifestless \ + >"$worktree/manifestless-new" && + printf "%s\n" manifestless-new | + GIT_INDEX_FILE="$gitdir/manifestless.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/manifestless-add.trace" \ + git -C "$worktree" add --sparse \ + --pathspec-from-file=- && + test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/manifestless-add.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/manifestless-add.trace" && + GIT_INDEX_FILE="$gitdir/manifestless.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/manifestless-tree.trace" \ + git -C "$worktree" write-tree \ + >"$gitdir/manifestless-tree" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/manifestless-tree.trace" && + git -C "$worktree" ls-tree \ + "$(cat "$gitdir/manifestless-tree")" \ + manifestless-new >"$gitdir/manifestless-entry" && + test_grep "manifestless-new$" \ + "$gitdir/manifestless-entry" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && test_write_lines "*.asset text" \ >"$worktree/.gitattributes" && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ @@ -1117,6 +1224,466 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'fast-forward merges preserve authenticated worktree proofs' ' + test_when_finished "rm -rf fast-forward-bound-proof fast-forward-linked fast-forward-target" && + test_create_repo fast-forward-bound-proof && + ( + cd fast-forward-bound-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git worktree add --detach ../fast-forward-target HEAD && + test_write_lines incoming >../fast-forward-target/tracked && + git -C ../fast-forward-target add tracked && + git -C ../fast-forward-target commit -qm incoming && + target=$(git -C ../fast-forward-target rev-parse HEAD) && + git worktree add --detach ../fast-forward-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + cat >.git/check-merge-proof.pl <<-\EOF && + binmode STDIN; + local $/; + my $index = ; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + die "invalid $name token\n" if $end < 0; + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched provider tokens\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"} && + $tokens{"FSMN"} eq $tokens{"FSCF"}; + EOF + for worktree in "$PWD" "$PWD/../fast-forward-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + perl "$PWD/.git/check-merge-proof.pl" \ + <"$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/merge.trace" \ + git -C "$worktree" merge --ff-only "$target" \ + >"$gitdir/merge" && + test_region index do_write_index "$gitdir/merge.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/merge.trace" && + perl "$PWD/.git/check-merge-proof.pl" \ + <"$gitdir/index" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status" && + test_must_be_empty "$gitdir/status" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/status.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/status.trace" && + test_write_lines "*.asset text" \ + >"$worktree/.gitattributes" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" add .gitattributes && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/attributes.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/attributes" && + test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/attributes.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/attributes.trace" && + test_grep "^1 A\\. .* \\.gitattributes$" \ + "$gitdir/attributes" || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'full status durably repairs missing mixed-writer index proofs' ' + test_when_finished "rm -rf mixed-writer-missing-proofs" && + test_create_repo mixed-writer-missing-proofs && + ( + cd mixed-writer-missing-proofs && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_grep FSCF .git/index && + cat >.git/remove-mixed-writer-proofs.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $algorithm = $ARGV[0]; + my $rawsz = $algorithm eq "sha256" ? 32 : 20; + for my $name ("FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, + $algorithm eq "sha256" ? sha256($payload) : sha1($payload); + EOF + perl .git/remove-mixed-writer-proofs.pl "$(test_oid algo)" \ + <.git/index >.git/index.mixed && + mv .git/index.mixed .git/index && + test_grep FSMN .git/index && + test_grep UNTR .git/index && + test_grep ! FSUC .git/index && + test_grep ! FSCF .git/index && + cp .git/index .git/cold.before && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/cold.trace" \ + git status --porcelain=v2 >.git/cold && + test_must_be_empty .git/cold && + test_cmp_bin .git/cold.before .git/index && + ! test_region index do_write_index .git/cold.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/repair.trace" \ + git status >.git/repair && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/repair.trace && + test_region index do_write_index .git/repair.trace && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_grep FSCF .git/index && + cat >.git/check-mixed-writer-proof.pl <<-\EOF && + binmode STDIN; + local $/; + my $index = ; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched provider tokens\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"} && + $tokens{"FSMN"} eq $tokens{"FSCF"}; + EOF + perl .git/check-mixed-writer-proof.pl <.git/index && + for run in first second + do + cp .git/index ".git/readonly-$run.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/readonly-$run.trace" \ + git status --porcelain=v2 >".git/readonly-$run" && + test_must_be_empty ".git/readonly-$run" && + test_cmp_bin ".git/readonly-$run.index" .git/index && + test_trace2_data fsmonitor config/coherent 1 \ + <".git/readonly-$run.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <".git/readonly-$run.trace" && + ! test_region index do_write_index \ + ".git/readonly-$run.trace" || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary removals and renames preserve safe worktree proofs' ' + test_when_finished "rm -rf rm-mv-bound-proof rm-mv-linked" && + test_create_repo rm-mv-bound-proof && + ( + cd rm-mv-bound-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines remove >remove-me && + test_write_lines move >move-me && + test_write_lines sibling >sibling && + test_write_lines "*.asset text" >.gitattributes && + test_write_lines "*.ignored" >.gitignore && + git add remove-me move-me sibling .gitattributes .gitignore && + git commit -qm base && + test_write_lines successor >sibling && + git add sibling && + git commit -qm successor && + git worktree add --detach ../rm-mv-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + cat >.git/check-rm-mv-proof.pl <<-\EOF && + binmode STDIN; + local $/; + my $index = ; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched provider tokens\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"} && + $tokens{"FSMN"} eq $tokens{"FSCF"}; + EOF + for worktree in "$PWD" "$PWD/../rm-mv-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + for operation in remove rename mixed-reset + do + case "$operation" in + remove) set -- rm --quiet remove-me ;; + rename) set -- mv move-me renamed ;; + mixed-reset) set -- reset --mixed HEAD~1 ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$operation.trace" \ + git -C "$worktree" "$@" && + perl "$PWD/.git/check-rm-mv-proof.pl" \ + <"$gitdir/index" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/$operation.trace" && + cp "$gitdir/index" "$gitdir/$operation.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$operation-status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$operation-status" && + test_cmp_bin "$gitdir/$operation.index" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$operation-status.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$operation-status.trace" || return 1 + done && + test_write_lines exposed >"$worktree/hidden.ignored" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" rm --quiet .gitignore && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/ignore-removed" && + test_grep "^? hidden\\.ignored$" \ + "$gitdir/ignore-removed" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" mv .gitattributes \ + moved.attributes && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/attributes.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/attributes" && + test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/attributes.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/attributes.trace" || return 1 + done && + test_write_lines "*.filtered filter=demo" >.gitattributes && + git config filter.demo.clean cat && + git config filter.demo.required true && + test_write_lines raw >active.filtered && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git add .gitattributes active.filtered && + git config filter.demo.clean false && + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 --untracked-files=no \ + >.git/filtered 2>.git/filter-error && + test_grep "clean filter .demo. failed" .git/filter-error + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'configured pulls preserve authenticated worktree proofs' ' + test_when_finished "rm -rf pull-proof-origin.git pull-proof-seed pull-proof-ff pull-proof-ff-linked pull-proof-rebase pull-proof-rebase-linked pull-proof-autostash pull-proof-autostash-linked" && + git init --bare pull-proof-origin.git && + test_create_repo pull-proof-seed && + ( + cd pull-proof-seed && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git branch -M main && + git remote add origin "$PWD/../pull-proof-origin.git" && + git push --quiet -u origin main && + git --git-dir="$PWD/../pull-proof-origin.git" \ + symbolic-ref HEAD refs/heads/main && + cat >.git/check-pull-proof.pl <<-\EOF && + binmode STDIN; + local $/; + my $index = ; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched provider tokens\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"} && + $tokens{"FSMN"} eq $tokens{"FSCF"}; + EOF + for mode in ff rebase autostash + do + git clone --quiet "$PWD/../pull-proof-origin.git" \ + "$PWD/../pull-proof-$mode" && + repo="$PWD/../pull-proof-$mode" && + linked="$PWD/../pull-proof-$mode-linked" && + git -C "$repo" worktree add --quiet \ + -b "linked-$mode" "$linked" origin/main && + git -C "$linked" branch --quiet \ + --set-upstream-to=origin/main && + if test "$mode" = ff + then + git -C "$repo" config pull.ff only + else + git -C "$repo" config pull.rebase true + fi && + git -C "$repo" config core.untrackedCache true && + git -C "$repo" config core.fsmonitor true && + for role in main linked + do + case "$role" in + main) worktree="$repo" ;; + linked) worktree="$linked" ;; + esac && + if test "$mode" != ff + then + test_write_lines "$mode-$role" \ + >"$worktree/local-$role" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" add "local-$role" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" commit \ + -qm "local-$mode-$role" || return 1 + fi && + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + perl "$PWD/.git/check-pull-proof.pl" \ + <"$gitdir/index" && + test_write_lines "$mode-$role" \ + >"upstream-$mode-$role" && + git add "upstream-$mode-$role" && + git commit -qm "upstream-$mode-$role" && + git push --quiet origin main && + if test "$mode" = autostash + then + test_write_lines dirty >"$worktree/tracked" && + set -- pull --quiet --rebase --autostash + else + set -- pull --quiet + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/pull.trace" \ + git -C "$worktree" "$@" \ + >"$gitdir/pull" && + if test "$mode" != ff + then + test_grep "\"name\":\"rebase\"" \ + "$gitdir/pull.trace" && + test_path_is_file "$worktree/local-$role" || + return 1 + fi && + ! test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/pull.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/pull.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/pull.trace" && + perl "$PWD/.git/check-pull-proof.pl" \ + <"$gitdir/index" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status" && + if test "$mode" = autostash + then + test_grep "^1 \\.M .* tracked$" \ + "$gitdir/status" || return 1 + else + test_must_be_empty "$gitdir/status" || return 1 + fi && + test_cmp_bin "$gitdir/readonly.index" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/status.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/status.trace" || return 1 + done || return 1 + done + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'expired add preserves untracked candidates until revalidation' ' test_when_finished "rm -rf pending-untracked-revalidation pending-untracked-hostile" && @@ -1533,7 +2100,7 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'diff closes reset fsmonitor tokens in main and linked worktrees' ' + 'diff preserves pending reset proofs in main and linked worktrees' ' test_when_finished "rm -rf builtin-diff-reset builtin-diff-reset-linked" && test_create_repo builtin-diff-reset && ( @@ -1578,12 +2145,29 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty "$gitdir/reset.actual" && test_trace2_data fsm_client query/trivial-response 1 \ <"$gitdir/reset.trace" && - test_trace2_data fsmonitor token_closure/accepted 1 \ + test_trace2_data fsmonitor \ + untracked/provider-reset-pending 1 \ <"$gitdir/reset.trace" && test_region index do_write_index "$gitdir/reset.trace" && test_grep FSMN "$gitdir/index" && - test_grep ! FSUC "$gitdir/index" && - test_grep "builtin:test:[2-9]" "$gitdir/index" && + test_grep FSUC "$gitdir/index" && + test_grep "pending:test:[2-9]" "$gitdir/index" && + test_fsmonitor_pending_full_proof "$gitdir/index" && + cp "$gitdir/index" "$gitdir/pending.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/pending-status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/pending-status" && + test_must_be_empty "$gitdir/pending-status" && + test_cmp_bin "$gitdir/pending.index" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/pending-status.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/pending-status.trace" && + ! test_region index do_write_index \ + "$gitdir/pending-status.trace" && GIT_TEST_PRELOAD_INDEX=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/next.trace" \ @@ -3063,6 +3647,301 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'update-index doctor permutations retain authenticated proofs' ' + test_when_finished "rm -rf doctor-update-proof doctor-update-linked" && + test_create_repo doctor-update-proof && + ( + cd doctor-update-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git worktree add --detach ../doctor-update-linked HEAD && + test-tool chmtime -120 tracked \ + ../doctor-update-linked/tracked && + git update-index --refresh && + git -C ../doctor-update-linked update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + cat >.git/remove-doctor-proofs.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $rawsz = $ARGV[0] eq "sha256" ? 32 : 20; + for my $name ("FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, $rawsz == 32 ? sha256($payload) : sha1($payload); + EOF + for worktree in "$PWD" "$PWD/../doctor-update-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ + git -C "$worktree" status --short \ + >"$gitdir/checkpoint" && + test_must_be_empty "$gitdir/checkpoint" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/checkpoint.trace" && + find "$gitdir" -maxdepth 1 -type f \ + -name "index.csh1.*" >"$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/checkpoints" && + if test_have_prereq MACOS + then + find "$gitdir" -maxdepth 1 -type f \ + -name "index.cswi.*" >"$gitdir/witnesses" && + test_line_count = 1 "$gitdir/witnesses" || return 1 + fi && + for mode in healthy history + do + for order in normal reverse + do + if test "$mode" = history + then + perl "$PWD/.git/remove-doctor-proofs.pl" \ + "$(test_oid algo)" <"$gitdir/index" \ + >"$gitdir/index.foreign" && + mv "$gitdir/index.foreign" \ + "$gitdir/index" && + test_grep ! FSUC "$gitdir/index" && + test_grep ! FSCF "$gitdir/index" || return 1 + fi && + if test "$order" = normal + then + set -- --untracked-cache --force-write-index + else + set -- --force-write-index --untracked-cache + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode-$order.trace" \ + git -C "$worktree" update-index "$@" && + test_grep FSUC "$gitdir/index" && + test_grep FSCF "$gitdir/index" && + ! test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/$mode-$order.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$mode-$order.trace" || return 1 + done || return 1 + done || return 1 + done + ) +' + +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'provider restarts keep diff and untracked status correct' ' + test_when_finished "rm -rf daemon-diff-reset daemon-diff-linked" && + test_when_finished \ + "git -C daemon-diff-reset fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-diff-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo daemon-diff-reset && + ( + cd daemon-diff-reset && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep && + test_commit base cached/deep/tracked && + git worktree add --detach ../daemon-diff-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../daemon-diff-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + git -C "$worktree" fsmonitor--daemon start \ + --start-timeout=10 && + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + test_grep FSMN "$gitdir/index" && + test_grep FSUC "$gitdir/index" && + test_grep FSCF "$gitdir/index" && + git -C "$worktree" fsmonitor--daemon stop && + test-tool chmtime =-60 \ + "$worktree/cached/deep/tracked" && + git -C "$worktree" fsmonitor--daemon start \ + --start-timeout=10 && + GIT_TRACE2_EVENT="$gitdir/diff.trace" \ + git -C "$worktree" diff >"$gitdir/diff" && + test_must_be_empty "$gitdir/diff" && + test_trace2_data fsm_client query/trivial-response 1 \ + <"$gitdir/diff.trace" && + test_trace2_data fsmonitor \ + untracked/provider-reset-pending 1 \ + <"$gitdir/diff.trace" && + test_fsmonitor_pending_full_proof "$gitdir/index" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/readonly.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/readonly" && + test_must_be_empty "$gitdir/readonly" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/readonly.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/readonly.trace" && + ! test_region index do_write_index \ + "$gitdir/readonly.trace" && + git -C "$worktree" fsmonitor--daemon stop && + test_write_lines hidden \ + >"$worktree/cached/deep/hidden-during-restart" && + test_write_lines "tracked -text" \ + >"$worktree/cached/deep/.gitattributes" && + test-tool chmtime =-30 \ + "$worktree/cached/deep/tracked" && + git -C "$worktree" fsmonitor--daemon start \ + --start-timeout=10 && + git -C "$worktree" diff >"$gitdir/hidden-diff" && + test_must_be_empty "$gitdir/hidden-diff" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" -c core.untrackedCache=false \ + status --porcelain=v2 >"$gitdir/hidden.expect" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/hidden.actual" && + test_cmp "$gitdir/hidden.expect" \ + "$gitdir/hidden.actual" && + test_grep "^? cached/deep/hidden-during-restart$" \ + "$gitdir/hidden.actual" && + test_grep "^? cached/deep/\\.gitattributes$" \ + "$gitdir/hidden.actual" && + git -C "$worktree" fsmonitor--daemon stop || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-only status never authenticates stale untracked history' ' + test_when_finished "rm -rf tracked-only-reset tracked-only-linked" && + test_create_repo tracked-only-reset && + ( + cd tracked-only-reset && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep && + test_commit base cached/deep/tracked && + git worktree add --detach ../tracked-only-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../tracked-only-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + test_grep FSUC "$gitdir/index" && + test_write_lines unexpected \ + >"$worktree/cached/deep/new-untracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/tracked-only.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no >"$gitdir/tracked-only" && + test_must_be_empty "$gitdir/tracked-only" && + test_trace2_data fsm_client query/trivial-response 1 \ + <"$gitdir/tracked-only.trace" && + test_grep ! FSUC "$gitdir/index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" -c core.untrackedCache=false \ + status --porcelain=v2 >"$gitdir/expect" && + test_grep "^? cached/deep/new-untracked$" \ + "$gitdir/expect" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/readonly" && + test_cmp "$gitdir/expect" "$gitdir/readonly" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/writable" && + test_cmp "$gitdir/expect" "$gitdir/writable" || return 1 + done + ) +' + +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-only status preserves new files after daemon restart' ' + test_when_finished "rm -rf daemon-tracked-only daemon-tracked-only-linked" && + test_when_finished \ + "git -C daemon-tracked-only fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-tracked-only-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo daemon-tracked-only && + ( + cd daemon-tracked-only && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep && + test_commit base cached/deep/tracked && + git worktree add --detach ../daemon-tracked-only-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../daemon-tracked-only-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + git -C "$worktree" fsmonitor--daemon start \ + --start-timeout=10 && + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + test_grep FSUC "$gitdir/index" && + git -C "$worktree" fsmonitor--daemon stop && + test_write_lines unexpected \ + >"$worktree/cached/deep/new-untracked" && + git -C "$worktree" fsmonitor--daemon start \ + --start-timeout=10 && + GIT_TRACE2_EVENT="$gitdir/tracked-only.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no >"$gitdir/tracked-only" && + test_must_be_empty "$gitdir/tracked-only" && + test_trace2_data fsm_client query/trivial-response 1 \ + <"$gitdir/tracked-only.trace" && + test_grep ! FSUC "$gitdir/index" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" -c core.untrackedCache=false \ + status --porcelain=v2 >"$gitdir/expect" && + test_grep "^? cached/deep/new-untracked$" \ + "$gitdir/expect" && + cp "$gitdir/index" "$gitdir/readonly.index" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/readonly" && + test_cmp "$gitdir/expect" "$gitdir/readonly" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/writable" && + test_cmp "$gitdir/expect" "$gitdir/writable" && + git -C "$worktree" fsmonitor--daemon stop || return 1 + done + ) +' + test_expect_success PTHREADS,UNTRACKED_CACHE,SHA1 'load cache-tree and untracked-cache extensions in parallel' ' test_create_repo parallel-extensions && ( diff --git a/wt-status.c b/wt-status.c index 945693a3abbb2f..e7356d3b5e29e3 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2226,6 +2226,7 @@ static int wt_status_close_fsmonitor_token( (!require_untracked && (s->show_untracked_files == SHOW_NO_UNTRACKED_FILES || s->show_ignored_mode) && + istate->fsmonitor_untracked_valid && istate->fsmonitor_untracked_token && istate->fsmonitor_last_update && !strcmp(istate->fsmonitor_untracked_token, From 1565e9d2751a4779abe38b48cc03e8b482e2b9c3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 01:35:22 -0500 Subject: [PATCH 313/403] t7527: expect safe mixed resets to preserve clean history A mixed reset now authenticates every changed index entry before keeping its clean-status history. Updating an ordinary tracked file is safe, so require a coherent proof and no full attribute-manifest scan while still verifying that the worktree modification is reported. --- t/t7527-builtin-fsmonitor.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 4aaf8da578136b..e90a726eb31d7a 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -5859,7 +5859,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'mixed reset drops history after a logical index change' ' + 'mixed reset preserves history across safe tracked content changes' ' test_when_finished "rm -rf reset-mixed-changed" && test_create_repo reset-mixed-changed && ( @@ -5889,7 +5889,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status >.git/actual && test_grep "modified:.*tracked" .git/actual && - test_trace2_data fsmonitor config/coherent 0 \ + test_trace2_data fsmonitor config/coherent 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <.git/status.trace ) ' From e3c9a5b818ccbcd65e61f9352ceee95990599739 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:08:20 -0500 Subject: [PATCH 314/403] fsmonitor: preserve proofs through filters, replay, and index locks A configured LFS filter is not necessarily active for any tracked path. Bind the normal clean-status configuration before diff, but retain the existing rule against loading sidecar history for configured filters. Treat Git's actual primary index.lock as a physical index when a pre-commit hook runs write-tree. Keep arbitrary temporary indexes, symlinks, and hardlink aliases on their existing isolated paths. Initialize authenticated history for native-fsmonitor cherry-pick and revert operations, while leaving hook providers and unsupported systems untouched. After a daemon reset, preserve tracked-only status directory snapshots only as pending candidates. Require an authenticated complete tracked proof, exclude scoped, sparse, and alternate cases, and validate every directory before its untracked entries can be trusted. Cover inactive LFS, active required filters, generating commit-a hooks, replay conflicts, daemon restarts, offline untracked files, and repeated immutable read-only statuses in primary and linked SHA-1/SHA-256 trees. --- builtin/diff.c | 6 +- builtin/revert.c | 10 ++ builtin/write-tree.c | 13 ++- t/t7519-status-fsmonitor.sh | 212 ++++++++++++++++++++++++++++++++---- wt-status.c | 32 +++++- 5 files changed, 242 insertions(+), 31 deletions(-) diff --git a/builtin/diff.c b/builtin/diff.c index 35f9a17f59f853..2d9d6de62ecce6 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -453,11 +453,11 @@ void prepare_diff_external_history(struct repository *repo) goto done; worktree = get_current_worktree(repo); if (!worktree || - clean_status_config_read_repository(repo, &digest) || - digest.filter_configured) + clean_status_config_read_repository(repo, &digest)) goto done; clean_status_set_config_digest(repo, &digest); - clean_status_enable_external_history(repo); + if (!digest.filter_configured) + clean_status_enable_external_history(repo); done: free_worktree(worktree); diff --git a/builtin/revert.c b/builtin/revert.c index bedc40f368eccc..ac8968f3558d8d 100644 --- a/builtin/revert.c +++ b/builtin/revert.c @@ -2,9 +2,12 @@ #include "git-compat-util.h" #include "builtin.h" +#include "clean-status-config.h" +#include "clean-status.h" #include "parse-options.h" #include "diff.h" #include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "revision.h" #include "rerere.h" @@ -115,6 +118,7 @@ static int run_sequencer(int argc, const char **argv, const char *prefix, const char sentinel_value = 0; /* value not important */ const char *strategy = &sentinel_value; const char *gpg_sign = &sentinel_value; + struct clean_status_config_digest clean_digest; enum empty_action empty_opt = EMPTY_COMMIT_UNSPECIFIED; int cmd = 0; struct option base_options[] = { @@ -172,6 +176,12 @@ static int run_sequencer(int argc, const char **argv, const char *prefix, argc = parse_options(argc, argv, prefix, options, usage_str, PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT); + if (fstat_is_reliable() && !getenv(INDEX_ENVIRONMENT) && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + !clean_status_config_read_repository(the_repository, &clean_digest)) { + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, &clean_digest); + } prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; diff --git a/builtin/write-tree.c b/builtin/write-tree.c index 338e4565c2fc29..bb04b01968d068 100644 --- a/builtin/write-tree.c +++ b/builtin/write-tree.c @@ -34,22 +34,27 @@ static int write_tree_uses_worktree_index(void) const char *index_file = getenv(INDEX_ENVIRONMENT); struct strbuf worktree_index = STRBUF_INIT; struct stat st; - char *expected = NULL, *actual = NULL; + char *expected = NULL, *expected_lock = NULL, *actual = NULL; int matches = 0; if (!index_file) return 1; - if (lstat(index_file, &st) || S_ISLNK(st.st_mode)) + if (lstat(index_file, &st) || !S_ISREG(st.st_mode)) return 0; strbuf_addf(&worktree_index, "%s/index", repo_get_git_dir(the_repository)); expected = real_pathdup(worktree_index.buf, 0); actual = real_pathdup(index_file, 0); - if (expected && actual && !strcmp(expected, actual)) - matches = 1; + if (expected && actual) { + expected_lock = xstrfmt("%s.lock", expected); + if (!strcmp(expected, actual) || + !strcmp(expected_lock, actual)) + matches = 1; + } free(expected); + free(expected_lock); free(actual); strbuf_release(&worktree_index); return matches; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index b384e03fe71e0d..8ae4262d296408 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -598,8 +598,8 @@ prepare_builtin_closure_repo () { ) } -test_fsmonitor_pending_full_proof () { - perl - "$1" <<-\EOF +test_fsmonitor_full_proof () { + perl - "$1" "$2" <<-\EOF binmode STDIN; open my $input, "<", $ARGV[0] or die "cannot read index: $!\n"; binmode $input; @@ -626,8 +626,10 @@ test_fsmonitor_pending_full_proof () { $tokens{"FSMN"} eq $tokens{"FSCF"}; my ($suffix) = $tokens{"FSMN"} =~ /\Abuiltin:(.+)\z/; die "missing provider token\n" unless defined $suffix; - die "mismatched pending untracked token\n" unless - $tokens{"FSUC"} eq "pending:$suffix"; + my $untracked = $ARGV[1] eq "pending" ? + "pending:$suffix" : $tokens{"FSMN"}; + die "mismatched untracked token\n" unless + $tokens{"FSUC"} eq $untracked; EOF } @@ -1067,6 +1069,12 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ EOF write_script .git/hooks/pre-commit <<-\EOF && test -n "$GIT_INDEX_FILE" || exit 1 + if test -n "${HOOK_GENERATE-}" + then + test "$GIT_INDEX_FILE" = "$HOOK_EXPECT_INDEX" || exit 1 + printf "%s\n" generated >"$HOOK_GENERATE" || exit 1 + git add -- "$HOOK_GENERATE" || exit 1 + fi git write-tree >"$HOOK_PROOF_OUTPUT" || exit 1 perl "$HOOK_PROOF_HELPER" <"$GIT_INDEX_FILE" EOF @@ -1150,6 +1158,36 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ "$gitdir/hook.trace" && perl "$PWD/.git/check-write-tree-proof.pl" \ <"$gitdir/index" && + test_write_lines commit-all >"$worktree/sibling" && + HOOK_GENERATE=hook-generated \ + HOOK_EXPECT_INDEX="$gitdir/index.lock" \ + HOOK_PROOF_HELPER="$PWD/.git/check-write-tree-proof.pl" \ + HOOK_PROOF_OUTPUT="$gitdir/hook-all-tree" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=sibling \ + GIT_TRACE2_EVENT="$gitdir/hook-all.trace" \ + git -C "$worktree" commit -aqm commit-all && + test_file_not_empty "$gitdir/hook-all-tree" && + git -C "$worktree" ls-tree HEAD hook-generated \ + >"$gitdir/hook-all-entry" && + test_grep "hook-generated$" "$gitdir/hook-all-entry" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/hook-all.trace" && + perl "$PWD/.git/check-write-tree-proof.pl" \ + <"$gitdir/index" && + cp "$gitdir/index" "$gitdir/hook-all-before-status" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/hook-all-status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/hook-all-status" && + test_must_be_empty "$gitdir/hook-all-status" && + test_cmp_bin "$gitdir/hook-all-before-status" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/hook-all-status.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/hook-all-status.trace" && cp "$gitdir/index" "$gitdir/readonly.index" && cp "$gitdir/index" "$gitdir/snapshot.index" && test_write_lines snapshot >"$worktree/snapshot-new" && @@ -1684,6 +1722,86 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'clean sequencer operations preserve authenticated worktree proofs' ' + test_when_finished "rm -rf sequencer-proof sequencer-linked" && + test_create_repo sequencer-proof && + ( + cd sequencer-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines picked >tracked && + git add tracked && + git commit -qm picked && + picked=$(git rev-parse HEAD) && + git reset --hard HEAD^ && + git worktree add --detach ../sequencer-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../sequencer-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + for operation in pick revert + do + case "$operation" in + pick) set -- cherry-pick --no-edit "$picked" ;; + revert) set -- revert --no-edit HEAD ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/$operation.trace" \ + git -C "$worktree" "$@" \ + >"$gitdir/$operation" && + test_fsmonitor_full_proof \ + "$gitdir/index" paired && + cp "$gitdir/index" \ + "$gitdir/$operation.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$operation-status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$operation-status" && + test_must_be_empty "$gitdir/$operation-status" && + test_cmp_bin "$gitdir/$operation.index" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$operation-status.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$operation-status.trace" || return 1 + done || return 1 + done && + test_write_lines conflicting >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git commit -qm local-conflict && + test_must_fail env \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git cherry-pick --no-edit "$picked" \ + >.git/conflict.out 2>.git/conflict.err && + if test_fsmonitor_full_proof .git/index paired \ + >/dev/null 2>&1 + then + return 1 + fi && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/conflict && + test_grep "^u UU .* tracked$" .git/conflict + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'expired add preserves untracked candidates until revalidation' ' test_when_finished "rm -rf pending-untracked-revalidation pending-untracked-hostile" && @@ -2112,6 +2230,10 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && git -c core.fsmonitor=false worktree add --detach \ ../builtin-diff-reset-linked HEAD && + git config filter.lfs.clean "git-lfs clean -- %f" && + git config filter.lfs.smudge "git-lfs smudge -- %f" && + git config filter.lfs.process "git-lfs filter-process" && + git config filter.lfs.required true && for worktree in "$PWD" "$PWD/../builtin-diff-reset-linked" do gitdir=$(git -C "$worktree" \ @@ -2152,7 +2274,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep FSMN "$gitdir/index" && test_grep FSUC "$gitdir/index" && test_grep "pending:test:[2-9]" "$gitdir/index" && - test_fsmonitor_pending_full_proof "$gitdir/index" && + test_fsmonitor_full_proof "$gitdir/index" pending && cp "$gitdir/index" "$gitdir/pending.index" && GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ @@ -2176,7 +2298,17 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty "$gitdir/next.actual" && test_trace2_data index preload/sum_lstat 0 \ <"$gitdir/next.trace" || return 1 - done + done && + git config filter.lfs.process "" && + git config filter.lfs.clean false && + test_write_lines "tracked filter=lfs" >.gitattributes && + cp .git/index .git/filtered.index && + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git diff >.git/filtered.out 2>.git/filtered.err && + test_grep "clean filter .lfs. failed" .git/filtered.err && + test_cmp_bin .git/filtered.index .git/index ) ' @@ -3758,6 +3890,10 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP git worktree add --detach ../daemon-diff-linked HEAD && git config core.untrackedCache true && git config core.fsmonitor true && + git config filter.lfs.clean "git-lfs clean -- %f" && + git config filter.lfs.smudge "git-lfs smudge -- %f" && + git config filter.lfs.process "git-lfs filter-process" && + git config filter.lfs.required true && for worktree in "$PWD" "$PWD/../daemon-diff-linked" do gitdir=$(git -C "$worktree" \ @@ -3785,7 +3921,7 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP test_trace2_data fsmonitor \ untracked/provider-reset-pending 1 \ <"$gitdir/diff.trace" && - test_fsmonitor_pending_full_proof "$gitdir/index" && + test_fsmonitor_full_proof "$gitdir/index" pending && cp "$gitdir/index" "$gitdir/readonly.index" && GIT_OPTIONAL_LOCKS=0 \ GIT_TRACE2_EVENT="$gitdir/readonly.trace" \ @@ -3861,20 +3997,36 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty "$gitdir/tracked-only" && test_trace2_data fsm_client query/trivial-response 1 \ <"$gitdir/tracked-only.trace" && - test_grep ! FSUC "$gitdir/index" && + test_trace2_data fsmonitor \ + untracked/provider-reset-pending 1 \ + <"$gitdir/tracked-only.trace" && + test_fsmonitor_full_proof "$gitdir/index" pending && GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ git -C "$worktree" -c core.untrackedCache=false \ status --porcelain=v2 >"$gitdir/expect" && test_grep "^? cached/deep/new-untracked$" \ "$gitdir/expect" && - cp "$gitdir/index" "$gitdir/readonly.index" && - GIT_OPTIONAL_LOCKS=0 \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - git -C "$worktree" status --porcelain=v2 \ - >"$gitdir/readonly" && - test_cmp "$gitdir/expect" "$gitdir/readonly" && - test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + for pass in first second + do + cp "$gitdir/index" "$gitdir/readonly-$pass.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/readonly-$pass.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/readonly-$pass" && + test_cmp "$gitdir/expect" \ + "$gitdir/readonly-$pass" && + test_cmp_bin "$gitdir/readonly-$pass.index" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/readonly-$pass.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/readonly-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/readonly-$pass.trace" || return 1 + done && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ git -C "$worktree" status --porcelain=v2 \ >"$gitdir/writable" && @@ -3922,18 +4074,34 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP test_must_be_empty "$gitdir/tracked-only" && test_trace2_data fsm_client query/trivial-response 1 \ <"$gitdir/tracked-only.trace" && - test_grep ! FSUC "$gitdir/index" && + test_trace2_data fsmonitor \ + untracked/provider-reset-pending 1 \ + <"$gitdir/tracked-only.trace" && + test_fsmonitor_full_proof "$gitdir/index" pending && GIT_OPTIONAL_LOCKS=0 \ git -C "$worktree" -c core.untrackedCache=false \ status --porcelain=v2 >"$gitdir/expect" && test_grep "^? cached/deep/new-untracked$" \ "$gitdir/expect" && - cp "$gitdir/index" "$gitdir/readonly.index" && - GIT_OPTIONAL_LOCKS=0 \ - git -C "$worktree" status --porcelain=v2 \ - >"$gitdir/readonly" && - test_cmp "$gitdir/expect" "$gitdir/readonly" && - test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + for pass in first second + do + cp "$gitdir/index" "$gitdir/readonly-$pass.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/readonly-$pass.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/readonly-$pass" && + test_cmp "$gitdir/expect" \ + "$gitdir/readonly-$pass" && + test_cmp_bin "$gitdir/readonly-$pass.index" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/readonly-$pass.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/readonly-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/readonly-$pass.trace" || return 1 + done && git -C "$worktree" status --porcelain=v2 \ >"$gitdir/writable" && test_cmp "$gitdir/expect" "$gitdir/writable" && diff --git a/wt-status.c b/wt-status.c index e7356d3b5e29e3..c24c42f71350c1 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2168,8 +2168,21 @@ static int wt_status_close_fsmonitor_token( .staged_ignored = STRING_LIST_INIT_DUP, }; enum wt_status_token_closure_result result; + int preserve_untracked, token_accepted = 0; refresh_fsmonitor(istate); + preserve_untracked = !require_untracked && + s->show_untracked_files == SHOW_NO_UNTRACKED_FILES && + !s->show_ignored_mode && !s->pathspec.nr && + fstat_is_reliable() && !getenv(INDEX_ENVIRONMENT) && + istate == istate->repo->index && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + fsm_settings__get_mode(s->repo) == FSMONITOR_MODE_IPC && + istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + istate->untracked && istate->untracked->root && + istate->untracked->root->valid && + istate->untracked->fsmonitor_revalidation; if (!fsmonitor_has_pending_token(istate) || fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC) { int attr_inputs_match = @@ -2246,8 +2259,10 @@ static int wt_status_close_fsmonitor_token( if (proof) { result = wt_status_close_semantic_fsmonitor_token( &closure, &proof); - if (result == WT_STATUS_TOKEN_CLOSURE_ACCEPTED) + if (result == WT_STATUS_TOKEN_CLOSURE_ACCEPTED) { + token_accepted = 1; goto accepted; + } if (result == WT_STATUS_TOKEN_CLOSURE_FALLBACK) goto fallback; wt_status_reset_attr_snapshot_if_changed(s); @@ -2256,8 +2271,10 @@ static int wt_status_close_fsmonitor_token( } if (wt_status_close_ordinary_fsmonitor_token( - &closure, refreshed_before_closure)) + &closure, refreshed_before_closure)) { + token_accepted = 1; goto accepted; + } /* Keep the last valid token and fall back to complete scans. */ fallback: @@ -2274,6 +2291,17 @@ static int wt_status_close_fsmonitor_token( closure.refresh_result |= refresh_index( istate, refresh_flags, &s->pathspec, NULL, NULL); accepted: + if (token_accepted && preserve_untracked && + !istate->fsmonitor_untracked_valid && + istate->untracked->root && istate->untracked->root->valid && + clean_status_revalidated_token_matches(istate) && + !clean_status_filter_scope_needs_validation(istate)) { + /* Tracked closure leaves directory snapshots unverified. */ + istate->untracked->fsmonitor_revalidation = 1; + istate->fsmonitor_untracked_token = + xstrdup(istate->fsmonitor_last_update); + clean_status_begin_fsmonitor_semantic_baseline(istate); + } wt_status_publish_staged_untracked(&closure); wt_status_discard_staged_untracked(&closure); trace2_region_leave("status", "fsmonitor_token_closure", s->repo); From 136d1c0f1e4dd9e460e353ad3eec4e0cad9c7d7f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:27:09 -0500 Subject: [PATCH 315/403] t7527: accept either authenticated legacy checkpoint restore An intervening status may refresh the external checkpoint after a legacy writer stages a new directory. A later legacy unstaging can then restore the same logical index directly or recover both its authenticated semantic and untracked history across the changed index. Accept either complete authenticated restore path while retaining the exact status oracle and the existing no-bulk-traversal assertions. --- t/t7527-builtin-fsmonitor.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index e90a726eb31d7a..6b4c7c11a8b913 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -4147,8 +4147,18 @@ test_expect_success MACOS,LEGACY_PREVIEW_FSMONITOR_GIT,UNTRACKED_CACHE,SEMANTIC_ GIT_TRACE2_EVENT="$PWD/.git/foreign-unstaged.trace" \ git status --porcelain=v2 >.git/unstaged.actual && test_cmp .git/unstaged.expect .git/unstaged.actual && - test_trace2_data fsmonitor history/external-restored 1 \ - <.git/foreign-unstaged.trace && + { + test_trace2_data fsmonitor history/external-restored 1 \ + <.git/foreign-unstaged.trace || + { + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 \ + <.git/foreign-unstaged.trace && + test_trace2_data fsmonitor \ + history/external-untracked-restored 1 \ + <.git/foreign-unstaged.trace + } + } && ! test_trace2_data index preload/bulk_useful \ "[1-9][0-9]*" <.git/foreign-unstaged.trace && ! test_trace2_data index preload/bulk_dirs \ From 57b3c9021c3d11ac4598087cdf419d6f5fc8c3c3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:37:25 -0500 Subject: [PATCH 316/403] diff: restore authenticated history with configured filters A configured but inactive filter must not prevent diff commands from restoring authenticated external clean history. Otherwise linked indexes rewritten by older Git repeatedly rescan every tracked path, even when their matching checkpoint remains intact. Always enable external history after binding the repository configuration. Checkpoint restoration already validates configuration, attributes, provider tokens, worktree identity, and active filter scope. Exercise diff, diff-files, and diff-index against a linked worktree with an inactive required LFS filter and missing physical proof extensions. Also verify that activating the filter invalidates the checkpoint and fails safely. --- builtin/diff.c | 3 +- t/t7519-status-fsmonitor.sh | 63 ++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/builtin/diff.c b/builtin/diff.c index 2d9d6de62ecce6..d2e4e2297f3808 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -456,8 +456,7 @@ void prepare_diff_external_history(struct repository *repo) clean_status_config_read_repository(repo, &digest)) goto done; clean_status_set_config_digest(repo, &digest); - if (!digest.filter_configured) - clean_status_enable_external_history(repo); + clean_status_enable_external_history(repo); done: free_worktree(worktree); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 8ae4262d296408..40c5d8418733f9 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3689,6 +3689,13 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO git -C "$worktree" config core.autocrlf false && git -C "$worktree" config core.untrackedCache true && git -C "$worktree" config core.fsmonitor true && + git -C "$worktree" config filter.lfs.clean \ + "git-lfs clean -- %f" && + git -C "$worktree" config filter.lfs.smudge \ + "git-lfs smudge -- %f" && + git -C "$worktree" config filter.lfs.process \ + "git-lfs filter-process" && + git -C "$worktree" config filter.lfs.required true && git -C "$worktree" config index.recordEndOfIndexEntries false && test-tool chmtime -120 \ "$worktree/existing/tracked" "$worktree/existing/sibling" && @@ -3735,6 +3742,43 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO test_grep UNTR "$gitdir/index" && test_grep ! FSUC "$gitdir/index" && test_grep ! FSCF "$gitdir/index" && + for command in diff diff-files diff-index + do + case "$command" in + diff-index) set -- "$command" HEAD ;; + *) set -- "$command" ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" -c core.fsmonitor=false \ + -c core.untrackedCache=false "$@" \ + >"$gitdir/$command.expect" && + cp "$gitdir/index" "$gitdir/$command.before" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$gitdir/$command.trace" \ + git -C "$worktree" "$@" \ + >"$gitdir/$command.actual" && + test_cmp "$gitdir/$command.expect" \ + "$gitdir/$command.actual" && + test_cmp_bin "$gitdir/$command.before" "$gitdir/index" && + { + test_trace2_data fsmonitor history/external-restored 1 \ + <"$gitdir/$command.trace" || + test_trace2_data fsmonitor \ + history/external-semantic-restored 1 \ + <"$gitdir/$command.trace" + } && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$command.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$command.trace" && + ! test_trace2_data index preload/sum_lstat \ + "[2-9][0-9]*" <"$gitdir/$command.trace" && + ! test_trace2_data index preload/sum_lstat \ + "1[0-9][0-9]*" <"$gitdir/$command.trace" || + return 1 + done && test_write_lines dirty >"$worktree/existing/tracked" && for run in first second do @@ -3775,7 +3819,24 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO "$gitdir/status-$run" && ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <"$gitdir/status-$run.trace" || return 1 - done + done && + git -C "$worktree" config filter.lfs.process "" && + git -C "$worktree" config filter.lfs.clean false && + test_write_lines "existing/tracked filter=lfs" \ + >"$worktree/.gitattributes" && + cp "$gitdir/index" "$gitdir/active-filter.before" && + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/active-filter.trace" \ + git -C "$worktree" diff \ + >"$gitdir/active-filter.out" \ + 2>"$gitdir/active-filter.err" && + test_grep "clean filter .lfs. failed" \ + "$gitdir/active-filter.err" && + test_cmp_bin "$gitdir/active-filter.before" "$gitdir/index" && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <"$gitdir/active-filter.trace" && + ! test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <"$gitdir/active-filter.trace" ) ' From 8514240f26ae208d2c6e2802aa9b2e9e474ca794 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:57:49 -0500 Subject: [PATCH 317/403] fsmonitor: skip missing-checkpoint hashes for read-only commands External-history restoration currently computes a logical digest of every index entry before checking whether an authenticated checkpoint was actually loaded. Missing, malformed, or differently namespaced sidecars therefore add an unnecessary full-index pass to every read-only diff. Return before the digest when optional locks are disabled and no checkpoint exists. Preserve the writable path because its source digest is required to issue the first authenticated checkpoint, and retain missing-provider invalidation telemetry. Cover all three unusable checkpoint cases, subsequent authenticated restoration, writable checkpoint issuance, and active required filters in the existing linked-worktree regression. --- clean-status-history.c | 6 ++++++ t/t7519-status-fsmonitor.sh | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/clean-status-history.c b/clean-status-history.c index 53adf902044184..16f49951fb0812 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1545,6 +1545,12 @@ int clean_status_restore_external_history(struct index_state *istate) goto have_index_hash; } } + if (!record_loaded && !use_optional_locks()) { + if (missing_fsmonitor_recovery) + trace2_data_intmax("fsmonitor", istate->repo, + "history/external-proof-invalidated", 1); + goto done; + } if (clean_status_index_logical_digest(istate, index_hash)) goto done; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 40c5d8418733f9..dbb1b8f50c676c 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3742,6 +3742,39 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO test_grep UNTR "$gitdir/index" && test_grep ! FSUC "$gitdir/index" && test_grep ! FSCF "$gitdir/index" && + checkpoint=$(cat "$gitdir/checkpoints") && + cp "$checkpoint" "$gitdir/checkpoint.valid" && + for corruption in missing malformed wrong-namespace + do + rm -f "$checkpoint" "$checkpoint.wrong" && + case "$corruption" in + missing) : ;; + malformed) printf "%s\n" corrupt >"$checkpoint" ;; + wrong-namespace) + cp "$gitdir/checkpoint.valid" "$checkpoint.wrong" ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" -c core.fsmonitor=false \ + -c core.untrackedCache=false diff \ + >"$gitdir/$corruption.expect" && + cp "$gitdir/index" "$gitdir/$corruption.before" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/$corruption.trace" \ + git -C "$worktree" diff \ + >"$gitdir/$corruption.actual" && + test_cmp "$gitdir/$corruption.expect" \ + "$gitdir/$corruption.actual" && + test_cmp_bin "$gitdir/$corruption.before" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/$corruption.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/$corruption.trace" && + test_grep ! "\"label\":\"history_logical_digest\"" \ + "$gitdir/$corruption.trace" || return 1 + done && + rm -f "$checkpoint.wrong" && + cp "$gitdir/checkpoint.valid" "$checkpoint" && for command in diff diff-files diff-index do case "$command" in @@ -3820,6 +3853,14 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <"$gitdir/status-$run.trace" || return 1 done && + rm -f "$checkpoint" && + GIT_TRACE2_EVENT="$gitdir/reissue-checkpoint.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/reissue-checkpoint" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/reissue-checkpoint.trace" && + test_path_is_file "$checkpoint" && + test_fsmonitor_full_proof "$gitdir/index" paired && git -C "$worktree" config filter.lfs.process "" && git -C "$worktree" config filter.lfs.clean false && test_write_lines "existing/tracked filter=lfs" \ From ae84bbabfbe502de070ba66bf2705406b43e30d4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 03:12:08 -0500 Subject: [PATCH 318/403] fsmonitor: reserve checkpoint source hashes for writable status Optional locks describe what a command may do, not whether it will publish external clean history. Ordinary diff, diff-files, and diff-index can therefore still hash every index entry after failing to find a checkpoint, even though none of them can create one. Explicitly mark writable status as the sole command that requires an external-history source digest. Missing or unusable checkpoints then return immediately for both ordinary and read-only diff commands, while status retains the pre-refresh digest needed to publish its first authenticated checkpoint. Exercise missing, malformed, and wrongly namespaced checkpoints with optional locks enabled and disabled, preserving writable checkpoint issuance and active-filter failure coverage. --- builtin/commit.c | 2 ++ clean-status-history.c | 9 +++++++- clean-status.h | 1 + t/t7519-status-fsmonitor.sh | 46 +++++++++++++++++++++++++------------ 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 07c565fbff6fa2..04bfacdb08354e 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1831,6 +1831,8 @@ struct repository *repo UNUSED) if (isatty(2)) clean_status_enable_progress(the_repository); } + if (use_optional_locks()) + clean_status_require_external_history_source(the_repository); repo_read_index(the_repository); if (use_optional_locks()) clean_status_capture_external_history_source( diff --git a/clean-status-history.c b/clean-status-history.c index 16f49951fb0812..e02d8830ecb67d 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -23,6 +23,8 @@ #define CLEAN_STATUS_HISTORY_SCHEMA "builtin-fsmonitor-history-v2" +static struct repository *external_history_source_repo; + static void invalidate_disk_history(struct clean_status_state *state) { state->disk_config_seen = 1; @@ -516,6 +518,11 @@ static int external_history_namespace(struct index_state *istate, char *out) return ret; } +void clean_status_require_external_history_source(struct repository *repo) +{ + external_history_source_repo = repo; +} + void clean_status_capture_external_history_source( struct index_state *istate) { @@ -1545,7 +1552,7 @@ int clean_status_restore_external_history(struct index_state *istate) goto have_index_hash; } } - if (!record_loaded && !use_optional_locks()) { + if (!record_loaded && external_history_source_repo != istate->repo) { if (missing_fsmonitor_recovery) trace2_data_intmax("fsmonitor", istate->repo, "history/external-proof-invalidated", 1); diff --git a/clean-status.h b/clean-status.h index 47fbeacd430918..8602e66f6213c0 100644 --- a/clean-status.h +++ b/clean-status.h @@ -140,6 +140,7 @@ int clean_status_has_recovered_tracked_stat( const struct index_state *istate); int clean_status_external_history_owns_index( const struct index_state *istate); +void clean_status_require_external_history_source(struct repository *repo); void clean_status_capture_external_history_source( struct index_state *istate); int clean_status_save_external_history(struct index_state *istate); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index dbb1b8f50c676c..eac59d4792abcd 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3757,21 +3757,37 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO git -C "$worktree" -c core.fsmonitor=false \ -c core.untrackedCache=false diff \ >"$gitdir/$corruption.expect" && - cp "$gitdir/index" "$gitdir/$corruption.before" && - GIT_OPTIONAL_LOCKS=0 \ - GIT_TRACE2_EVENT="$gitdir/$corruption.trace" \ - git -C "$worktree" diff \ - >"$gitdir/$corruption.actual" && - test_cmp "$gitdir/$corruption.expect" \ - "$gitdir/$corruption.actual" && - test_cmp_bin "$gitdir/$corruption.before" \ - "$gitdir/index" && - test_trace2_data fsmonitor config/coherent 0 \ - <"$gitdir/$corruption.trace" && - test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ - <"$gitdir/$corruption.trace" && - test_grep ! "\"label\":\"history_logical_digest\"" \ - "$gitdir/$corruption.trace" || return 1 + for locking in readonly default + do + trace="$gitdir/$corruption-$locking.trace" && + cp "$gitdir/index" \ + "$gitdir/$corruption-$locking.before" && + if test "$locking" = readonly + then + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$trace" \ + git -C "$worktree" diff \ + >"$gitdir/$corruption.actual" + else + sane_unset GIT_OPTIONAL_LOCKS && + GIT_TRACE2_EVENT="$trace" \ + git -C "$worktree" diff \ + >"$gitdir/$corruption.actual" + fi && + test_cmp "$gitdir/$corruption.expect" \ + "$gitdir/$corruption.actual" && + test_cmp_bin \ + "$gitdir/$corruption-$locking.before" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 0 \ + <"$trace" && + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 <"$trace" && + test_grep ! "\"label\":\"history_logical_digest\"" \ + "$trace" && + ! test_region index do_write_index "$trace" || + return 1 + done || return 1 done && rm -f "$checkpoint.wrong" && cp "$gitdir/checkpoint.valid" "$checkpoint" && From dfc55f903ca1dc355fd916fcae91974a37891017 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 03:45:13 -0500 Subject: [PATCH 319/403] diff: repair provider-reset history before publishing its index After the builtin fsmonitor daemon loses its history, diff previously advanced the tracked token while leaving the untracked proof pending. Every subsequent full or read-only status then had to revalidate every cached directory again. Perform one complete authenticated worktree and exclude validation before updating the index. Pin the original physical index, keep its lock unheld during directory traversal, and verify that the same index still exists after acquiring the lock. Publish the repaired index only after tracked, untracked, configuration, and provider-token proofs all agree. Preserve fail-closed behavior for concurrent writers, held locks, sparse or split indexes, conflicts, filters, unreadable directories, and lost provider events. Extend scripted and real-daemon regressions to require fully paired proofs, bounded follow-up directory work, and correct offline changes. --- builtin/diff.c | 134 +++++++++++++++++++++++++++++++++--- t/t7519-status-fsmonitor.sh | 36 ++++++---- 2 files changed, 150 insertions(+), 20 deletions(-) diff --git a/builtin/diff.c b/builtin/diff.c index d2e4e2297f3808..0838d8edd0e561 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -9,6 +9,7 @@ #include "builtin.h" #include "clean-status.h" +#include "clean-status-index.h" #include "clean-status-sidecar.h" #include "config.h" #include "ewah/ewok.h" @@ -29,9 +30,11 @@ #include "revision.h" #include "log-tree.h" #include "setup.h" +#include "thread-utils.h" #include "oid-array.h" #include "tree.h" #include "worktree.h" +#include "wt-status.h" #define DIFF_NO_INDEX_EXPLICIT 1 #define DIFF_NO_INDEX_IMPLICIT 2 @@ -241,6 +244,29 @@ static void builtin_diff_combined(struct rev_info *revs, oid_array_clear(&parents); } +static pthread_mutex_t diff_refresh_warning_mutex; +static int diff_refresh_warning_seen; + +static void capture_diff_refresh_warning(const char *message UNUSED, + va_list params UNUSED) +{ + pthread_mutex_lock(&diff_refresh_warning_mutex); + diff_refresh_warning_seen = 1; + pthread_mutex_unlock(&diff_refresh_warning_mutex); +} + +static int can_close_diff_fsmonitor_token(struct index_state *istate) +{ + return fsmonitor_pending_token_from_provider(istate) && + fstat_is_reliable() && + the_repository->config_values_private_.trust_ctime && + the_repository->config_values_private_.check_stat && + !getenv(INDEX_ENVIRONMENT) && + !istate->split_index && istate->sparse_index == INDEX_EXPANDED && + !unmerged_index(istate) && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC; +} + static void refresh_index_quietly(void) { struct lock_file lock_file = LOCK_INIT; @@ -253,19 +279,111 @@ static void refresh_index_quietly(void) if (!use_optional_locks()) return; + can_close_token = can_close_diff_fsmonitor_token(istate); + if (can_close_token && + !repo_config_values(the_repository)->apply_sparse_checkout && + istate->untracked && + istate->untracked->root && + istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + !istate->fsmonitor_untracked_valid) { + struct clean_status_index_snapshot source = { .fd = -1 }; + struct clean_status_config_digest digest; + struct object_id exclude_digest; + struct stat scanned_worktree; + struct wt_status status; + report_fn original_warning; + int proof_complete; + int warning_seen; + + if (clean_status_index_snapshot_open_allow_null_checksum( + &source, repo_get_index_file(the_repository), + the_repository->hash_algo)) + return; + if (clean_status_config_read_repository(the_repository, + &digest)) { + clean_status_index_snapshot_release(&source); + return; + } + clean_status_set_config_digest(the_repository, &digest); + discard_index(istate); + repo_read_index(the_repository); + if (!clean_status_index_snapshot_still_matches_proof_epoch( + &source, istate)) { + clean_status_index_snapshot_release(&source); + return; + } + refresh_fsmonitor(istate); + if (!can_close_diff_fsmonitor_token(istate) || + repo_config_values(the_repository)->apply_sparse_checkout || + !istate->untracked || !istate->untracked->root || + !istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + istate->fsmonitor_untracked_valid) { + clean_status_index_snapshot_release(&source); + return; + } + if (pthread_mutex_init(&diff_refresh_warning_mutex, NULL)) { + clean_status_index_snapshot_release(&source); + return; + } + diff_refresh_warning_seen = 0; + original_warning = get_warn_routine(); + set_warn_routine(capture_diff_refresh_warning); + wt_status_prepare(the_repository, &status); + status.certify_clean_status = 1; + status.show_untracked_files = istate->untracked->dir_flags ? + SHOW_NORMAL_UNTRACKED_FILES : SHOW_ALL_UNTRACKED_FILES; + wt_status_start_untracked_cache_preload(&status); + wt_status_refresh_index(&status, + REFRESH_QUIET | REFRESH_UNMERGED | + REFRESH_DEFER_BULK_DIRTY, 1); + proof_complete = !status.certify_untracked_scan_failed && + !wt_status_certified_excludes_digest( + &status, &exclude_digest, &scanned_worktree) && + !fsmonitor_has_pending_token(istate) && + istate->fsmonitor_untracked_valid && + istate->fsmonitor_last_update && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token) && + (!istate->clean_status || + clean_status_revalidated_token_matches(istate)); + wt_status_collect_free_buffers(&status); + set_warn_routine(original_warning); + pthread_mutex_lock(&diff_refresh_warning_mutex); + warning_seen = diff_refresh_warning_seen; + pthread_mutex_unlock(&diff_refresh_warning_mutex); + pthread_mutex_destroy(&diff_refresh_warning_mutex); + string_list_clear(&status.untracked, 0); + string_list_clear(&status.ignored, 0); + free(status.branch); + if (!proof_complete || warning_seen) { + clean_status_index_snapshot_release(&source); + return; + } + fd = repo_hold_locked_index(the_repository, &lock_file, 0); + if (fd < 0) { + clean_status_index_snapshot_release(&source); + return; + } + if (!clean_status_index_snapshot_still_matches_path( + &source, repo_get_index_file(the_repository), + the_repository->hash_algo)) { + rollback_lock_file(&lock_file); + clean_status_index_snapshot_release(&source); + return; + } + repo_update_index_if_able(the_repository, &lock_file); + clean_status_index_snapshot_release(&source); + return; + } fd = repo_hold_locked_index(the_repository, &lock_file, 0); if (fd < 0) return; discard_index(istate); repo_read_index(the_repository); - can_close_token = fstat_is_reliable() && - the_repository->config_values_private_.trust_ctime && - the_repository->config_values_private_.check_stat && - !getenv(INDEX_ENVIRONMENT) && - !istate->split_index && istate->sparse_index == INDEX_EXPANDED && - !unmerged_index(istate) && - fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && - fsmonitor_pending_token_from_provider(istate); + can_close_token = can_close_diff_fsmonitor_token(istate); refreshed = refresh_index(istate, REFRESH_QUIET | REFRESH_UNMERGED | (can_close_token ? REFRESH_IN_PROOF_EPOCH : 0), diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index eac59d4792abcd..cf8d1e8c249c05 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2218,7 +2218,7 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'diff preserves pending reset proofs in main and linked worktrees' ' + 'diff fully revalidates reset proofs in main and linked worktrees' ' test_when_finished "rm -rf builtin-diff-reset builtin-diff-reset-linked" && test_create_repo builtin-diff-reset && ( @@ -2267,14 +2267,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty "$gitdir/reset.actual" && test_trace2_data fsm_client query/trivial-response 1 \ <"$gitdir/reset.trace" && - test_trace2_data fsmonitor \ - untracked/provider-reset-pending 1 \ - <"$gitdir/reset.trace" && test_region index do_write_index "$gitdir/reset.trace" && - test_grep FSMN "$gitdir/index" && - test_grep FSUC "$gitdir/index" && - test_grep "pending:test:[2-9]" "$gitdir/index" && - test_fsmonitor_full_proof "$gitdir/index" pending && + test_fsmonitor_full_proof "$gitdir/index" paired && cp "$gitdir/index" "$gitdir/pending.index" && GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ @@ -2288,6 +2282,12 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ! test_trace2_data fsmonitor \ semantic/manifest-scan-count 1 \ <"$gitdir/pending-status.trace" && + ! test_trace2_data dir preload_untracked_cache/dirs \ + "[2-9][0-9]*" \ + <"$gitdir/pending-status.trace" && + ! test_trace2_data dir preload_untracked_cache/dirs \ + "1[0-9][0-9]*" \ + <"$gitdir/pending-status.trace" && ! test_region index do_write_index \ "$gitdir/pending-status.trace" && GIT_TEST_PRELOAD_INDEX=1 \ @@ -4031,15 +4031,21 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP "$worktree/cached/deep/tracked" && git -C "$worktree" fsmonitor--daemon start \ --start-timeout=10 && + cp "$gitdir/index" "$gitdir/locked.index" && + : >"$gitdir/index.lock" && + GIT_TRACE2_EVENT="$gitdir/locked.trace" \ + git -C "$worktree" diff >"$gitdir/locked" && + rm -f "$gitdir/index.lock" && + test_must_be_empty "$gitdir/locked" && + test_cmp_bin "$gitdir/locked.index" "$gitdir/index" && + ! test_region index do_write_index \ + "$gitdir/locked.trace" && GIT_TRACE2_EVENT="$gitdir/diff.trace" \ git -C "$worktree" diff >"$gitdir/diff" && test_must_be_empty "$gitdir/diff" && test_trace2_data fsm_client query/trivial-response 1 \ <"$gitdir/diff.trace" && - test_trace2_data fsmonitor \ - untracked/provider-reset-pending 1 \ - <"$gitdir/diff.trace" && - test_fsmonitor_full_proof "$gitdir/index" pending && + test_fsmonitor_full_proof "$gitdir/index" paired && cp "$gitdir/index" "$gitdir/readonly.index" && GIT_OPTIONAL_LOCKS=0 \ GIT_TRACE2_EVENT="$gitdir/readonly.trace" \ @@ -4052,6 +4058,12 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP ! test_trace2_data fsmonitor \ semantic/manifest-scan-count 1 \ <"$gitdir/readonly.trace" && + ! test_trace2_data dir preload_untracked_cache/dirs \ + "[2-9][0-9]*" \ + <"$gitdir/readonly.trace" && + ! test_trace2_data dir preload_untracked_cache/dirs \ + "1[0-9][0-9]*" \ + <"$gitdir/readonly.trace" && ! test_region index do_write_index \ "$gitdir/readonly.trace" && git -C "$worktree" fsmonitor--daemon stop && From 7c19bf467bbdcbf4080c195d86bf907fd1c78590 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 19:15:59 -0500 Subject: [PATCH 320/403] fsmonitor: retain checked manifests across scoped directory deltas 60077b92ac (status: preserve semantic history across scoped and index changes, 2026-08-11) lets a second closing query preserve verified sibling subtrees. A directory event cannot use that path while recovery is still establishing its semantic proof, so even a local change rebuilds the complete attribute manifest and rescans every directory. Permit a bounded directory check only around the second closing query, after the tracked semantic proof has closed. Require a continuous provider delta, the same pinned index and namespace, and an unchanged configuration. Reopen each affected attribute candidate beneath the worktree root and compare its contents, indexed fallback, or absence with the checked manifest. Keep the existing full fallback for lost or global events, hardlinks, unsupported indexes, and changed sources. The directory still dirties every tracked entry and its untracked cone. Directory timestamps never certify tracked contents. When this new shortcut is used, revalidate the completed scan's exclude-source proof before retaining sibling results: a directory event can also conceal a changed .gitignore. Do not add that extra work to ordinary file events. In a local 2,050-directory fixture, the second walk visits two directories instead of 2,051, and only one attribute manifest is built. Three alternating runs reduced median recovery time from 604 ms to 480 ms; the existing file-event path remained within 2% of baseline. Retain both paths in the regression test and synchronize attribute and ignore changes between the two scans to exercise the full fallback. --- clean-status-manifest.c | 224 ++++++++++++++++++++++++++++++++++++ clean-status-manifest.h | 6 + fsmonitor.c | 101 +++++++++++++--- t/t7519-status-fsmonitor.sh | 161 ++++++++++++++++++++++++-- wt-status.c | 13 ++- 5 files changed, 479 insertions(+), 26 deletions(-) diff --git a/clean-status-manifest.c b/clean-status-manifest.c index 4259385110f016..d668abf8da01b4 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -3,6 +3,7 @@ #include "attr.h" #include "attr-manifest.h" #include "bloom.h" +#include "clean-status.h" #include "clean-status-config.h" #include "clean-status-index.h" #include "clean-status-internal.h" @@ -22,8 +23,10 @@ #include "read-cache-ll.h" #include "replace-object.h" #include "repository.h" +#include "semantic-verify.h" #include "semantic-verify-internal.h" #include "sparse-index.h" +#include "string-list.h" #include "trace2.h" #include "tree.h" #include "tree-walk.h" @@ -38,6 +41,18 @@ struct invalidate_manifest_data { int invalidated; }; +/* + * Only the second provider query after a closed semantic proof may reuse its + * manifest. A directory delta must authenticate every affected attribute + * source, hash, and absence; its tracked and untracked entries remain dirty. + * Directory timestamps never establish tracked-file content. + */ +static struct { + struct index_state *index; + const struct semantic_verify_proof *proof; + unsigned reused : 1; +} manifest_directory_delta; + static int build_manifest(struct index_state *istate, struct strbuf *manifest, unsigned char *manifest_hash, @@ -139,6 +154,215 @@ static int find_manifest_entry( return -1; } +void clean_status_manifest_begin_directory_delta( + struct index_state *istate, const struct semantic_verify_proof *proof) +{ + if (manifest_directory_delta.index) + BUG("nested clean-status directory delta"); + if (!proof || !semantic_verify_proof_is_current(istate, proof)) + return; + manifest_directory_delta.index = istate; + manifest_directory_delta.proof = proof; + manifest_directory_delta.reused = 0; +} + +int clean_status_manifest_end_directory_delta(struct index_state *istate) +{ + int reused; + + if (manifest_directory_delta.index != istate) + return 0; + reused = manifest_directory_delta.reused; + manifest_directory_delta.index = NULL; + manifest_directory_delta.proof = NULL; + manifest_directory_delta.reused = 0; + return reused; +} + +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static int directory_attribute_source_matches( + struct index_state *istate, struct semantic_verify_path *path, + const char *name, size_t position) +{ + struct clean_status_state *state = istate->clean_status; + struct attr_manifest_entry entry; + const struct cache_entry *indexed; + const struct git_hash_algo *algo = istate->repo->hash_algo; + const char *basename; + unsigned char observed[GIT_MAX_RAWSZ]; + struct stat st; + int parent_fd, found, present, pos; + + if (semantic_verify_resolve_parent(path, name, position, + &parent_fd, &basename)) + return 0; + if (fstatat(parent_fd, basename, &st, AT_SYMLINK_NOFOLLOW)) { + if (errno != ENOENT) + return 0; + } else if (!S_ISREG(st.st_mode) || st.st_nlink != 1) { + return 0; + } + if (worktree_attr_source_read(path, name, position, algo, + observed, &found)) + return 0; + present = !find_manifest_entry(&state->manifest.current, + name, algo, &entry); + if (found) + return present && entry.source == ATTR_MANIFEST_WORKTREE && + !memcmp(entry.hash, observed, algo->rawsz); + if (!fstatat(parent_fd, basename, &st, AT_SYMLINK_NOFOLLOW) || + errno != ENOENT) + return 0; + pos = index_name_pos(istate, name, strlen(name)); + if (pos < 0) + return !present; + indexed = istate->cache[pos]; + if (!S_ISREG(indexed->ce_mode) || ce_stage(indexed) || + ce_skip_worktree(indexed) || ce_intent_to_add(indexed) || + (indexed->ce_flags & CE_VALID)) + return 0; + return present && entry.source == ATTR_MANIFEST_INDEX && + !memcmp(entry.hash, indexed->oid.hash, algo->rawsz); +} +#endif + +int clean_status_manifest_directory_unchanged( + struct index_state *istate, const char *directory) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + struct clean_status_state *state = istate->clean_status; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + struct clean_status_index_snapshot snapshot; + struct clean_status_config_digest config; + struct attr_fingerprint attrs; + struct string_list candidates = STRING_LIST_INIT_DUP; + struct strbuf candidate = STRBUF_INIT; + const struct git_hash_algo *algo = istate->repo->hash_algo; + unsigned int first, count = 0, namespace_unstable = 0; + size_t len; + int pos, pinned = 0, safe = 0; + uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + if (manifest_directory_delta.index != istate || + !manifest_directory_delta.proof || + !fsmonitor_pending_token_from_provider(istate) || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + !fstat_is_reliable() || getenv(INDEX_ENVIRONMENT) || + istate != istate->repo->index || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + repo_config_values(istate->repo)->apply_sparse_checkout || !state || + !state->current_config_valid || !state->current_semantic_valid || + !state->current_tracked_policy_valid || !state->current_attr_valid || + !state->config_enforced || + (state->filter_configured && !state->filter_scope_valid) || + !state->manifest.scan_count || !state->manifest.current_valid || + !state->manifest.checked || state->manifest.current_invalidated || + state->manifest.global_fallback || + (state->manifest.current_flags & required) != required || + !semantic_verify_proof_is_current( + istate, manifest_directory_delta.proof) || + clean_status_config_read_repository(istate->repo, &config) || + !config.finalized || + config.filter_configured != state->filter_configured || + config.semantic_config_explicit != state->current_semantic_explicit || + memcmp(config.hash, state->current_config_hash, algo->rawsz) || + memcmp(config.semantic_hash, state->current_semantic_hash, + algo->rawsz) || + memcmp(config.tracked_policy_hash, + state->current_tracked_policy_hash, algo->rawsz) || + attr_fingerprint_repository(istate->repo, &attrs) || + attrs.sources_present != state->current_attr_sources_present || + memcmp(attrs.content_hash, state->current_attr_hash, algo->rawsz) || + memcmp(attrs.namespace_hash, + state->current_attr_namespace_hash, algo->rawsz) || + !attr_manifest_valid(state->manifest.current.buf, + state->manifest.current.len, algo)) + goto done; + + len = strlen(directory); + if (!len || directory[len - 1] != '/') + goto done; + pos = index_name_pos(istate, directory, len); + if (pos >= 0) + goto done; + first = -pos - 1; + if (first >= istate->cache_nr || + !starts_with(istate->cache[first]->name, directory)) + goto done; + if (clean_status_index_snapshot_pin_proof_epoch(&snapshot, istate)) + goto done; + pinned = 1; + if (semantic_verify_root_init(istate->repo, &root)) + goto done; + path = semantic_verify_path_new(root); + if (!path) + goto done; + + strbuf_addstr(&candidate, directory); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + string_list_insert(&candidates, candidate.buf); + for (unsigned int i = first; i < istate->cache_nr && + starts_with(istate->cache[i]->name, directory); i++) { + const struct cache_entry *ce = istate->cache[i]; + const char *slash = ce->name + len; + + if (++count > 64 || ce_stage(ce) || ce_skip_worktree(ce) || + ce_intent_to_add(ce) || (ce->ce_flags & CE_VALID) || + S_ISSPARSEDIR(ce->ce_mode)) + goto done; + while ((slash = strchr(slash, '/')) != NULL) { + strbuf_reset(&candidate); + strbuf_add(&candidate, ce->name, + slash - ce->name + 1); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + string_list_insert(&candidates, candidate.buf); + if (candidates.nr > 64) + goto done; + slash++; + } + } + for (size_t i = 0; i < candidates.nr; i++) + if (!directory_attribute_source_matches( + istate, path, candidates.items[i].string, + first + i)) + goto done; + + semantic_verify_path_free(path, &namespace_unstable, NULL); + path = NULL; + if (namespace_unstable || !semantic_verify_root_stable(root) || + !clean_status_index_snapshot_still_matches_proof_epoch( + &snapshot, istate) || + !semantic_verify_proof_is_current( + istate, manifest_directory_delta.proof) || + attr_fingerprint_repository(istate->repo, &attrs) || + attrs.sources_present != state->current_attr_sources_present || + memcmp(attrs.content_hash, state->current_attr_hash, algo->rawsz) || + memcmp(attrs.namespace_hash, + state->current_attr_namespace_hash, algo->rawsz)) + goto done; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/manifest-directory-reused", 1); + manifest_directory_delta.reused = 1; + safe = 1; + +done: + if (path) + semantic_verify_path_free(path, NULL, NULL); + semantic_verify_root_clear(root); + if (pinned) + clean_status_index_snapshot_release(&snapshot); + string_list_clear(&candidates, 0); + strbuf_release(&candidate); + return safe; +#else + (void)istate; + (void)directory; + return 0; +#endif +} + int clean_status_manifest_reconcile_deleted_attribute( struct index_state *istate, const char *name) { diff --git a/clean-status-manifest.h b/clean-status-manifest.h index ba90498d56c247..81cad1124e6af9 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -5,6 +5,7 @@ #include "strbuf.h" struct index_state; +struct semantic_verify_proof; struct clean_status_manifest_state { struct strbuf disk; @@ -31,6 +32,11 @@ void clean_status_manifest_adopt_disk( struct clean_status_manifest_state *state); int clean_status_manifest_refresh(struct index_state *istate, struct clean_status_manifest_state *state); +void clean_status_manifest_begin_directory_delta( + struct index_state *istate, const struct semantic_verify_proof *proof); +int clean_status_manifest_end_directory_delta(struct index_state *istate); +int clean_status_manifest_directory_unchanged( + struct index_state *istate, const char *directory); int clean_status_manifest_reconcile_deleted_attribute( struct index_state *istate, const char *path); int clean_status_manifest_reconcile_display_only_attribute( diff --git a/fsmonitor.c b/fsmonitor.c index 7026d2148d3fb0..1800ae7174f6a0 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -20,6 +20,7 @@ #include "run-command.h" #include "strbuf.h" #include "trace2.h" +#include "wrapper.h" #define INDEX_EXTENSION_VERSION1 (1) #define INDEX_EXTENSION_VERSION2 (2) @@ -410,7 +411,8 @@ void fsmonitor_invalidate_cache_entry(struct cache_entry *ce) } static size_t handle_path_with_trailing_slash( - struct index_state *istate, const char *name, int pos); + struct index_state *istate, const char *name, int pos, + int directory_is_semantically_safe); int fsmonitor_invalidate_attributes_path(struct index_state *istate, const char *name) @@ -547,7 +549,9 @@ static size_t handle_using_dir_name_hash_icase( pos = index_name_pos(istate, canonical_path.buf, canonical_path.len); nr_in_cone = handle_path_with_trailing_slash( - istate, canonical_path.buf, pos); + istate, canonical_path.buf, pos, + clean_status_directory_event_is_semantically_safe( + istate, canonical_path.buf)); strbuf_release(&canonical_path); return nr_in_cone; } @@ -603,7 +607,9 @@ static size_t handle_path_without_trailing_slash( strbuf_addch(&work_path, '/'); pos = index_name_pos(istate, work_path.buf, work_path.len); nr_in_cone = handle_path_with_trailing_slash( - istate, work_path.buf, pos); + istate, work_path.buf, pos, + clean_status_directory_event_is_semantically_safe( + istate, work_path.buf)); strbuf_release(&work_path); return nr_in_cone; } @@ -642,7 +648,8 @@ static size_t handle_path_without_trailing_slash( * untracked or case-incorrect. */ static size_t handle_path_with_trailing_slash( - struct index_state *istate, const char *name, int pos) + struct index_state *istate, const char *name, int pos, + int directory_is_semantically_safe) { int i; size_t nr_in_cone = 0; @@ -667,8 +674,7 @@ static size_t handle_path_with_trailing_slash( nr_in_cone++; } - if (nr_in_cone && - !clean_status_directory_event_is_semantically_safe(istate, name)) { + if (nr_in_cone && !directory_is_semantically_safe) { /* * A matched directory event may stand in for a nested * attribute-file change. @@ -681,7 +687,8 @@ static size_t handle_path_with_trailing_slash( return nr_in_cone; } -static void fsmonitor_refresh_callback(struct index_state *istate, char *name) +static void fsmonitor_refresh_callback(struct index_state *istate, char *name, + int closing_delta) { int len = strlen(name); int pos; @@ -724,10 +731,13 @@ static void fsmonitor_refresh_callback(struct index_state *istate, char *name) fsmonitor_invalidate_attributes_path(istate, name); } directory_is_semantically_safe = name[len - 1] == '/' && - clean_status_directory_event_is_semantically_safe(istate, name); + (clean_status_directory_event_is_semantically_safe(istate, name) || + (closing_delta && + clean_status_manifest_directory_unchanged(istate, name))); if (name[len - 1] == '/') - nr_in_cone = handle_path_with_trailing_slash(istate, name, pos); + nr_in_cone = handle_path_with_trailing_slash( + istate, name, pos, directory_is_semantically_safe); else nr_in_cone = handle_path_without_trailing_slash(istate, name, pos); if (pos < 0 && nr_in_cone && !directory_is_semantically_safe) @@ -907,6 +917,45 @@ enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( return FSMONITOR_QUERY_ERROR; } +static int fsmonitor_test_query_barrier(size_t query_nr) +{ + const char *at = getenv("GIT_TEST_FSMONITOR_QUERY_BARRIER_AT"); + const char *ready = getenv("GIT_TEST_FSMONITOR_QUERY_BARRIER_READY"); + const char *resume = getenv("GIT_TEST_FSMONITOR_QUERY_BARRIER_RESUME"); + struct stat st; + uintmax_t selected; + char *end; + char resumed; + int fd, ret; + + if (!at && !ready && !resume) + return 0; + if (!at || !ready || !resume || !*at || !*ready || !*resume || + !isdigit((unsigned char)*at)) + return -1; + errno = 0; + selected = strtoumax(at, &end, 10); + if (errno || *end || !selected) + return -1; + if (selected != (uintmax_t)query_nr) + return 0; + if (lstat(resume, &st) || !S_ISFIFO(st.st_mode)) + return -1; + fd = open(ready, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (fd < 0) + return -1; + ret = write_in_full(fd, "ready\n", 6) == 6 ? 0 : -1; + if (close(fd) || ret) + return -1; + fd = open(resume, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + ret = read_in_full(fd, &resumed, 1) == 1 ? 0 : -1; + if (close(fd)) + ret = -1; + return ret; +} + enum fsmonitor_query_outcome query_builtin_fsmonitor( const char *since_token, struct fsmonitor_query_result *result) { @@ -927,6 +976,8 @@ enum fsmonitor_query_outcome query_builtin_fsmonitor( if (query_nr >= strlen(test_sequence)) return FSMONITOR_QUERY_ERROR; outcome = test_sequence[query_nr++]; + if (fsmonitor_test_query_barrier(query_nr)) + return FSMONITOR_QUERY_ERROR; if (outcome == 'E') return FSMONITOR_QUERY_ERROR; @@ -981,7 +1032,7 @@ static int fsmonitor_hardlink_inode_cmp(const void *unused UNUSED, } static int apply_fsmonitor_paths(struct index_state *istate, - const struct strbuf *paths) + const struct strbuf *paths, int closing_delta) { const char *p = paths->buf; const char *end = paths->buf + paths->len; @@ -990,6 +1041,20 @@ static int apply_fsmonitor_paths(struct index_state *istate, unsigned int matches = 0; int count = 0; + if (closing_delta) { + for (const char *changed = p; changed < end; + changed += strlen(changed) + 1) { + size_t changed_len = strlen(changed); + + if (!strcmp(changed, FSMONITOR_PATH_GLOBAL_INVALIDATE) || + fsmonitor_parse_hardlink_inode( + changed, changed_len, NULL)) { + closing_delta = 0; + break; + } + } + } + while (p < end) { size_t len = strlen(p); uint32_t inode; @@ -997,12 +1062,13 @@ static int apply_fsmonitor_paths(struct index_state *istate, if (parsed < 0) { fsmonitor_refresh_callback( - istate, (char *)FSMONITOR_PATH_GLOBAL_INVALIDATE); + istate, (char *)FSMONITOR_PATH_GLOBAL_INVALIDATE, 0); count++; goto done; } if (!parsed) { - fsmonitor_refresh_callback(istate, (char *)p); + fsmonitor_refresh_callback( + istate, (char *)p, closing_delta); count++; } else if (!hashmap_get_entry_from_hash( &inodes, memhash(&inode, sizeof(inode)), &inode, @@ -1031,7 +1097,7 @@ static int apply_fsmonitor_paths(struct index_state *istate, &inodes, memhash(&inode, sizeof(inode)), &inode, struct fsmonitor_hardlink_inode, ent)) continue; - fsmonitor_refresh_callback(istate, ce->name); + fsmonitor_refresh_callback(istate, ce->name, 0); matches++; count++; } @@ -1453,20 +1519,21 @@ void refresh_fsmonitor(struct index_state *istate) int count = 0; if (fsm_mode == FSMONITOR_MODE_IPC) { - count = apply_fsmonitor_paths(istate, &query_result); + count = apply_fsmonitor_paths(istate, &query_result, 0); } else { buf = query_result.buf; for (i = bol; i < query_result.len; i++) { if (buf[i] != '\0') continue; if (i > bol) { - fsmonitor_refresh_callback(istate, buf + bol); + fsmonitor_refresh_callback( + istate, buf + bol, 0); count++; } bol = i + 1; } if (bol < query_result.len) { - fsmonitor_refresh_callback(istate, buf + bol); + fsmonitor_refresh_callback(istate, buf + bol, 0); count++; } } @@ -1659,7 +1726,7 @@ enum fsmonitor_token_result fsmonitor_query_pending_token( goto done; } - count = apply_fsmonitor_paths(istate, &result.paths); + count = apply_fsmonitor_paths(istate, &result.paths, 1); if (istate->untracked) istate->untracked->use_fsmonitor = !!untracked_ready; trace2_data_intmax("fsmonitor", istate->repo, diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index cf8d1e8c249c05..45914588c794ec 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -633,6 +633,28 @@ test_fsmonitor_full_proof () { EOF } +wait_for_fsmonitor_query_barrier () { + for attempt in $(test_seq 1 500) + do + if test "$(cat "$1" 2>/dev/null)" = ready + then + return 0 + fi + kill -0 "$2" 2>/dev/null || return 1 + sleep 0.01 + done + return 1 +} + +cleanup_fsmonitor_query_barrier () { + if test -n "${fsmonitor_query_pid-}" + then + kill "$fsmonitor_query_pid" 2>/dev/null || : + wait "$fsmonitor_query_pid" 2>/dev/null || : + fsmonitor_query_pid= + fi +} + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'bare status reuses a current tracked fsmonitor proof' ' test_when_finished "rm -rf builtin-tracked-clean" && @@ -3100,10 +3122,13 @@ test_expect_success HARDLINKS,!MINGW,!CYGWIN \ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'second closing-query change preserves verified sibling subtrees' ' - test_when_finished "rm -rf second-query-changed" && - test_create_repo second-query-changed && - ( - cd second-query-changed && + test_when_finished \ + "rm -rf second-query-changed-file second-query-changed-directory" && + for event in file directory + do + test_create_repo "second-query-changed-$event" && + ( + cd "second-query-changed-$event" && sane_unset GIT_TEST_SPLIT_INDEX && mkdir cached && test_write_lines "*.ignored" >.gitignore && @@ -3144,8 +3169,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ -c core.trustctime=true -c core.checkStat=default \ status --porcelain=v2 >.git/expect && + if test "$event" = directory + then + changed_path=cached/ + else + changed_path=cached/tracked + fi && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ - GIT_TEST_FSMONITOR_QUERY_PATH=cached/tracked \ + GIT_TEST_FSMONITOR_QUERY_PATH="$changed_path" \ GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ GIT_TRACE2_PERF="$PWD/.git/status.perf" \ git status --porcelain=v2 >.git/actual && @@ -3160,6 +3191,19 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/status.trace && test_trace2_data fsmonitor token_closure/apply_count 1 \ <.git/status.trace && + if test "$event" = directory + then + test_trace2_data fsmonitor \ + semantic/manifest-directory-reused 1 \ + <.git/status.trace + else + ! test_trace2_data fsmonitor \ + semantic/manifest-directory-reused 1 \ + <.git/status.trace + fi && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/status.trace >.git/manifest-scans && + test_line_count = 1 .git/manifest-scans && test_trace2_data status \ fsmonitor_token/reused-semantic-subtrees 1 \ <.git/status.trace && @@ -3196,9 +3240,106 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/status.trace && test_trace2_data fsmonitor token_closure/accepted 1 \ <.git/status.trace && - test_grep FSCF .git/index && - test_grep FSUC .git/index - ) + test_fsmonitor_full_proof .git/index paired + ) || return 1 + done +' + +test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'directory closure rejects raced attributes and rechecks raced excludes' ' + test_when_finished "rm -rf directory-race-attributes directory-race-ignore" && + for mutation in attributes ignore + do + test_create_repo "directory-race-$mutation" && + ( + cd "directory-race-$mutation" && + sane_unset GIT_TEST_SPLIT_INDEX && + fsmonitor_query_pid= && + trap cleanup_fsmonitor_query_barrier 0 && + mkdir cached && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + printf "aaaa\n" >cached/tracked && + test_write_lines ignored >cached/junk.ignored && + for sibling in $(test_seq 1 8) + do + mkdir "sibling-$sibling" && + test_write_lines "$sibling" \ + >"sibling-$sibling/tracked" || return 1 + done && + git add .gitignore cached/.gitignore cached/tracked sibling-* && + git commit -qm base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test_write_lines visible >sibling-1/visible && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/prime && + test_grep "^? sibling-1/visible$" .git/prime && + test-tool chmtime =-60 cached/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get cached/tracked) && + printf "bbbb\n" >cached/tracked && + test-tool chmtime =$mtime cached/tracked && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid cached/tracked && + ready="$PWD/.git/provider.ready" && + resume="$PWD/.git/provider.resume" && + mkfifo "$resume" && + { + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/ \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_AT=3 \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_READY="$ready" \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 \ + >.git/actual 2>.git/error & + fsmonitor_query_pid=$! + } && + wait_for_fsmonitor_query_barrier \ + "$ready" "$fsmonitor_query_pid" && + if test "$mutation" = attributes + then + test_write_lines "tracked text eol=crlf" \ + >cached/.gitattributes + else + test_write_lines "!junk.ignored" >cached/.gitignore + fi && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 >.git/expect && + printf x >"$resume" && + wait "$fsmonitor_query_pid" && + fsmonitor_query_pid= && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.M .* cached/tracked$" .git/actual && + test_grep "^? sibling-1/visible$" .git/actual && + if test "$mutation" = attributes + then + test_grep "^? cached/\\.gitattributes$" \ + .git/actual && + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 2 \ + <.git/status.trace && + ! test_trace2_data fsmonitor \ + semantic/manifest-directory-reused 1 \ + <.git/status.trace + else + test_grep "^1 \\.M .* cached/\\.gitignore$" \ + .git/actual && + test_grep "^? cached/junk\\.ignored$" .git/actual + fi && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace + ) || return 1 + done ' test_expect_success MACOS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ @@ -3337,6 +3478,10 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_grep "^1 \.M .* cached/tracked$" .git/actual && test_trace2_data fsmonitor apply/global-invalidation 1 \ <.git/status.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 2 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-directory-reused 1 \ + <.git/status.trace && test_trace2_data fsmonitor semantic/strong-invalidation 1 \ <.git/status.trace >.git/strong-invalidations && test_line_count = 2 .git/strong-invalidations && diff --git a/wt-status.c b/wt-status.c index c24c42f71350c1..603fcbd3a67704 100644 --- a/wt-status.c +++ b/wt-status.c @@ -13,6 +13,7 @@ #include "commit.h" #include "clean-status.h" #include "clean-status-index.h" +#include "clean-status-manifest.h" #include "diff.h" #include "environment.h" #include "exclude-source-proof.h" @@ -2067,6 +2068,8 @@ wt_status_close_semantic_fsmonitor_token( } if (defer_untracked) { + int directory_delta_reused; + closure->untracked_ready = wt_status_stage_untracked(closure); closure->untracked_proof_complete = @@ -2081,15 +2084,23 @@ wt_status_close_semantic_fsmonitor_token( /* A second query closes the subsequent untracked scan. */ closure->queries++; + clean_status_manifest_begin_directory_delta(istate, *proof); result = fsmonitor_query_pending_token( istate, wt_status_untracked_cache_valid(closure)); + directory_delta_reused = + clean_status_manifest_end_directory_delta(istate); if (result != FSMONITOR_TOKEN_CLEAN) { + /* Only directory reuse adds an unobserved exclude risk. */ int reuse_semantic_subtrees = result == FSMONITOR_TOKEN_CHANGED && !clean_status_filter_scope_needs_validation(istate) && !clean_status_worktree_manifest_needs_refresh(istate) && - semantic_verify_proof_is_current(istate, *proof); + semantic_verify_proof_is_current(istate, *proof) && + (!directory_delta_reused || + (s->certify_exclude_proof && + exclude_source_proof_validate( + s->certify_exclude_proof))); wt_status_discard_staged_untracked(closure); if (reuse_semantic_subtrees) { From ef6cdb50392925e077500c4c1ab203c3ee12cd1c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 19:16:13 -0500 Subject: [PATCH 321/403] diff: reuse pinned observations during provider-reset repair dfc55f903c (diff: repair provider-reset history before publishing its index, 2026-08-15) repairs tracked and untracked history together after the provider loses its token. The repair rereads the physical index, however, discarding the pending provider token and attribute manifest that diff has already obtained. The reread then repeats both operations. Keep the existing index state when its pinned physical source still matches, its logical changes are limited to acceleration metadata, and its configuration, attributes, and pending proof epoch remain valid. Clear every tracked entry's up-to-date and fsmonitor-valid bits, along with any bulk result: observations made before the repair epoch cannot certify tracked contents. The ordinary complete tracked and untracked validation still runs before the single index write. Retain the reread fallback, late optional index lock, and final pinned path check. A concurrent writer must win even with index.skipHash set. The regression test now requires one index read and manifest scan, and pauses a repair while another process stages a file. Across three local real-daemon runs with 2,050 sibling directories, index reads and complete manifest scans fell from two to one. Median repair time fell from 138 ms to 55 ms. Each run published a fully paired proof and kept read-only followers correct without another full directory scan. --- builtin/diff.c | 47 +++++++++++++++++++++++-- t/t7519-status-fsmonitor.sh | 68 ++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/builtin/diff.c b/builtin/diff.c index 0838d8edd0e561..7aa66f7fda581a 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -8,6 +8,7 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "attr-fingerprint.h" #include "clean-status.h" #include "clean-status-index.h" #include "clean-status-sidecar.h" @@ -21,6 +22,7 @@ #include "fsmonitor-ll.h" #include "fsmonitor-settings.h" #include "tag.h" +#include "trace2.h" #include "diff.h" #include "diff-merges.h" #include "diffcore.h" @@ -267,6 +269,45 @@ static int can_close_diff_fsmonitor_token(struct index_state *istate) fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC; } +static int reuse_diff_recovery_observations( + struct index_state *istate, + const struct clean_status_index_snapshot *source) +{ + struct attr_source_snapshot *attrs = NULL; + struct clean_status_proof_epoch *epoch = NULL; + int reused = 0; + unsigned int i; + + if (!clean_status_index_snapshot_still_matches_proof_epoch( + source, istate) || + !clean_status_index_can_reuse_source_logical_hash(istate) || + !clean_status_fsmonitor_semantic_baseline_pending(istate) || + clean_status_fsmonitor_strong_mismatch(istate) || + clean_status_filter_scope_needs_validation(istate) || + clean_status_manifest_global_fallback(istate) || + clean_status_worktree_manifest_needs_refresh(istate) || + clean_status_capture_attr_snapshot(istate, &attrs) || !attrs) + goto done; + + epoch = clean_status_capture_proof_epoch(istate, attrs, 0); + if (!epoch || !clean_status_proof_epoch_prime_matches(istate, epoch)) + goto done; + + /* Observations made before the proof epoch cannot certify tracked files. */ + for (i = 0; i < istate->cache_nr; i++) + istate->cache[i]->ce_flags &= + ~(CE_UPTODATE | CE_FSMONITOR_VALID); + preload_index_bulk_result_clear(istate); + reused = 1; + trace2_data_intmax("diff", istate->repo, + "recovery/reused-provider-observations", 1); + +done: + clean_status_release_proof_epoch(epoch); + attr_source_snapshot_free(attrs); + return reused; +} + static void refresh_index_quietly(void) { struct lock_file lock_file = LOCK_INIT; @@ -306,8 +347,10 @@ static void refresh_index_quietly(void) return; } clean_status_set_config_digest(the_repository, &digest); - discard_index(istate); - repo_read_index(the_repository); + if (!reuse_diff_recovery_observations(istate, &source)) { + discard_index(istate); + repo_read_index(the_repository); + } if (!clean_status_index_snapshot_still_matches_proof_epoch( &source, istate)) { clean_status_index_snapshot_release(&source); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 45914588c794ec..1b450d5228c1dc 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2282,13 +2282,22 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_trace2_data fsm_client query/trivial-response 1 \ <"$gitdir/readonly.trace" && GIT_TEST_PRELOAD_INDEX=1 \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TTCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/reset.trace" \ git -C "$worktree" diff \ >"$gitdir/reset.actual" && test_must_be_empty "$gitdir/reset.actual" && test_trace2_data fsm_client query/trivial-response 1 \ <"$gitdir/reset.trace" && + test_trace2_data diff recovery/reused-provider-observations 1 \ + <"$gitdir/reset.trace" && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/reset.trace" >"$gitdir/reset.manifests" && + test_line_count = 1 "$gitdir/reset.manifests" && + test_grep \ + "\"event\":\"region_enter\".*\"category\":\"index\",\"label\":\"do_read_index\"" \ + "$gitdir/reset.trace" >"$gitdir/reset.reads" && + test_line_count = 1 "$gitdir/reset.reads" && test_region index do_write_index "$gitdir/reset.trace" && test_fsmonitor_full_proof "$gitdir/index" paired && cp "$gitdir/index" "$gitdir/pending.index" && @@ -2334,6 +2343,63 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'provider-reset diff never overwrites a competing skipHash writer' ' + test_when_finished "rm -rf diff-recovery-competing-writer" && + test_create_repo diff-recovery-competing-writer && + ( + cd diff-recovery-competing-writer && + sane_unset GIT_TEST_SPLIT_INDEX && + fsmonitor_query_pid= && + trap cleanup_fsmonitor_query_barrier 0 && + test_commit base tracked && + test_commit sibling sibling && + git config index.skipHash true && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_trailing_hash .git/index >.git/index.hash && + test_oid zero >.git/zero && + test_cmp .git/zero .git/index.hash && + test-tool chmtime =-60 tracked && + ready="$PWD/.git/provider.ready" && + resume="$PWD/.git/provider.resume" && + mkfifo "$resume" && + { + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_AT=2 \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_READY="$ready" \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$PWD/.git/diff.trace" \ + git diff >.git/actual 2>.git/error & + fsmonitor_query_pid=$! + } && + wait_for_fsmonitor_query_barrier \ + "$ready" "$fsmonitor_query_pid" && + test_trace2_data diff recovery/reused-provider-observations 1 \ + <.git/diff.trace && + test_path_is_missing .git/index.lock && + test_write_lines competing >sibling && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + add sibling && + cp .git/index .git/competing.index && + printf x >"$resume" && + wait "$fsmonitor_query_pid" && + fsmonitor_query_pid= && + test_must_be_empty .git/actual && + test_cmp_bin .git/competing.index .git/index && + ! test_region index do_write_index .git/diff.trace && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + diff --cached --name-only >.git/staged && + test_grep "^sibling$" .git/staged + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin trivial closure can rescan and accept' ' test_when_finished "rm -rf builtin-closure-trivial" && From b4bd792a518fa087cbf394c017ac5ca427b48be7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 21:43:12 -0500 Subject: [PATCH 322/403] dir: share normalized exclude hashes across identical blobs cae38fd1a9 (status: close fsmonitor tokens around complete status scans, 2026-07-29) rechecks cached ignore sources before pruning an fsmonitor-valid untracked tree. Older UNTR writers hash the newline appended for parsing, however, so their cached OIDs do not match the indexed blobs. A read-only status cannot persist the canonical OIDs and repeats those content checks on every invocation. Group eligible repeated index OIDs before starting the existing workers. When a worker has read a source coherently and proved both its raw and newline-appended hashes, publish that content relation for the group. Other members still require their own complete, non-racy singleton-file stat match and the normal conversion check. Unique blobs, failed mutex initialization, hardlinks, weak stat settings, and conversions retain the existing content-verification path. No extra reads or object-store lookups are needed to populate the cache. Keep both historical newline forms in a repeated read-only regression. In a 1,024-directory fixture with identical 32 KiB ignore files, three alternating runs reduced median status time from 71.2 ms to 35.9 ms and avoided 1,020 content checks. An all-unique control stayed within 1.3% of baseline. The preserved large-worktree index avoided about 883 repeated reads, with unchanged status output and physical index bytes. --- dir.c | 127 +++++++++++++++++++++++++++++++-- t/t7519-status-fsmonitor.sh | 138 ++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/dir.c b/dir.c index 4ef901ca2d66bf..6dd004d3c81fc8 100644 --- a/dir.c +++ b/dir.c @@ -19,6 +19,7 @@ #include "gettext.h" #include "name-hash.h" #include "object-file.h" +#include "oidmap.h" #include "path.h" #include "path-namespace.h" #include "refs.h" @@ -76,8 +77,22 @@ struct cached_dir { struct untracked_cache_dir *ucd; }; +/* + * Older UNTR writers hash the extra newline used by the ignore parser. + * A checked file read can establish that representation for every path + * with the same indexed blob. Workers share only this content relation; + * each path still needs its own strong stat and conversion checks. + */ +struct normalized_exclude_oid { + struct oidmap_entry ent; + struct object_id normalized; + size_t candidates; + unsigned int valid : 1; +}; + struct untracked_cache_preload_task { struct untracked_cache_dir *ucd; + struct normalized_exclude_oid *normalized_oid; char *path; struct stat_data stat_data; struct object_id exclude_oid; @@ -88,6 +103,7 @@ struct untracked_cache_preload_task { unsigned int exclude_matches : 1; unsigned int exclude_index_present : 1; unsigned int exclude_index_candidate : 1; + unsigned int exclude_index_normalized_equivalent : 1; unsigned int exclude_index_matches : 1; unsigned int exclude_index_content_matches : 1; unsigned int normalize_exclude_oid : 1; @@ -111,14 +127,18 @@ struct untracked_cache_preload { const struct pathspec *pathspec; struct untracked_cache_preload_task *tasks; struct object_id *exclude_index_oids; + struct oidmap normalized_excludes; + pthread_mutex_t normalized_mutex; struct untracked_cache_preload_data *data; struct cache_time index_timestamp; char *exclude_per_dir; size_t nr; + size_t normalized_objects; int threads; unsigned int dir_flags; uint64_t started_at; unsigned int fsmonitor_excludes_only : 1; + unsigned int normalized_mutex_initialized : 1; }; #define UNTRACKED_CACHE_MAX_OVERLAP_THREADS 6 @@ -399,9 +419,64 @@ static void preload_fsmonitor_excludes_from_index( */ task->stat_data = ce->ce_stat_data; task->exclude_index_candidate = 1; + if (preload->normalized_mutex_initialized && + fstat_is_reliable() && !ce_stage(ce) && + !oideq(&ce->oid, &task->exclude_oid)) { + struct normalized_exclude_oid *entry = + oidmap_get(&preload->normalized_excludes, &ce->oid); + + if (!entry) { + CALLOC_ARRAY(entry, 1); + oidcpy(&entry->ent.oid, &ce->oid); + oidmap_put(&preload->normalized_excludes, entry); + } + entry->candidates++; + task->normalized_oid = entry; + } next: strbuf_release(&exclude_path); } + /* A unique blob has no other observation to share. */ + for (i = 0; i < preload->nr; i++) { + struct untracked_cache_preload_task *task = &preload->tasks[i]; + + if (task->normalized_oid && task->normalized_oid->candidates < 2) + task->normalized_oid = NULL; + } +} + +static int preload_normalized_exclude_matches( + struct untracked_cache_preload *preload, + const struct untracked_cache_preload_task *task) +{ + const struct normalized_exclude_oid *entry = task->normalized_oid; + int matches; + + if (!entry) + return 0; + pthread_mutex_lock(&preload->normalized_mutex); + matches = entry->valid && oideq(&entry->normalized, &task->exclude_oid); + pthread_mutex_unlock(&preload->normalized_mutex); + return matches; +} + +static void preload_remember_normalized_exclude( + struct untracked_cache_preload *preload, + struct untracked_cache_preload_task *task, + const struct object_id *raw, const struct object_id *normalized) +{ + struct normalized_exclude_oid *entry = task->normalized_oid; + + if (!entry || !oideq(raw, &entry->ent.oid) || + !oideq(normalized, &task->exclude_oid)) + return; + pthread_mutex_lock(&preload->normalized_mutex); + if (!entry->valid) { + oidcpy(&entry->normalized, normalized); + entry->valid = 1; + preload->normalized_objects++; + } + pthread_mutex_unlock(&preload->normalized_mutex); } static struct untracked_cache_preload *untracked_cache_preload_start_1( @@ -437,6 +512,10 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( fsmonitor_excludes_only, preload->pathspec); strbuf_release(&path); if (fsmonitor_excludes_only) { + if (!pthread_mutex_init(&preload->normalized_mutex, NULL)) { + preload->normalized_mutex_initialized = 1; + oidmap_init(&preload->normalized_excludes, 0); + } CALLOC_ARRAY(preload->exclude_index_oids, preload->nr); preload_fsmonitor_excludes_from_index(preload); } @@ -533,7 +612,8 @@ static void *preload_untracked_cache_thread(void *_data) struct stat st; if (preload->fsmonitor_excludes_only) { - struct object_id raw_oid; + struct object_id raw_oid, normalized_oid; + int normalized_equivalent; if (!preload->exclude_per_dir) continue; @@ -543,9 +623,12 @@ static void *preload_untracked_cache_thread(void *_data) strbuf_addch(&exclude_path, '/'); strbuf_addstr(&exclude_path, preload->exclude_per_dir); + normalized_equivalent = + preload_normalized_exclude_matches(preload, task); if (task->exclude_index_candidate && - oideq(&preload->exclude_index_oids[i], - &task->exclude_oid) && + (normalized_equivalent || + oideq(&preload->exclude_index_oids[i], + &task->exclude_oid)) && !lstat(exclude_path.buf, &st) && cached_exclude_file_matches_index_stat( &task->stat_data, &st)) { @@ -553,6 +636,9 @@ static void *preload_untracked_cache_thread(void *_data) task->exclude_index_matches = 1; task->exclude_index_content_matches = 1; task->exclude_matches = 1; + task->exclude_index_normalized_equivalent = + normalized_equivalent; + task->normalize_exclude_oid = normalized_equivalent; strbuf_release(&exclude_path); continue; } @@ -560,7 +646,11 @@ static void *preload_untracked_cache_thread(void *_data) preload->repo->hash_algo, exclude_path.buf, &task->exclude_oid, &raw_oid, - NULL, &task->exclude_mode); + task->normalized_oid ? &normalized_oid : NULL, + &task->exclude_mode); + if (task->exclude_matches && task->normalized_oid) + preload_remember_normalized_exclude( + preload, task, &raw_oid, &normalized_oid); if (task->exclude_matches && task->exclude_index_present && oideq(&preload->exclude_index_oids[i], @@ -725,6 +815,14 @@ static void untracked_cache_preload_free( trace2_data_intmax("dir", preload->repo, "preload_untracked_cache/wall_us", (getnanotime() - preload->started_at) / 1000); + if (preload->fsmonitor_excludes_only) + trace2_data_intmax("dir", preload->repo, + "preload_untracked_cache/index-normalized-objects", + preload->normalized_objects); + if (preload->normalized_mutex_initialized) { + pthread_mutex_destroy(&preload->normalized_mutex); + oidmap_clear(&preload->normalized_excludes, 1); + } free(preload->data); for (i = 0; i < preload->nr; i++) free(preload->tasks[i].path); @@ -820,10 +918,16 @@ static int update_preloaded_exclude_index_uptodate( ce = preload->istate->cache[pos]; if (!ce_stage(ce) && S_ISREG(ce->ce_mode) && oideq(&ce->oid, &preload->exclude_index_oids[task_nr])) { - if (task->exclude_index_matches) { + if (task->exclude_index_matches && + !task->exclude_index_normalized_equivalent) { converts = 0; content_matches = 1; } else { + /* + * The normalized relation only describes raw blob bytes. + * Keep conversion checks on the main thread and revalidate + * the actual file when the current path converts. + */ converts = would_convert_to_git( preload->istate, path.buf); content_matches = converts ? @@ -908,6 +1012,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, if (preload->fsmonitor_excludes_only) { size_t index_matches = 0; + size_t normalized_matches = 0; size_t invalidated = 0; size_t index_uptodate = 0; size_t normalized = 0; @@ -921,6 +1026,7 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, task->exclude_matches; int exclude_invalidated = !exclude_matches; int exclude_revalidated; + int index_marked; if (!exclude_matches) invalidate_gitignore(uc, task->ucd); @@ -928,10 +1034,15 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, if (task->exclude_index_matches) index_matches++; } - index_uptodate += + index_marked = update_preloaded_exclude_index_uptodate( preload, task, i, &normalized, &invalidated, &exclude_revalidated); + index_uptodate += index_marked; + if (index_marked && task->exclude_index_matches && + task->exclude_index_normalized_equivalent && + exclude_revalidated < 0) + normalized_matches++; if (exclude_matches && exclude_revalidated == 0) { invalidate_gitignore(uc, task->ucd); exclude_invalidated = 1; @@ -945,6 +1056,10 @@ int untracked_cache_preload_finish(struct untracked_cache_preload *preload, "dir", istate->repo, "preload_untracked_cache/index-excludes", index_matches); + trace2_data_intmax( + "dir", istate->repo, + "preload_untracked_cache/index-normalized-excludes", + normalized_matches); trace2_data_intmax( "dir", istate->repo, "preload_untracked_cache/index-uptodate", diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 1b450d5228c1dc..3adb495d107543 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -430,6 +430,144 @@ test_expect_success UNTRACKED_CACHE \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'historical normalized excludes reuse authenticated index stats' ' + test_when_finished "rm -rf normalized-excludes-lf normalized-excludes-no-lf" && + for ending in lf no-lf + do + test_create_repo "normalized-excludes-$ending" && + ( + cd "normalized-excludes-$ending" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir one two && + if test "$ending" = lf + then + printf "ignored\n" >one/.gitignore + else + printf ignored >one/.gitignore + fi && + cp one/.gitignore two/.gitignore && + test_write_lines hidden >one/ignored && + test_write_lines hidden >two/ignored && + git add one/.gitignore two/.gitignore && + git commit -qm base && + test-tool chmtime -120 one/.gitignore two/.gitignore && + git update-index --refresh && + git config core.untrackedCache true && + raw=$(git rev-parse :one/.gitignore) && + normalized=$( + { cat one/.gitignore && printf "\n"; } | + git hash-object --stdin + ) && + test "$raw" != "$normalized" && + + # Exercise the genuine historical add_patterns() encoding. + git update-index --assume-unchanged \ + one/.gitignore two/.gitignore && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/historical && + test_must_be_empty .git/historical && + test-tool dump-untracked-cache >.git/historical.dump && + test_grep "^/one/ $normalized .*valid" \ + .git/historical.dump && + test_grep "^/two/ $normalized .*valid" \ + .git/historical.dump && + git update-index --no-assume-unchanged \ + one/.gitignore two/.gitignore && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSMN .git/index && + test_grep FSUC .git/index && + test_grep FSCF .git/index && + cat >.git/restore-historical-excludes.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my ($algorithm, $raw_hex, $normalized_hex) = @ARGV; + my $size = $algorithm eq "sha256" ? 32 : 20; + my $body = substr($index, 0, -$size); + my $offset = index($body, "UNTR"); + die "missing UNTR extension\n" if $offset < 0; + my $length = unpack("N", substr($body, $offset + 4, 4)); + die "invalid UNTR size\n" + if $offset + 8 + $length > length($body); + my $payload = substr($body, $offset + 8, $length); + my $raw = pack("H*", $raw_hex); + my $normalized = pack("H*", $normalized_hex); + my $cursor = 0; + my $replaced = 0; + while (($cursor = index($payload, $raw, $cursor)) >= 0) { + substr($payload, $cursor, $size, $normalized); + $cursor += $size; + $replaced++; + } + die "expected exactly two historical excludes\n" + unless $replaced == 2; + substr($body, $offset + 8, $length, $payload); + print $body, + $size == 32 ? sha256($body) : sha1($body); + EOF + perl .git/restore-historical-excludes.pl \ + "$(test_oid algo)" "$raw" "$normalized" \ + <.git/index >.git/index.historical && + mv .git/index.historical .git/index && + test-tool dump-untracked-cache >.git/restored.dump && + test_grep "^/one/ $normalized " .git/restored.dump && + test_grep "^/two/ $normalized " .git/restored.dump && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + cp .git/index .git/readonly.index && + for run in first second + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_UNTRACKED_CACHE_THREADS=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$run.trace" \ + git status --porcelain=v2 \ + >".git/$run.actual" && + test_cmp .git/expect ".git/$run.actual" && + test_cmp_bin .git/readonly.index .git/index && + test_trace2_data fsmonitor config/coherent 1 \ + <".git/$run.trace" && + test_trace2_data dir \ + preload_untracked_cache/index-excludes 1 \ + <".git/$run.trace" && + test_trace2_data dir \ + preload_untracked_cache/index-normalized-excludes 1 \ + <".git/$run.trace" && + test_trace2_data dir \ + preload_untracked_cache/index-normalized-objects 1 \ + <".git/$run.trace" && + test_trace2_data dir \ + preload_untracked_cache/index-uptodate 2 \ + <".git/$run.trace" && + test_trace2_data dir \ + preload_untracked_cache/index-invalidated 0 \ + <".git/$run.trace" && + test_trace2_data dir \ + preload_untracked_cache/normalized-excludes 2 \ + <".git/$run.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <".git/$run.trace" && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <".git/$run.trace" && + ! test_region index do_write_index \ + ".git/$run.trace" || return 1 + done + ) || return 1 + done +' + test_expect_success UNTRACKED_CACHE,HARDLINKS,POSIXPERM,SANITY \ 'fsmonitor rechecks cached unreadable per-directory excludes' ' test_when_finished "rm -rf fsmonitor-unreadable-exclude" && From f5d69f4ebb5eaed0b7b95065a8d8d9b8721e6f35 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:02:25 -0500 Subject: [PATCH 323/403] status: admit clean sidecars for inactive configured filters e4f0e5486f (status: issue sidecars after a verified full scan, 2026-07-28) refuses to issue a clean-status sidecar when any clean filter is configured. The reader imposes the same restriction. Even after semantic verification proves that no tracked path uses a filter, a repository with a global Git LFS configuration must read its index on every clean status. Allow issuance in the existing configured-filter proof domain only when the current configuration and semantic hashes match the supplied digest, the filter scope is authenticated and inactive, and the usual complete history and closed-token checks pass. Readers retain the exact configuration, provider, attribute, exclude, index, and hardlink checks. No sidecar format change is needed: previous issuers could not write a sidecar in this proof domain. A complete command-line filter-disable override intentionally shares the underlying configuration digest. Record that normalization in the in-memory digest and reject such invocations for both issuance and reuse, so a temporarily disabled filter cannot prime a clean proof. Cover consecutive read-only hits without index reads, configuration changes, active filters, external attributes, and the normalized override boundary. On a 100,000-file repository with an unused required LFS filter, five alternating read-only pairs against the same physical index reduce the median from 24.4 ms to 20.8 ms. Each new reader avoids the 8.8 MB index entirely, without changing the index or sidecar. --- clean-status-config.c | 3 + clean-status-config.h | 1 + clean-status-fast.c | 7 +- clean-status-sidecar-issue.c | 24 ++++- t/t7530-status-clean-sidecar.sh | 126 +++++++++++++++++++++++++++ t/unit-tests/u-clean-status-config.c | 3 + 6 files changed, 159 insertions(+), 5 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index 547359faee2130..99b8d60377d817 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -224,6 +224,9 @@ static void flush_pending_filter(struct clean_status_config_digest *digest) if (!pending) return; + /* Remember command overrides omitted from the authenticated digest. */ + if (pending->mask == CLEAN_STATUS_FILTER_COMPLETE) + digest->normalized_filter_disable = 1; for (unsigned i = 0; i < pending->nr; i++) { struct clean_status_pending_filter_entry *entry = &pending->entries[i]; diff --git a/clean-status-config.h b/clean-status-config.h index 7e6fe8189a61a3..779a45e720aed3 100644 --- a/clean-status-config.h +++ b/clean-status-config.h @@ -19,6 +19,7 @@ struct clean_status_config_digest { unsigned initialized : 1; unsigned finalized : 1; unsigned filter_configured : 1; + unsigned normalized_filter_disable : 1; unsigned semantic_config_explicit : 1; unsigned attribute_tree_configured : 1; unsigned fsmonitor_value_seen : 1; diff --git a/clean-status-fast.c b/clean-status-fast.c index b72f1e592374ac..e0e2661b2be726 100644 --- a/clean-status-fast.c +++ b/clean-status-fast.c @@ -216,7 +216,8 @@ int clean_status_try_sidecar( int ret = 0; *repository_inputs_changed = 0; - if (!config->finalized || config->filter_configured || + if (!config->finalized || + (config->filter_configured && config->normalized_filter_disable) || getenv(INDEX_ENVIRONMENT) || is_bare_repository(repo) || !repo_get_work_tree(repo) || !current_worktree_is_main(repo) || @@ -291,7 +292,9 @@ int clean_status_try_sidecar( } if (clean_status_config_read_repository(repo, &fresh_config) || - fresh_config.filter_configured || + fresh_config.filter_configured != config->filter_configured || + (fresh_config.filter_configured && + fresh_config.normalized_filter_disable) || memcmp(fresh_config.hash, config->hash, repo->hash_algo->rawsz)) { trace_miss(repo, "fast-config-raced"); diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index d046307b2a7d34..91cd4bc9960d25 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -58,11 +58,29 @@ static int output_is_certifiable(const struct wt_status *status, !status->ignored.nr; } -static int history_is_certifiable(const struct index_state *istate) +static int history_is_certifiable( + const struct index_state *istate, + const struct clean_status_config_digest *config) { const struct clean_status_state *state = istate->clean_status; + /* + * The configured-filter proof domain requires an authenticated, + * fully classified inactive scope. A normalized disabled-filter + * override shares that digest and is never certifiable. + */ return state && + state->filter_configured == config->filter_configured && + (!config->filter_configured || + (!config->normalized_filter_disable && + state->current_config_valid && + state->current_semantic_valid && + !memcmp(state->current_config_hash, config->hash, + istate->repo->hash_algo->rawsz) && + !memcmp(state->current_semantic_hash, config->semantic_hash, + istate->repo->hash_algo->rawsz) && + state->filter_scope_valid && + !clean_status_filter_scope_needs_validation(istate))) && clean_status_has_persistent_fsmonitor_semantic_history(istate) && clean_status_revalidated_token_matches(istate) && state->manifest.current_valid && @@ -211,12 +229,12 @@ int clean_status_issue_sidecar( int installed = 0; if (!is_lock_file_locked(index_lock) || - !config->finalized || config->filter_configured || + !config->finalized || !output_is_certifiable(status, normal_clean_query)) { trace_miss(repo, "issue-command-or-output"); goto done; } - if (!history_is_certifiable(istate)) { + if (!history_is_certifiable(istate, config)) { trace_miss(repo, "issue-coherent-history"); goto done; } diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index c6d9b2330df9ab..c417b5547d3ad9 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -290,6 +290,132 @@ test_expect_success DURABLE_FSMONITOR \ test_grep ! "\"label\":\"do_read_index\"" hit.trace ' +test_expect_success DURABLE_FSMONITOR \ + 'inactive configured filters can issue authenticated clean sidecars' ' + test_when_finished "stop_daemon sidecar-inactive-filter" && + setup_repo sidecar-inactive-filter && + git -C sidecar-inactive-filter config core.autocrlf false && + git -C sidecar-inactive-filter config core.untrackedCache true && + git -C sidecar-inactive-filter config filter.sidecar.clean cat && + git -C sidecar-inactive-filter config filter.sidecar.smudge cat && + git -C sidecar-inactive-filter config filter.sidecar.process \ + "missing-inactive-filter-process" && + git -C sidecar-inactive-filter config filter.sidecar.required true && + prime_semantic_history sidecar-inactive-filter && + cp sidecar-inactive-filter/.git/index inactive-filter.index && + + test_env GIT_TRACE2_EVENT="$PWD/inactive-filter.issue.trace" \ + bulk_status -C sidecar-inactive-filter \ + status --porcelain=v2 >inactive-filter.issue && + test_must_be_empty inactive-filter.issue && + test_cmp_bin inactive-filter.index \ + sidecar-inactive-filter/.git/index && + test_trace2_data status clean-proof/sidecar 1 \ + inactive-filter.config && + test_must_be_empty inactive-filter.config && + test_trace2_data status clean-proof/miss fast-config-changed \ + inactive-filter.disabled-read && + test_must_be_empty inactive-filter.disabled-read && + test_trace2_data status clean-proof/miss fast-repository-shape \ + inactive-filter.disabled-issue && + test_must_be_empty inactive-filter.disabled-issue && + test_path_is_missing sidecar-inactive-filter/.git/index.csts && + test_grep ! "\"key\":\"clean-proof/sidecar\"" \ + inactive-filter.disabled-issue.trace && + + bulk_status -C sidecar-inactive-filter \ + status --porcelain=v2 >inactive-filter.reissue && + test_must_be_empty inactive-filter.reissue && + test_path_is_file sidecar-inactive-filter/.git/index.csts && + test_write_lines "tracked -text" \ + >sidecar-inactive-filter/.git/info/attributes && + assert_fallback_matches_oracle sidecar-inactive-filter \ + inactive-filter.external-attrs.trace && + test_trace2_data status clean-proof/miss \ + fast-repository-unavailable \ + active-filter.issue && + test_must_be_empty active-filter.issue && + test_path_is_file sidecar-active-filter/.git/index.csts && + + test_write_lines "tracked filter=sidecar" \ + >sidecar-active-filter/.gitattributes && + assert_fallback_matches_oracle sidecar-active-filter \ + active-filter.activation.trace && + test_grep "^1 \\.M .* tracked$" actual && + test_grep "^? \\.gitattributes$" actual && + + git -c filter.sidecar.clean= \ + -c filter.sidecar.smudge= \ + -c filter.sidecar.process= \ + -c filter.sidecar.required=false \ + -C sidecar-active-filter add .gitattributes && + git -c filter.sidecar.clean= \ + -c filter.sidecar.smudge= \ + -c filter.sidecar.process= \ + -c filter.sidecar.required=false \ + -C sidecar-active-filter commit -qm "activate disabled filter" && + rm -f sidecar-active-filter/.git/index.csts && + test_env GIT_TRACE2_EVENT="$PWD/active-filter.disabled.trace" \ + bulk_status -c filter.sidecar.clean= \ + -c filter.sidecar.smudge= \ + -c filter.sidecar.process= \ + -c filter.sidecar.required=false \ + -C sidecar-active-filter status --porcelain=v2 \ + >active-filter.disabled && + test_must_be_empty active-filter.disabled && + test_path_is_missing sidecar-active-filter/.git/index.csts && + test_grep ! "\"key\":\"clean-proof/sidecar\"" \ + active-filter.disabled.trace && + assert_fallback_matches_oracle sidecar-active-filter \ + active-filter.restored.trace && + test_grep "^1 \\.M .* tracked$" actual +' + test_expect_success DURABLE_FSMONITOR \ 'ordinary clean status installs its first missing sidecar' ' test_when_finished "stop_daemon sidecar-plain-first" && diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 07065b3fbe6bac..2df1ac6fad30e0 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -207,6 +207,7 @@ void test_clean_status_config__only_complete_disabled_filters_are_normalized(voi clean_status_config_init(&baseline, algo); clean_status_config_add(&baseline, keys[0], "configured", &ctx); clean_status_config_final(&baseline); + cl_assert(!baseline.normalized_filter_disable); for (unsigned mask = 0; mask < (1U << ARRAY_SIZE(keys)); mask++) { clean_status_config_init(&digest, algo); @@ -221,6 +222,8 @@ void test_clean_status_config__only_complete_disabled_filters_are_normalized(voi clean_status_config_final(&digest); cl_assert_equal_i(hasheq(digest.hash, baseline.hash, algo), !mask || mask == 15); + cl_assert_equal_i(digest.normalized_filter_disable, + mask == 15); if (mask == 15) { cl_assert(hasheq(digest.semantic_hash, baseline.semantic_hash, algo)); From 44b6b8b7a148507deabdb1b23493830d8176c155 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:10:43 -0500 Subject: [PATCH 324/403] exclude: avoid an intermediate reopen of regular sources Scoped directory-delta recovery must revalidate its exclude sources before retaining already scanned siblings. Removing that check would miss a changed ignore rule, but reopening each tiny source repeatedly is expensive. A 2,050-directory fixture spends about 155 ms validating only 20 KiB of ignore-file contents. After reading a regular source, use anchored fstatat() for the first pathname identity check. Preserve the follow policy and compare the same complete identity. The final check still reopens the source for reading, and the complete content hash and parent checks are unchanged. Nonregular sources and unsupported platforms retain their old path. Five alternating runs against the same initial index reduce the median exclude-validation phase from 155 ms to 129 ms and complete recovery from 476 ms to 447 ms. Both builds produce identical repaired indexes. Extend the unit tests to validate followed regular symlinks and reject same-content symlink replacement under a no-follow policy. --- exclude-source-proof.c | 19 +++++++++++- t/unit-tests/u-exclude-source-proof.c | 44 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/exclude-source-proof.c b/exclude-source-proof.c index 83ee1626d93746..8dba2273540a4a 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -259,6 +259,23 @@ static int source_matches(struct exclude_source_capture *capture, return ret; } +static int source_matches_after_read(struct exclude_source_capture *capture, + const struct stat *expected) +{ +#if EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN + if (S_ISREG(expected->st_mode)) { + struct stat named; + int flags = capture->nofollow ? AT_SYMLINK_NOFOLLOW : 0; + + /* The final reopened descriptor still proves readability. */ + return !fstatat(capture->parent_fd, capture->relative, + &named, flags) && + path_namespace_stat_equal(expected, &named); + } +#endif + return source_matches(capture, expected); +} + static int same_observation( const struct exclude_source_proof_entry *entry, int exists, size_t size, const struct object_id *oid) @@ -384,7 +401,7 @@ static int proof_entry_matches( if ((size_t)read_in_full(fd, buf, size) != size || fstat(fd, &after) || !path_namespace_stat_equal(&before, &after) || - !source_matches(capture, &after)) + !source_matches_after_read(capture, &after)) goto done; hash_object_file(proof->istate->repo->hash_algo, buf, size, OBJ_BLOB, &oid); diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index 4ac7690f2bf286..21e7cc33c5870c 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -263,6 +263,7 @@ void test_exclude_source_proof__honors_nofollow(void) char *parent = make_path("parent"); char *source = make_path("parent/source"); char *target = make_path("parent/target"); + char *replacement = make_path("parent/replacement"); int fd; cl_must_pass(mkdir(parent, 0700)); @@ -275,12 +276,54 @@ void test_exclude_source_proof__honors_nofollow(void) cl_must_pass(fd); cl_must_pass(close(fd)); exclude_source_capture_release(capture); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + write_file_buf(replacement, "content", 7); + cl_must_pass(unlink(source)); + cl_must_pass(symlink("replacement", source)); + cl_assert(exclude_source_proof_validate(proof)); capture = exclude_source_capture_begin(proof, source, 1); cl_assert(capture != NULL); fd = exclude_source_capture_open(capture); cl_assert(fd < 0 && errno == ELOOP); exclude_source_capture_release(capture); + write_file_buf(replacement, "changed", 7); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(replacement); + free(target); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_nofollow_symlink_replacement(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + char *target = make_path("parent/target"); + struct stat st; + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + write_file_buf(target, "content", 7); + capture = exclude_source_capture_begin(proof, source, 1); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + exclude_source_capture_record(capture, fd, &st, "content", 7); + exclude_source_capture_release(capture); + cl_must_pass(close(fd)); + cl_assert(exclude_source_proof_validate(proof)); + + cl_must_pass(unlink(source)); + cl_must_pass(symlink("target", source)); + cl_assert(!exclude_source_proof_validate(proof)); exclude_source_proof_release(proof); free(target); @@ -450,6 +493,7 @@ SKIP_TEST(test_exclude_source_proof__digest_deduplicates_and_ignores_identity) SKIP_TEST(test_exclude_source_proof__rejects_open_failure) SKIP_TEST(test_exclude_source_proof__fails_closed_without_parent_opener) SKIP_TEST(test_exclude_source_proof__honors_nofollow) +SKIP_TEST(test_exclude_source_proof__rejects_nofollow_symlink_replacement) SKIP_TEST(test_exclude_source_proof__opens_directory_sources) SKIP_TEST(test_exclude_source_proof__accepts_same_content_parent_replacement) SKIP_TEST(test_exclude_source_proof__reresolves_absent_source_parent) From cdcd1c4d8d8bc38769fe9e5035d374233ca21f0a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:18:47 -0500 Subject: [PATCH 325/403] status: preserve legacy proof epochs across preload tuning 4bc13b15c9 (status: ignore command-scoped preload tuning in proofs, 2026-08-13) excludes both preload settings from authenticated status configuration hashes. Legacy FSCF proofs lack an explicit tracked-policy hash, so migrating them across harmless configuration drift also checks that configuration sources predate the index. That separate check recognizes core.preloadIndexBulk but not core.preloadIndex. Consequently, adding core.preloadIndex to an otherwise safe migration discards the authenticated tracked state. In the existing 258-path legacy fixture, enabling preload sends all 258 entries through bulk preload; disabling it performs 258 individual stats. The unmodified control refreshes only the reported path. Use the existing command-scoped acceleration predicate for legacy migration too. The proof-neutral key set and all source-epoch checks remain unchanged. Extend the migration regression to require preserved tracked state and no full preload, refresh, or manifest scan for both values. --- clean-status-config.c | 2 +- t/t7527-builtin-fsmonitor.sh | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/clean-status-config.c b/clean-status-config.c index 99b8d60377d817..3740bb929ef000 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -382,7 +382,7 @@ static int config_epoch_command_is_safe( { return starts_with(key, "advice.") || !strcmp(key, "user.name") || !strcmp(key, "user.email") || - !strcmp(key, "core.preloadindexbulk") || + config_is_command_acceleration(key, ctx) || config_is_command_transport(key, ctx); } diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 6b4c7c11a8b913..c1d5587122d3de 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -3660,6 +3660,41 @@ test_expect_success MACOS,UNTRACKED_CACHE,PERL_TEST_HELPERS,SEMANTIC_VERIFY_ANCH ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <.git/repeat.trace && + for legacy_preload in true false + do + cp .git/index.legacy .git/index && + rm -f .git/index.csts .git/index.csh1.* \ + .git/index.cswi.* && + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked.txt \ + GIT_TRACE2_EVENT="$PWD/.git/legacy-preload-$legacy_preload.trace" \ + git -c user.name=Legacy \ + -c core.preloadIndex="$legacy_preload" \ + status --porcelain=v2 \ + >".git/legacy-preload-$legacy_preload.actual" && + test_cmp .git/legacy.expect \ + ".git/legacy-preload-$legacy_preload.actual" || + return 1 + done && + for legacy_preload in true false + do + test_trace2_data fsmonitor config/tracked-epoch-preserved 1 \ + <".git/legacy-preload-$legacy_preload.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <".git/legacy-preload-$legacy_preload.trace" && + ! test_trace2_data index preload/bulk_useful \ + "[1-9][0-9]*" \ + <".git/legacy-preload-$legacy_preload.trace" && + ! test_trace2_data index preload/sum_lstat \ + "\([2-9]\|[1-9][0-9][0-9]*\)" \ + <".git/legacy-preload-$legacy_preload.trace" && + ! test_trace2_data index refresh/sum_lstat \ + "\([2-9]\|[1-9][0-9][0-9]*\)" \ + <".git/legacy-preload-$legacy_preload.trace" || + return 1 + done && + cp .git/index.legacy .git/index && rm -f .git/index.csts .git/index.csh1.* .git/index.cswi.* && GIT_OPTIONAL_LOCKS=0 git -c advice.statusHints=true \ From 26874a9298d5fd82ec4d14e05ea3f6b0b3e2d578 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:30:29 -0500 Subject: [PATCH 326/403] fsmonitor: bound directory reuse by attribute sources 7c19bf467b (fsmonitor: retain checked manifests across scoped directory deltas, 2026-08-15) limits both the tracked entries and the distinct attribute sources examined for a directory event. The entry limit makes a flat directory's 65th tracked file trigger a second full manifest and a whole-worktree retry even when the directory has one attribute source. Bound the expensive attribute-source checks, not the in-memory entry walk. Keep every entry eligibility check and the independent 64-source limit. The provider callback already has to invalidate each affected entry, and none of those content checks is removed. Configuration, namespace, index, attribute, and exclude proofs remain unchanged. In five alternating runs with 2,048 unaffected sibling directories, median recovery fell from 278 ms to 155 ms for a 65-file cone, and from 316 ms to 206 ms for a 1,024-file cone. Both cases build one manifest and retry three directories instead of rebuilding the manifest and visiting 2,050 directories. Extend the regression to require the exact number of refreshed entries, retain the distinct-source limit, and exercise attribute and ignore changes while a large directory is being closed. --- clean-status-manifest.c | 5 +- t/t7519-status-fsmonitor.sh | 95 ++++++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/clean-status-manifest.c b/clean-status-manifest.c index d668abf8da01b4..fe881e44e38edd 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -239,7 +239,7 @@ int clean_status_manifest_directory_unchanged( struct string_list candidates = STRING_LIST_INIT_DUP; struct strbuf candidate = STRBUF_INIT; const struct git_hash_algo *algo = istate->repo->hash_algo; - unsigned int first, count = 0, namespace_unstable = 0; + unsigned int first, namespace_unstable = 0; size_t len; int pos, pinned = 0, safe = 0; uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | @@ -303,12 +303,13 @@ int clean_status_manifest_directory_unchanged( strbuf_addstr(&candidate, directory); strbuf_addstr(&candidate, GITATTRIBUTES_FILE); string_list_insert(&candidates, candidate.buf); + /* Bound attribute-source I/O, not the affected in-memory entries. */ for (unsigned int i = first; i < istate->cache_nr && starts_with(istate->cache[i]->name, directory); i++) { const struct cache_entry *ce = istate->cache[i]; const char *slash = ce->name + len; - if (++count > 64 || ce_stage(ce) || ce_skip_worktree(ce) || + if (ce_stage(ce) || ce_skip_worktree(ce) || ce_intent_to_add(ce) || (ce->ce_flags & CE_VALID) || S_ISSPARSEDIR(ce->ce_mode)) goto done; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 3adb495d107543..50c352e73a523c 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3327,8 +3327,8 @@ test_expect_success HARDLINKS,!MINGW,!CYGWIN \ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'second closing-query change preserves verified sibling subtrees' ' test_when_finished \ - "rm -rf second-query-changed-file second-query-changed-directory" && - for event in file directory + "rm -rf second-query-changed-file second-query-changed-directory second-query-changed-large-directory" && + for event in file directory large-directory do test_create_repo "second-query-changed-$event" && ( @@ -3339,6 +3339,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_write_lines "*.ignored" >cached/.gitignore && printf "aaaa\n" >cached/tracked && test_write_lines ignored >cached/junk.ignored && + if test "$event" = large-directory + then + for descendant in $(test_seq 1 128) + do + test_write_lines "$descendant" \ + >"cached/retained-$descendant" || return 1 + done + fi && for sibling in $(test_seq 1 12) do mkdir "sibling-$sibling" && @@ -3348,6 +3356,10 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ >"sibling-$sibling/retained.ignored" || return 1 done && git add .gitignore cached/.gitignore cached/tracked sibling-* && + if test "$event" = large-directory + then + git add cached/retained-* + fi && git commit -m base && git config core.trustctime false && git config core.checkStat minimal && @@ -3373,7 +3385,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ -c core.trustctime=true -c core.checkStat=default \ status --porcelain=v2 >.git/expect && - if test "$event" = directory + if test "$event" != file then changed_path=cached/ else @@ -3395,7 +3407,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/status.trace && test_trace2_data fsmonitor token_closure/apply_count 1 \ <.git/status.trace && - if test "$event" = directory + if test "$event" != file then test_trace2_data fsmonitor \ semantic/manifest-directory-reused 1 \ @@ -3437,8 +3449,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test "$initial_opened" -gt 8 && test $((retry_opened - initial_opened)) -gt 0 && test $((retry_opened - initial_opened)) -le 2 && - test_trace2_data index refresh/sum_lstat "[0-2]" \ - <.git/status.trace && + if test "$event" = large-directory + then + test_trace2_data index refresh/sum_lstat 130 \ + <.git/status.trace + else + test_trace2_data index refresh/sum_lstat "[0-2]" \ + <.git/status.trace + fi && test_trace2_data status \ fsmonitor_token/untracked-after-retry 1 \ <.git/status.trace && @@ -3449,6 +3467,63 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ done ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'directory closure rejects too many distinct attribute candidates' ' + test_when_finished "rm -rf directory-many-attribute-candidates" && + test_create_repo directory-many-attribute-candidates && + ( + cd directory-many-attribute-candidates && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached sibling && + for descendant in $(test_seq 1 64) + do + mkdir "cached/child-$descendant" && + printf "aaaa\n" \ + >"cached/child-$descendant/tracked" || return 1 + done && + test_write_lines retained >sibling/tracked && + git add cached sibling && + git commit -qm base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test_write_lines visible >sibling/visible && + git -c core.fsmonitor=false status --porcelain=v2 >.git/prime && + test_grep "^? sibling/visible$" .git/prime && + test-tool chmtime =-60 cached/child-1/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get cached/child-1/tracked) && + printf "bbbb\n" >cached/child-1/tracked && + test-tool chmtime =$mtime cached/child-1/tracked && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid cached/child-1/tracked && + GIT_OPTIONAL_LOCKS=0 git \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.trustctime=true -c core.checkStat=default \ + status --porcelain=v2 >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/ \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.M .* cached/child-1/tracked$" .git/actual && + test_grep "^? sibling/visible$" .git/actual && + test_trace2_data fsmonitor semantic/manifest-scan-count 2 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/manifest-directory-reused 1 \ + <.git/status.trace && + ! test_trace2_data status \ + fsmonitor_token/reused-semantic-subtrees 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_fsmonitor_full_proof .git/index paired + ) +' + test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'directory closure rejects raced attributes and rechecks raced excludes' ' test_when_finished "rm -rf directory-race-attributes directory-race-ignore" && @@ -3465,13 +3540,19 @@ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_write_lines "*.ignored" >cached/.gitignore && printf "aaaa\n" >cached/tracked && test_write_lines ignored >cached/junk.ignored && + for descendant in $(test_seq 1 128) + do + test_write_lines "$descendant" \ + >"cached/retained-$descendant" || return 1 + done && for sibling in $(test_seq 1 8) do mkdir "sibling-$sibling" && test_write_lines "$sibling" \ >"sibling-$sibling/tracked" || return 1 done && - git add .gitignore cached/.gitignore cached/tracked sibling-* && + git add .gitignore cached/.gitignore cached/tracked \ + cached/retained-* sibling-* && git commit -qm base && git config core.trustctime false && git config core.checkStat minimal && From fad7b3d2e6d46dbddd5ea76ef7a8957e5a9ff8bc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:49:47 -0500 Subject: [PATCH 327/403] t7527: separate index and sidecar preload coverage f5d69f4ebb (status: admit clean sidecars for inactive configured filters, 2026-08-15) lets the first clean status in the preload test issue a sidecar. A later status can then return without reading the index, so the test's required config/coherent event is absent despite a valid clean-proof hit. Keep the existing test on the physical-index path by disabling optional locks for both the tuned and untuned readers. Require the index to stay byte-identical and the sidecar to remain absent, while retaining the strict coherence, no-manifest, and no-refresh assertions. Exercise the same five preload configurations separately against an issued inactive-filter sidecar in t7530. Require an exact independent oracle, a clean-proof hit with no index or directory scan, and unchanged index and sidecar bytes. This covers both paths without weakening the original proof-admission test. Production behavior is unchanged. --- t/t7527-builtin-fsmonitor.sh | 8 ++++++ t/t7530-status-clean-sidecar.sh | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index c1d5587122d3de..c10f1635ff5a98 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -6855,6 +6855,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/prime.trace && test_grep FSCF .git/index && test_grep FSUC .git/index && + test_path_is_missing .git/index.csts && + cp .git/index .git/preload.index && for label in bulk preload both bulk-false preload-false do @@ -6865,10 +6867,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ bulk-false) set -- -c core.preloadIndexBulk=false ;; preload-false) set -- -c core.preloadIndex=false ;; esac && + GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ git "$@" status --porcelain=v2 >.git/$label && test_must_be_empty .git/$label && + test_cmp_bin .git/preload.index .git/index && + test_path_is_missing .git/index.csts && test_trace2_data fsmonitor config/coherent 1 \ <.git/$label.trace && ! test_trace2_data fsmonitor semantic/initial-mismatch 1 \ @@ -6879,10 +6884,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <.git/$label.trace && ! test_trace2_data index refresh/sum_lstat \ "[1-9][0-9]*" <.git/$label.trace && + GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/$label-plain.trace" \ git status --porcelain=v2 >.git/$label-plain && test_must_be_empty .git/$label-plain && + test_cmp_bin .git/preload.index .git/index && + test_path_is_missing .git/index.csts && test_trace2_data fsmonitor config/coherent 1 \ <.git/$label-plain.trace && ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index c417b5547d3ad9..40b21af45cb1f9 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -318,6 +318,51 @@ test_expect_success DURABLE_FSMONITOR \ assert_clean_sidecar_hit sidecar-inactive-filter \ sidecar-inactive-filter inactive-filter.hit-again && + for label in bulk preload both bulk-false preload-false + do + case "$label" in + bulk) set -- -c core.preloadIndexBulk ;; + preload) set -- -c core.preloadIndex ;; + both) set -- -c core.preloadIndexBulk -c core.preloadIndex ;; + bulk-false) set -- -c core.preloadIndexBulk=false ;; + preload-false) set -- -c core.preloadIndex=false ;; + esac && + cp sidecar-inactive-filter/.git/index \ + "inactive-filter-$label.index" && + cp sidecar-inactive-filter/.git/index.csts \ + "inactive-filter-$label.sidecar" && + GIT_OPTIONAL_LOCKS=0 \ + git "$@" -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -C sidecar-inactive-filter status --porcelain=v2 \ + >"inactive-filter-$label.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/inactive-filter-$label.trace" \ + git "$@" -C sidecar-inactive-filter \ + status --porcelain=v2 \ + >"inactive-filter-$label.actual" && + test_cmp "inactive-filter-$label.expect" \ + "inactive-filter-$label.actual" && + test_cmp_bin "inactive-filter-$label.index" \ + sidecar-inactive-filter/.git/index && + test_cmp_bin "inactive-filter-$label.sidecar" \ + sidecar-inactive-filter/.git/index.csts && + test_trace2_data status clean-proof/hit 1 \ + <"inactive-filter-$label.trace" && + test_grep ! "\"label\":\"do_read_index\"" \ + "inactive-filter-$label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"refresh\"" \ + "inactive-filter-$label.trace" && + test_grep ! "\"category\":\"index\",\"label\":\"preload" \ + "inactive-filter-$label.trace" && + test_grep ! "\"label\":\"read_directory\"" \ + "inactive-filter-$label.trace" && + test_grep ! "\"key\":\"semantic/manifest-scan-count\"" \ + "inactive-filter-$label.trace" && + test_grep ! "\"label\":\"do_write_index\"" \ + "inactive-filter-$label.trace" || return 1 + done && + GIT_OPTIONAL_LOCKS=0 \ GIT_TRACE2_EVENT="$PWD/inactive-filter.config.trace" \ git -c filter.sidecar.required=false \ From 6ebfec2b43be861bf2745676a17eaade61d7f4bc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:01:28 -0500 Subject: [PATCH 328/403] dir: share normalized exclude hashes without pthreads b4bd792a51 (dir: share normalized exclude hashes across identical blobs, 2026-08-15) initializes its sharing map only after creating a mutex. With NO_PTHREADS, the dummy mutex initializer returns ENOSYS, so identical ignore files are read and hashed separately even though the sole preload worker runs synchronously. Initialize the map directly when threads are unavailable. The dummy lock and destroy operations are no-ops, and the map is still released after the worker finishes. A failed real mutex initialization continues to disable sharing in threaded builds. The existing historical-normalized-excludes regression failed with no shared object or reused index stat. It and the neighboring hardlink and conversion checks pass with NO_PTHREADS for both object hashes. --- dir.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dir.c b/dir.c index 6dd004d3c81fc8..5338ee0c7cb72c 100644 --- a/dir.c +++ b/dir.c @@ -512,7 +512,8 @@ static struct untracked_cache_preload *untracked_cache_preload_start_1( fsmonitor_excludes_only, preload->pathspec); strbuf_release(&path); if (fsmonitor_excludes_only) { - if (!pthread_mutex_init(&preload->normalized_mutex, NULL)) { + if (!HAVE_THREADS || + !pthread_mutex_init(&preload->normalized_mutex, NULL)) { preload->normalized_mutex_initialized = 1; oidmap_init(&preload->normalized_excludes, 0); } From b6fbc8e1bd1f4952fd800b36b7981f73e2a23f18 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:07:47 -0500 Subject: [PATCH 329/403] t7519: distinguish serial and threaded index refreshes 28dac0af16 (stash: preserve authenticated worktree proofs during creation, 2026-08-14) and 9b689dfb57 (diff: close fsmonitor tokens after complete tracked refresh, 2026-08-14) check exact index-preload counts. With NO_PTHREADS, preload_index() returns before entering that trace region, so the tests reject the serial refresh even when its output and authenticated worktree proofs are correct. Keep the exact preload counts in threaded builds and require the whole preload region to be absent otherwise. Continue checking the ordinary refresh count, index immutability, provider-token pairing, and status output in both modes. --- t/t7519-status-fsmonitor.sh | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 50c352e73a523c..b268244d8fdbe6 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1157,8 +1157,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ! test_trace2_data fsmonitor \ semantic/manifest-scan-count 1 \ <"$gitdir/status-$run.trace" && - test_trace2_data index preload/sum_lstat 1 \ - <"$gitdir/status-$run.trace" && + if test_have_prereq PTHREADS + then + test_trace2_data index preload/sum_lstat 1 \ + <"$gitdir/status-$run.trace" + else + test_region ! index preload \ + "$gitdir/status-$run.trace" + fi && test_trace2_data index refresh/sum_lstat 1 \ <"$gitdir/status-$run.trace" || return 1 done && @@ -2465,8 +2471,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git -C "$worktree" diff \ >"$gitdir/next.actual" && test_must_be_empty "$gitdir/next.actual" && - test_trace2_data index preload/sum_lstat 0 \ - <"$gitdir/next.trace" || return 1 + if test_have_prereq PTHREADS + then + test_trace2_data index preload/sum_lstat 0 \ + <"$gitdir/next.trace" + else + test_region ! index preload "$gitdir/next.trace" + fi || return 1 done && git config filter.lfs.process "" && git config filter.lfs.clean false && From 1257a5f602616f0ea9705b2873cf9e0cddd99cf3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:12:25 -0500 Subject: [PATCH 330/403] t7519: check scoped recovery with the available worker count ef7d1509c1 (status: preload full recovery for scoped pathspecs, 2026-08-13) verifies recovery with 6, 8, 12, and 16 preload workers. Those requests are intentionally ignored in NO_PTHREADS builds, where the cache is checked synchronously by one worker. The test nevertheless requires the requested parallel count and fails before checking the remaining recovery cases. Retain the existing worker counts when threads are available and test one worker otherwise. Both modes still require exact status output, accepted token closure, and correct handling of outside-directory, ignore-file, and global changes. --- t/t7519-status-fsmonitor.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index b268244d8fdbe6..cec6bde177cf52 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2161,7 +2161,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_OPTIONAL_LOCKS=0 \ git -c core.fsmonitor=false -c core.untrackedCache=false \ status --porcelain=v2 -- cached/deep >.git/scoped.expect && - for workers in 6 8 12 16 + if test_have_prereq PTHREADS + then + worker_counts="6 8 12 16" + else + worker_counts=1 + fi && + for workers in $worker_counts do rm -f .git/index.csts && GIT_OPTIONAL_LOCKS=0 \ From 3a86cb8af72a961d73d477b87257485afa323513 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:14:33 -0500 Subject: [PATCH 331/403] status: retain recovery and progress without pthreads b76a6c5d88 (status: show delayed progress during semantic refresh, 2026-08-10) protects progress updates with a mutex. The provider-reset repair added in dfc55f903c (diff: repair provider-reset history before publishing its index, 2026-08-15) similarly protects its warning flag. Both require pthread_mutex_init() to succeed, but the NO_PTHREADS stub returns ENOSYS. Interactive status can therefore abort when progress starts, and diff silently declines an otherwise valid durable repair. Initialize these mutexes only when threads are available. Serial builds run the callbacks on the main thread and use no-op lock and destroy operations. Threaded builds retain the existing failure handling, and the recovery proof, warning, snapshot, and index-lock checks are unchanged. Add a progress unit suite that checks repository selection and the start/update/stop lifecycle without installing signal handlers. It reproduces the abort before the fix. The existing provider-reset diff test also exercises the missing serial repair. Both object-hash variants pass the complete t7519 suite with NO_PTHREADS. --- Makefile | 1 + builtin/diff.c | 3 +- clean-status.c | 2 +- t/meson.build | 1 + t/unit-tests/u-clean-status-progress.c | 51 ++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 t/unit-tests/u-clean-status-progress.c diff --git a/Makefile b/Makefile index 46601baeddb8a8..5766c2c9226990 100644 --- a/Makefile +++ b/Makefile @@ -1579,6 +1579,7 @@ CLAR_TEST_SUITES += u-clean-status-history-store CLAR_TEST_SUITES += u-clean-status-identity CLAR_TEST_SUITES += u-clean-status-index CLAR_TEST_SUITES += u-clean-status-manifest +CLAR_TEST_SUITES += u-clean-status-progress CLAR_TEST_SUITES += u-clean-status-sidecar CLAR_TEST_SUITES += u-clean-status-store CLAR_TEST_SUITES += u-ctype diff --git a/builtin/diff.c b/builtin/diff.c index 7aa66f7fda581a..d384b3b7383604 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -366,7 +366,8 @@ static void refresh_index_quietly(void) clean_status_index_snapshot_release(&source); return; } - if (pthread_mutex_init(&diff_refresh_warning_mutex, NULL)) { + if (HAVE_THREADS && + pthread_mutex_init(&diff_refresh_warning_mutex, NULL)) { clean_status_index_snapshot_release(&source); return; } diff --git a/clean-status.c b/clean-status.c index 2c17894e949baf..7409daa8dc78b0 100644 --- a/clean-status.c +++ b/clean-status.c @@ -53,7 +53,7 @@ struct clean_status_progress *clean_status_start_progress( if (repo != progress_repo) return NULL; CALLOC_ARRAY(progress, 1); - if (pthread_mutex_init(&progress->mutex, NULL)) + if (HAVE_THREADS && pthread_mutex_init(&progress->mutex, NULL)) BUG("could not initialize clean status progress mutex"); progress->display = start_delayed_progress(repo, title, total); return progress; diff --git a/t/meson.build b/t/meson.build index 0f52f5637b3066..3a6263fefd00f9 100644 --- a/t/meson.build +++ b/t/meson.build @@ -7,6 +7,7 @@ clar_test_suites = [ 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', + 'unit-tests/u-clean-status-progress.c', 'unit-tests/u-clean-status-sidecar.c', 'unit-tests/u-clean-status-store.c', 'unit-tests/u-ctype.c', diff --git a/t/unit-tests/u-clean-status-progress.c b/t/unit-tests/u-clean-status-progress.c new file mode 100644 index 00000000000000..04560d506c60ed --- /dev/null +++ b/t/unit-tests/u-clean-status-progress.c @@ -0,0 +1,51 @@ +#define USE_THE_REPOSITORY_VARIABLE +#define GIT_TEST_PROGRESS_ONLY + +#include "unit-test.h" +#include "clean-status.h" +#include "progress.h" +#include "repository.h" + +static int previous_progress_testing; + +void test_clean_status_progress__initialize(void) +{ + previous_progress_testing = progress_testing; + progress_testing = 1; + clean_status_enable_progress(NULL); +} + +void test_clean_status_progress__cleanup(void) +{ + clean_status_enable_progress(NULL); + progress_testing = previous_progress_testing; +} + +void test_clean_status_progress__requires_enabled_repository(void) +{ + struct repository other = { 0 }; + + cl_assert_equal_p(clean_status_start_progress( + the_repository, "disabled progress", 1), NULL); + clean_status_enable_progress(the_repository); + cl_assert_equal_p(clean_status_start_progress( + &other, "other repository", 1), NULL); +} + +void test_clean_status_progress__starts_updates_and_stops(void) +{ + struct clean_status_progress *progress; + + clean_status_enable_progress(the_repository); + progress = clean_status_start_progress( + the_repository, "clean status", 2); + cl_assert(progress != NULL); + clean_status_update_progress(progress, 0); + clean_status_update_progress(progress, 1); + clean_status_update_progress(progress, 1); + clean_status_stop_progress(&progress); + cl_assert_equal_p(progress, NULL); + clean_status_update_progress(NULL, 1); + clean_status_stop_progress(&progress); + clean_status_stop_progress(NULL); +} From e36890c2b6d7887b22dbe9842d89d92b7cd8d4c8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:54:44 -0500 Subject: [PATCH 332/403] status: reuse bulk results after discarding legacy caches 7d5f592039 (status: reuse complete APFS untracked preload results, 2026-07-21) lets normal status reuse a complete bulk directory scan when no untracked cache exists. A native provider can discard an unauthenticated legacy cache while reading the index, but leaves an empty replacement behind. Even with bulk preload enabled, that replacement forces status to enumerate the entire worktree again. Record the discard on the replacement cache as process-local state. For a read-only, whole-worktree status, remove that empty cache only after the existing bulk-provider admission checks succeed. The normal bulk result can then supply untracked paths. Keep usable caches, writable recovery, explicit opt-outs, and ineligible query shapes on their existing paths. A failed scan or closing query still falls back to ordinary traversal; no untracked-cache proof or index is written. A 1.16-million-entry worktree spent 24.6 seconds in the duplicate traversal. The existing no-cache control reduced the same read-only command from 34.1 to 12.6 seconds. Cover the historical cache encoding, repeated immutable reads, independent status output, and the paired, writable, disabled, and unsupported-backend cases with both hashes. --- dir.c | 4 + dir.h | 2 + t/t7519-status-fsmonitor.sh | 223 ++++++++++++++++++++++++++++++++++++ wt-status.c | 39 +++++++ 4 files changed, 268 insertions(+) diff --git a/dir.c b/dir.c index 5338ee0c7cb72c..2405796d911378 100644 --- a/dir.c +++ b/dir.c @@ -4427,11 +4427,15 @@ int untracked_cache_adopt_legacy(struct index_state *istate) void untracked_cache_discard_legacy(struct index_state *istate) { + int had_root; + if (!istate->untracked || !legacy_ident_in_untracked(istate->untracked)) return; + had_root = !!istate->untracked->root; free_untracked_cache(istate->untracked); new_untracked_cache(istate, -1); + istate->untracked->fsmonitor_legacy_discarded = had_root; trace2_data_intmax("fsmonitor", istate->repo, "untracked/legacy-discarded", 1); } diff --git a/dir.h b/dir.h index 3b5f0c704197f9..7f16e89302c72e 100644 --- a/dir.h +++ b/dir.h @@ -221,6 +221,8 @@ struct untracked_cache { unsigned int use_fsmonitor : 1; /* A lost provider boundary requires ordinary directory validation. */ unsigned int fsmonitor_revalidation : 1; + /* Process-local: a populated legacy cache was discarded on read. */ + unsigned int fsmonitor_legacy_discarded : 1; }; /** diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index cec6bde177cf52..9a77b7e31bef3f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -61,6 +61,26 @@ test_lazy_prereq HARDLINKS ' ln hardlink-a hardlink-b ' +test_lazy_prereq STATUS_BULK_PRELOAD ' + test_create_repo status-bulk-preload-prereq && + ( + cd status-bulk-preload-prereq && + test_write_lines tracked >tracked && + test_write_lines sibling >sibling && + git add tracked sibling && + git commit -qm base && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$PWD/.git/bulk.trace" \ + git -c core.fsmonitor=false \ + -c core.preloadIndexBulk=true \ + status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data index preload/bulk_result complete \ + <.git/bulk.trace + ) +' + test_expect_success 'FSMN parser fails closed' ' test-tool read-cache --test-fsmn-parser ' @@ -994,6 +1014,209 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'discarded legacy caches reuse an explicit bulk recovery pass' ' + test_when_finished "rm -rf legacy-discard-bulk" && + if test_have_prereq STATUS_BULK_PRELOAD + then + bulk_available=yes + else + bulk_available=no + fi && + test_create_repo legacy-discard-bulk && + ( + cd legacy-discard-bulk && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p cached/deep sibling/empty && + test_write_lines "*.ignored" >.gitignore && + test_write_lines tracked >cached/deep/tracked && + test_write_lines sibling >sibling/empty/tracked && + test_write_lines hidden >cached/deep/hidden.ignored && + test_write_lines visible >cached/deep/visible && + git add .gitignore cached/deep/tracked sibling/empty/tracked && + git commit -qm base && + test-tool chmtime -120 .gitignore cached/deep/tracked \ + sibling/empty/tracked && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/paired && + test_grep "^? cached/deep/visible$" .git/paired && + test_grep ! hidden.ignored .git/paired && + test_fsmonitor_full_proof .git/index paired && + test_path_is_missing .git/index.csts && + find .git -maxdepth 1 -type f \ + \( -name "index.csh1.*" -o -name "index.cswi.*" \) \ + >.git/checkpoints && + test_must_be_empty .git/checkpoints && + cp .git/index .git/paired.index && + + cat >.git/restore-legacy-untracked.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $algorithm = $ARGV[0]; + my $rawsz = $algorithm eq "sha256" ? 32 : 20; + my $body = substr($index, 0, -$rawsz); + my $offset = index($body, "UNTR"); + die "missing UNTR extension\n" if $offset < 0; + my $size = unpack("N", substr($body, $offset + 4, 4)); + die "invalid UNTR extension size\n" + if $offset + 8 + $size > length($body); + my $payload = substr($body, $offset + 8, $size); + die "missing populated UNTR directory root\n" + unless index($payload, "cached\0") >= 0 && + index($payload, "visible\0") >= 0; + my $cursor = 0; + my $byte = ord(substr($payload, $cursor++, 1)); + my $ident_length = $byte & 127; + while ($byte & 128) { + die "truncated UNTR identity length\n" + if $cursor >= length($payload); + $ident_length++; + $byte = ord(substr($payload, $cursor++, 1)); + $ident_length = ($ident_length << 7) + ($byte & 127); + } + die "truncated UNTR identity\n" + if $cursor + $ident_length > length($payload); + my $ident = substr($payload, $cursor, $ident_length); + my $suffix = ", cache version 2\0"; + die "unexpected current UNTR identity\n" + unless substr($ident, -length($suffix)) eq $suffix; + substr($ident, -length($suffix), length($suffix), "\0"); + my $length = length($ident); + my @bytes = ($length & 127); + while ($length >>= 7) { + unshift @bytes, 128 | ((--$length) & 127); + } + my $replacement = pack("C*", @bytes) . $ident . + substr($payload, $cursor + $ident_length); + substr($body, $offset, 8 + $size, + "UNTR" . pack("N", length($replacement)) . $replacement); + my $fsuc = index($body, "FSUC"); + die "missing paired FSUC extension\n" if $fsuc < 0; + my $fsuc_size = unpack("N", substr($body, $fsuc + 4, 4)); + die "invalid FSUC extension size\n" + if $fsuc + 8 + $fsuc_size > length($body); + substr($body, $fsuc, 8 + $fsuc_size, ""); + print $body, + $algorithm eq "sha256" ? sha256($body) : sha1($body); + EOF + perl .git/restore-legacy-untracked.pl "$(test_oid algo)" \ + <.git/paired.index >.git/legacy.index && + cp .git/legacy.index .git/index && + test_grep FSMN .git/index && + test_grep UNTR .git/index && + test_grep FSCF .git/index && + test_grep ! FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + test_cmp .git/paired .git/expect && + test_cmp_bin .git/legacy.index .git/index && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/disabled.trace" \ + git -c core.preloadIndexBulk=false \ + status --porcelain=v2 >.git/disabled && + test_cmp .git/expect .git/disabled && + test_cmp_bin .git/legacy.index .git/index && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/disabled.trace && + test_trace2_data fsmonitor untracked/legacy-preserved 1 \ + <.git/disabled.trace && + test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <.git/disabled.trace && + ! test_trace2_data status untracked/bulk-recovery 1 \ + <.git/disabled.trace && + ! test_trace2_data index preload/bulk_untracked_complete 1 \ + <.git/disabled.trace && + test_region dir read_directory .git/disabled.trace && + ! test_region index do_write_index .git/disabled.trace && + + cp .git/paired.index .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/paired.trace" \ + git -c core.preloadIndexBulk=true \ + status --porcelain=v2 >.git/paired.actual && + test_cmp .git/expect .git/paired.actual && + test_cmp_bin .git/paired.index .git/index && + test_fsmonitor_full_proof .git/index paired && + ! test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <.git/paired.trace && + ! test_trace2_data status untracked/bulk-recovery 1 \ + <.git/paired.trace && + + cp .git/legacy.index .git/index && + for run in first second + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$run.trace" \ + git -c core.preloadIndexBulk=true \ + status --porcelain=v2 \ + >".git/$run.actual" && + test_cmp .git/expect ".git/$run.actual" && + test_cmp_bin .git/legacy.index .git/index && + test_path_is_missing .git/index.csts && + find .git -maxdepth 1 -type f \ + \( -name "index.csh1.*" \ + -o -name "index.cswi.*" \) \ + >".git/$run.checkpoints" && + test_must_be_empty ".git/$run.checkpoints" && + test_trace2_data fsm_client query/trivial-response 1 \ + <".git/$run.trace" && + test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <".git/$run.trace" && + ! test_region index do_write_index ".git/$run.trace" && + if test "$bulk_available" = yes + then + test_trace2_data status untracked/bulk-recovery 1 \ + <".git/$run.trace" && + test_trace2_data index \ + preload/bulk_untracked_complete 1 \ + <".git/$run.trace" && + test_trace2_data index \ + preload/bulk_provider_applied \ + "[1-9][0-9]*" <".git/$run.trace" && + ! test_region dir read_directory \ + ".git/$run.trace" + else + ! test_trace2_data index \ + preload/bulk_untracked_complete 1 \ + <".git/$run.trace" && + test_region dir read_directory \ + ".git/$run.trace" + fi || return 1 + done && + + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/writable.trace" \ + git -c core.preloadIndexBulk=true \ + status --porcelain=v2 >.git/writable && + test_cmp .git/expect .git/writable && + test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <.git/writable.trace && + ! test_trace2_data status untracked/bulk-recovery 1 \ + <.git/writable.trace && + test_region index do_write_index .git/writable.trace && + test_fsmonitor_full_proof .git/index paired + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'index writers report missing authenticated untracked proofs' ' test_when_finished "rm -rf missing-untracked-proof" && diff --git a/wt-status.c b/wt-status.c index 603fcbd3a67704..a048f848f69a20 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1103,6 +1103,44 @@ static int wt_status_begin_attr_snapshot(struct wt_status *s) return ret; } +static void wt_status_prepare_bulk_recovery(struct wt_status *s) +{ + struct index_state *istate = s->repo->index; + struct untracked_cache *uc = istate->untracked; + + if (!uc || !uc->fsmonitor_legacy_discarded) + return; + uc->fsmonitor_legacy_discarded = 0; + if (use_optional_locks() || uc->root || + s->show_untracked_files != SHOW_NORMAL_UNTRACKED_FILES || + s->show_ignored_mode || s->pathspec.nr || + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || + istate->split_index || istate->sparse_index != INDEX_EXPANDED || + !fstat_is_reliable() || + !repo_config_values(s->repo)->trust_ctime || + !repo_config_values(s->repo)->check_stat || + fsm_settings__get_mode(s->repo) != FSMONITOR_MODE_IPC || + !fsmonitor_pending_token_from_provider(istate) || + istate->fsmonitor_untracked_valid || + istate->fsmonitor_untracked_revalidation_authenticated || + istate->fsmonitor_legacy_untracked_fallback || + uc->fsmonitor_revalidation || + clean_status_filter_scope_needs_validation(istate) || + clean_status_manifest_global_fallback(istate) || + clean_status_worktree_manifest_needs_refresh(istate) || + !preload_index_bulk_can_close_provider(istate)) + return; + + /* + * The index read discarded an unauthenticated directory tree. A + * read-only caller cannot publish its replacement, so let the + * already enabled bulk scan supply complete untracked results. + * If that scan cannot close, ordinary traversal still supplies them. + */ + remove_untracked_cache(istate); + trace2_data_intmax("status", s->repo, "untracked/bulk-recovery", 1); +} + void wt_status_start_untracked_cache_preload(struct wt_status *s) { struct index_state *istate = s->repo->index; @@ -1118,6 +1156,7 @@ void wt_status_start_untracked_cache_preload(struct wt_status *s) wt_status_begin_attr_snapshot(s); /* Record the provider token before either filesystem traversal. */ refresh_fsmonitor(istate); + wt_status_prepare_bulk_recovery(s); if (s->certify_clean_status && !fsmonitor_has_pending_token(istate)) reopened_valid_token = From 5abe1ce4dfce9b88158e2a71182eb905e99edc87 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 00:03:09 -0500 Subject: [PATCH 333/403] preload-index: select bulk for read-only cache recovery e36890c2b6 (status: reuse bulk results after discarding legacy caches, 2026-08-15) avoids a second directory traversal when bulk preload is already enabled. Ordinary read-only status still takes the serial path because core.preloadIndexBulk defaults to false, even after losing a populated legacy cache and invalidating enough tracked entries to make the complete bulk scan useful. Let that exact recovery request supply the default while status closes its provider token. An explicit test override or configured boolean still wins, including false. Keep the existing useful-entry threshold, backend, index-shape, provider, attribute, exclusion, and closing-query checks. Clear the process-local request after closure so it cannot change later preload decisions. This does not enable bulk scans for healthy caches, ordinary sparse provider deltas, writable recovery, or unrelated commands. Document the narrow default exception and extend the legacy-cache regression to check automatic selection and each explicit opt-out. The automatic case previously performed a second walk even though the explicit bulk case already completed it correctly. --- Documentation/config/core.adoc | 5 ++- preload-index.c | 10 ++--- read-cache-ll.h | 2 + t/t7519-status-fsmonitor.sh | 70 ++++++++++++++++++++++------------ wt-status.c | 13 +++++-- 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/Documentation/config/core.adoc b/Documentation/config/core.adoc index 59bc4a818cceb8..6f8b4ac9372891 100644 --- a/Documentation/config/core.adoc +++ b/Documentation/config/core.adoc @@ -738,7 +738,10 @@ but may cost more than normal preload depending on filesystem and cache state. Inconclusive scans are discarded before continuing with the normal preload. Currently this is supported on APFS, ext-family filesystems, and XFS, and only has an effect when `core.preloadIndex` is enabled. Defaults -to false. +to false, except that a whole-worktree, read-only `git status` may use it +after the native file system monitor discards a populated legacy +untracked cache. Setting this option explicitly to false also disables +that recovery optimization. core.unsetenvvars:: Windows-only: comma-separated list of environment variables' diff --git a/preload-index.c b/preload-index.c index 95ebfff1d46787..5197ffa5e61913 100644 --- a/preload-index.c +++ b/preload-index.c @@ -322,11 +322,11 @@ static int preload_bulk_config_enabled(struct index_state *index) int control; control = git_env_bool("GIT_TEST_PRELOAD_INDEX_BULK", -1); - if (control < 0) - repo_config_get_bool(index->repo, "core.preloadindexbulk", - &enabled); - else - enabled = control; + if (control >= 0) + return control; + if (repo_config_get_bool(index->repo, "core.preloadindexbulk", + &enabled)) + enabled = index->preload_bulk_recovery_requested; return enabled; } diff --git a/read-cache-ll.h b/read-cache-ll.h index 409e4a9ca23c44..f721e1caf4e540 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -199,6 +199,8 @@ struct index_state { fsmonitor_legacy_untracked_fallback : 1, fsmonitor_pending_token_from_provider : 1, preload_untracked_complete : 1, + /* Read-only status request; never serialized. */ + preload_bulk_recovery_requested : 1, preload_bulk_provider_pending : 1, preload_bulk_excludes_digest_pending : 1, preload_bulk_excludes_digest_valid : 1; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 9a77b7e31bef3f..840d3689611a3b 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1015,7 +1015,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ - 'discarded legacy caches reuse an explicit bulk recovery pass' ' + 'discarded legacy caches select one bulk recovery pass' ' test_when_finished "rm -rf legacy-discard-bulk" && if test_have_prereq STATUS_BULK_PRELOAD then @@ -1122,26 +1122,43 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP test_cmp .git/paired .git/expect && test_cmp_bin .git/legacy.index .git/index && - GIT_OPTIONAL_LOCKS=0 \ - GIT_TEST_PRELOAD_INDEX=1 \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ - GIT_TRACE2_EVENT="$PWD/.git/disabled.trace" \ - git -c core.preloadIndexBulk=false \ - status --porcelain=v2 >.git/disabled && - test_cmp .git/expect .git/disabled && - test_cmp_bin .git/legacy.index .git/index && - test_trace2_data fsm_client query/trivial-response 1 \ - <.git/disabled.trace && - test_trace2_data fsmonitor untracked/legacy-preserved 1 \ - <.git/disabled.trace && - test_trace2_data fsmonitor untracked/legacy-discarded 1 \ - <.git/disabled.trace && - ! test_trace2_data status untracked/bulk-recovery 1 \ - <.git/disabled.trace && - ! test_trace2_data index preload/bulk_untracked_complete 1 \ - <.git/disabled.trace && - test_region dir read_directory .git/disabled.trace && - ! test_region index do_write_index .git/disabled.trace && + for disabled in bulk environment preload + do + case "$disabled" in + bulk) + set -- git -c core.preloadIndexBulk=false + ;; + environment) + set -- test_env GIT_TEST_PRELOAD_INDEX_BULK=0 \ + git -c core.preloadIndexBulk=true + ;; + preload) + set -- git -c core.preloadIndex=false \ + -c core.preloadIndexBulk=true + ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$disabled.trace" \ + "$@" status --porcelain=v2 \ + >".git/$disabled.actual" && + test_cmp .git/expect ".git/$disabled.actual" && + test_cmp_bin .git/legacy.index .git/index && + test_trace2_data fsm_client query/trivial-response 1 \ + <".git/$disabled.trace" && + test_trace2_data fsmonitor untracked/legacy-preserved 1 \ + <".git/$disabled.trace" && + test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <".git/$disabled.trace" && + ! test_trace2_data status untracked/bulk-recovery 1 \ + <".git/$disabled.trace" && + ! test_trace2_data index preload/bulk_untracked_complete 1 \ + <".git/$disabled.trace" && + test_region dir read_directory ".git/$disabled.trace" && + ! test_region index do_write_index \ + ".git/$disabled.trace" || return 1 + done && cp .git/paired.index .git/index && GIT_OPTIONAL_LOCKS=0 \ @@ -1159,14 +1176,19 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP <.git/paired.trace && cp .git/legacy.index .git/index && - for run in first second + for run in first second auto do + if test "$run" = auto + then + set -- git + else + set -- git -c core.preloadIndexBulk=true + fi && GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_PRELOAD_INDEX=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/$run.trace" \ - git -c core.preloadIndexBulk=true \ - status --porcelain=v2 \ + "$@" status --porcelain=v2 \ >".git/$run.actual" && test_cmp .git/expect ".git/$run.actual" && test_cmp_bin .git/legacy.index .git/index && diff --git a/wt-status.c b/wt-status.c index a048f848f69a20..f194d8001ff36b 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1108,6 +1108,7 @@ static void wt_status_prepare_bulk_recovery(struct wt_status *s) struct index_state *istate = s->repo->index; struct untracked_cache *uc = istate->untracked; + istate->preload_bulk_recovery_requested = 0; if (!uc || !uc->fsmonitor_legacy_discarded) return; uc->fsmonitor_legacy_discarded = 0; @@ -1127,14 +1128,19 @@ static void wt_status_prepare_bulk_recovery(struct wt_status *s) uc->fsmonitor_revalidation || clean_status_filter_scope_needs_validation(istate) || clean_status_manifest_global_fallback(istate) || - clean_status_worktree_manifest_needs_refresh(istate) || - !preload_index_bulk_can_close_provider(istate)) + clean_status_worktree_manifest_needs_refresh(istate)) return; + istate->preload_bulk_recovery_requested = 1; + if (!preload_index_bulk_can_close_provider(istate)) { + istate->preload_bulk_recovery_requested = 0; + return; + } /* * The index read discarded an unauthenticated directory tree. A * read-only caller cannot publish its replacement, so let the - * already enabled bulk scan supply complete untracked results. + * bulk scan supply both tracked and complete untracked results. + * Explicit configuration still takes precedence over this request. * If that scan cannot close, ordinary traversal still supplies them. */ remove_untracked_cache(istate); @@ -2371,6 +2377,7 @@ int wt_status_refresh_index(struct wt_status *s, proof = wt_status_prepare_semantic_verify(s, refresh_flags); ret = wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); + istate->preload_bulk_recovery_requested = 0; if (istate->preload_untracked == &s->untracked) { s->untracked_from_preload = istate->preload_untracked_complete; From 3fcb4c5a12b6c699f1a85cc3f5601d6c6578d530 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 00:40:08 -0500 Subject: [PATCH 334/403] status: recover discarded empty legacy caches in one pass In e36890c2b6 (status: reuse bulk results after discarding legacy caches, 2026-08-15), we remembered a discarded legacy cache only if it had a directory tree. A foreign index writer can leave a valid legacy UNTR extension with zero directory nodes and no FSUC or FSCF proof. The 1,160,465-entry checkout which exposed the duplicate walk has exactly that state, so the populated-cache restriction missed the real workload. Remember every actual replacement of a matching legacy cache. Newly initialized and current-format empty caches still do not qualify. Keep the existing read-only, provider, configuration, filter, and bulk-scan admission checks unchanged. Extend the regression with an empty legacy cache and a clean provider response, checking both explicit and automatic recovery. Also verify that a current-format empty cache retains ordinary traversal. On the real checkout, the complete recovery path reduces an explicitly enabled bulk status from 26.2 to 8.6 seconds by removing its second directory walk, with identical output and no index or sidecar writes. --- Documentation/config/core.adoc | 2 +- dir.c | 5 +- dir.h | 2 +- t/t7519-status-fsmonitor.sh | 104 ++++++++++++++++++++++++++++++++- wt-status.c | 2 +- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/Documentation/config/core.adoc b/Documentation/config/core.adoc index 6f8b4ac9372891..79af4e6038f255 100644 --- a/Documentation/config/core.adoc +++ b/Documentation/config/core.adoc @@ -739,7 +739,7 @@ state. Inconclusive scans are discarded before continuing with the normal preload. Currently this is supported on APFS, ext-family filesystems, and XFS, and only has an effect when `core.preloadIndex` is enabled. Defaults to false, except that a whole-worktree, read-only `git status` may use it -after the native file system monitor discards a populated legacy +after the native file system monitor discards a legacy untracked cache. Setting this option explicitly to false also disables that recovery optimization. diff --git a/dir.c b/dir.c index 2405796d911378..0c474dfad0c46b 100644 --- a/dir.c +++ b/dir.c @@ -4427,15 +4427,12 @@ int untracked_cache_adopt_legacy(struct index_state *istate) void untracked_cache_discard_legacy(struct index_state *istate) { - int had_root; - if (!istate->untracked || !legacy_ident_in_untracked(istate->untracked)) return; - had_root = !!istate->untracked->root; free_untracked_cache(istate->untracked); new_untracked_cache(istate, -1); - istate->untracked->fsmonitor_legacy_discarded = had_root; + istate->untracked->fsmonitor_legacy_discarded = 1; trace2_data_intmax("fsmonitor", istate->repo, "untracked/legacy-discarded", 1); } diff --git a/dir.h b/dir.h index 7f16e89302c72e..527ba58cc653e2 100644 --- a/dir.h +++ b/dir.h @@ -221,7 +221,7 @@ struct untracked_cache { unsigned int use_fsmonitor : 1; /* A lost provider boundary requires ordinary directory validation. */ unsigned int fsmonitor_revalidation : 1; - /* Process-local: a populated legacy cache was discarded on read. */ + /* Process-local: a legacy cache was discarded on read. */ unsigned int fsmonitor_legacy_discarded : 1; }; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 840d3689611a3b..0a7ad5aeee185f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1062,6 +1062,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP local $/; my $index = ; my $algorithm = $ARGV[0]; + my $empty = $ARGV[1] && $ARGV[1] =~ /^(current-)?empty$/; + my $current = $ARGV[1] && $ARGV[1] eq "current-empty"; my $rawsz = $algorithm eq "sha256" ? 32 : 20; my $body = substr($index, 0, -$rawsz); my $offset = index($body, "UNTR"); @@ -1089,14 +1091,21 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP my $suffix = ", cache version 2\0"; die "unexpected current UNTR identity\n" unless substr($ident, -length($suffix)) eq $suffix; - substr($ident, -length($suffix), length($suffix), "\0"); + substr($ident, -length($suffix), length($suffix), "\0") + unless $current; my $length = length($ident); my @bytes = ($length & 127); while ($length >>= 7) { unshift @bytes, 128 | ((--$length) & 127); } - my $replacement = pack("C*", @bytes) . $ident . - substr($payload, $cursor + $ident_length); + my $tail = substr($payload, $cursor + $ident_length); + if ($empty) { + my $exclude = index($tail, "\0", 76 + 2 * $rawsz); + die "missing UNTR per-directory exclude name\n" + if $exclude < 0; + $tail = substr($tail, 0, $exclude + 1) . "\0"; + } + my $replacement = pack("C*", @bytes) . $ident . $tail; substr($body, $offset, 8 + $size, "UNTR" . pack("N", length($replacement)) . $replacement); my $fsuc = index($body, "FSUC"); @@ -1105,6 +1114,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP die "invalid FSUC extension size\n" if $fsuc + 8 + $fsuc_size > length($body); substr($body, $fsuc, 8 + $fsuc_size, ""); + if ($empty) { + my $fscf = index($body, "FSCF"); + die "missing FSCF extension\n" if $fscf < 0; + my $fscf_size = unpack("N", substr($body, $fscf + 4, 4)); + die "invalid FSCF extension size\n" + if $fscf + 8 + $fscf_size > length($body); + substr($body, $fscf, 8 + $fscf_size, ""); + } print $body, $algorithm eq "sha256" ? sha256($body) : sha1($body); EOF @@ -1224,6 +1241,87 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP fi || return 1 done && + perl .git/restore-legacy-untracked.pl "$(test_oid algo)" \ + current-empty <.git/paired.index >.git/current.index && + cp .git/current.index .git/index && + test_grep FSMN .git/index && + test_grep UNTR .git/index && + test_grep ! FSCF .git/index && + test_grep ! FSUC .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/current.trace" \ + git status --porcelain=v2 >.git/current.actual && + test_cmp .git/expect .git/current.actual && + test_cmp_bin .git/current.index .git/index && + test_path_is_missing .git/index.csts && + find .git -maxdepth 1 -type f \ + \( -name "index.csh1.*" -o -name "index.cswi.*" \) \ + >.git/current.checkpoints && + test_must_be_empty .git/current.checkpoints && + ! test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <.git/current.trace && + ! test_trace2_data status untracked/bulk-recovery 1 \ + <.git/current.trace && + ! test_region index do_write_index .git/current.trace && + test_region dir read_directory .git/current.trace && + + perl .git/restore-legacy-untracked.pl "$(test_oid algo)" \ + empty <.git/paired.index >.git/empty.index && + cp .git/empty.index .git/index && + test_grep FSMN .git/index && + test_grep UNTR .git/index && + test_grep ! FSCF .git/index && + test_grep ! FSUC .git/index && + for run in empty-explicit empty-auto + do + if test "$run" = empty-auto + then + set -- git + else + set -- git -c core.preloadIndexBulk=true + fi && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$run.trace" \ + "$@" status --porcelain=v2 \ + >".git/$run.actual" && + test_cmp .git/expect ".git/$run.actual" && + test_cmp_bin .git/empty.index .git/index && + test_path_is_missing .git/index.csts && + find .git -maxdepth 1 -type f \ + \( -name "index.csh1.*" \ + -o -name "index.cswi.*" \) \ + >".git/$run.checkpoints" && + test_must_be_empty ".git/$run.checkpoints" && + test_trace2_data fsmonitor untracked/legacy-preserved 1 \ + <".git/$run.trace" && + test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <".git/$run.trace" && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <".git/$run.trace" && + ! test_region index do_write_index ".git/$run.trace" && + if test "$bulk_available" = yes + then + test_trace2_data status untracked/bulk-recovery 1 \ + <".git/$run.trace" && + test_trace2_data index \ + preload/bulk_untracked_complete 1 \ + <".git/$run.trace" && + ! test_region dir read_directory \ + ".git/$run.trace" + else + ! test_trace2_data index \ + preload/bulk_untracked_complete 1 \ + <".git/$run.trace" && + test_region dir read_directory \ + ".git/$run.trace" + fi || return 1 + done && + cp .git/legacy.index .git/index && + GIT_TEST_PRELOAD_INDEX=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/writable.trace" \ diff --git a/wt-status.c b/wt-status.c index f194d8001ff36b..356bc948c0711d 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1137,7 +1137,7 @@ static void wt_status_prepare_bulk_recovery(struct wt_status *s) } /* - * The index read discarded an unauthenticated directory tree. A + * The index read discarded an unauthenticated legacy cache. A * read-only caller cannot publish its replacement, so let the * bulk scan supply both tracked and complete untracked results. * Explicit configuration still takes precedence over this request. From 5e6cc3058cee863a900fd4447624a5095c1c316d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 11:46:47 -0500 Subject: [PATCH 335/403] t7519: isolate the bulk recovery test environment The environment-override case added in 5abe1ce4df (preload-index: select bulk for read-only cache recovery, 2026-08-16) selects test_env through "$@" and prefixes it with read-only and fake-provider variables. Assignments before a shell function need not have the same lifetime as an external command's environment. Linux CI exposes both a supposedly read-only control that rewrites the index and a final writable control that unexpectedly enters read-only bulk recovery. Use the external env command for the bulk override, and explicitly enable optional locks for the writable control. This keeps the two test modes independent without changing production behavior. --- t/t7519-status-fsmonitor.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 0a7ad5aeee185f..8d49966f859b4e 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1146,7 +1146,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP set -- git -c core.preloadIndexBulk=false ;; environment) - set -- test_env GIT_TEST_PRELOAD_INDEX_BULK=0 \ + set -- env GIT_TEST_PRELOAD_INDEX_BULK=0 \ git -c core.preloadIndexBulk=true ;; preload) @@ -1322,6 +1322,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done && cp .git/legacy.index .git/index && + GIT_OPTIONAL_LOCKS=1 \ GIT_TEST_PRELOAD_INDEX=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/writable.trace" \ From c6b2f26cf50bf3af04091c1343b11f11c505d457 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 11:57:15 -0500 Subject: [PATCH 336/403] fsmonitor: avoid full scans for partially proved private indexes In 8abeda59ec (fsmonitor: preserve authenticated proofs across ordinary commands, 2026-08-15), the conservative temporary-index path required both a complete proof and partial manifest history to be absent. A sparse add to a copied index can retain only the manifest-complete and full-index flags. That history cannot authenticate the index, but it still excludes the conservative path. The next sparse add or write-tree therefore rebuilds the complete worktree manifest. Use the existing strong invalidation path whenever a genuine private index lacks a complete proof. Keep the physical-index, index-lock, device/inode, provider, filesystem, and split-index guards unchanged. This does not promote partial history into a valid proof: tracked entries and the untracked cache are invalidated before normal processing. Extend the primary and linked-worktree regression with repeated sparse staging through one copied index. Compare its tree with independently staged, fsmonitor-disabled controls, and retain alias, attribute, and required-filter failure checks. Both hash formats reproduce the unwanted scan without this change and pass with it. --- fsmonitor.c | 3 +- t/t7519-status-fsmonitor.sh | 199 +++++++++++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 7 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index 1800ae7174f6a0..5dccb81b25f62b 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1241,8 +1241,7 @@ static void invalidate_fsmonitor_for_bootstrap( return; } if (getenv(INDEX_ENVIRONMENT) && - !clean_status_has_persistent_fsmonitor_semantic_history(istate) && - !clean_status_has_worktree_manifest_history(istate)) { + !clean_status_has_persistent_fsmonitor_semantic_history(istate)) { char *physical = xstrfmt("%s/index", repo_get_git_dir(istate->repo)); char *selected = real_pathdup(repo_get_index_file(istate->repo), 0); char *canonical = real_pathdup(physical, 0); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 8d49966f859b4e..096702c35025f2 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1646,6 +1646,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ >"$gitdir/canonical-tree" && test_region index do_write_index \ "$gitdir/canonical-tree.trace" && + ! test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/canonical-tree.trace" && ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <"$gitdir/canonical-tree.trace" && perl "$PWD/.git/check-write-tree-proof.pl" \ @@ -1681,6 +1684,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git -C "$worktree" ls-tree HEAD hook-generated \ >"$gitdir/hook-all-entry" && test_grep "hook-generated$" "$gitdir/hook-all-entry" && + ! test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/hook-all.trace" && ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <"$gitdir/hook-all.trace" && perl "$PWD/.git/check-write-tree-proof.pl" \ @@ -1701,27 +1707,210 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ cp "$gitdir/index" "$gitdir/readonly.index" && cp "$gitdir/index" "$gitdir/snapshot.index" && test_write_lines snapshot >"$worktree/snapshot-new" && - printf "%s\n" snapshot-new | + printf "%s\0" snapshot-new | + GIT_OPTIONAL_LOCKS=0 \ GIT_INDEX_FILE="$gitdir/snapshot.index" \ + GIT_LITERAL_PATHSPECS=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - GIT_TRACE2_EVENT="$gitdir/snapshot-add.trace" \ + GIT_TRACE2_EVENT="$gitdir/snapshot-prime.trace" \ git -C "$worktree" add --sparse \ - --pathspec-from-file=- && + --pathspec-from-file=- \ + --pathspec-file-nul && perl "$PWD/.git/check-write-tree-unbound.pl" \ <"$gitdir/snapshot.index" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + if test_have_prereq HARDLINKS && + test_have_prereq SYMLINKS + then + cp "$gitdir/snapshot.index" "$gitdir/index" && + ln "$gitdir/index" "$gitdir/physical-hardlink" && + ln -s "$gitdir/index" "$gitdir/physical-symlink" && + cp "$gitdir/index" "$gitdir/index.lock" && + for alias in index physical-hardlink \ + physical-symlink index.lock + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/$alias" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/alias-$alias.trace" \ + git -C "$worktree" status \ + --porcelain=v2 \ + >"$gitdir/alias-$alias" && + ! test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/alias-$alias.trace" && + test_cmp_bin "$gitdir/snapshot.index" \ + "$gitdir/index" || return 1 + done && + rm -f "$gitdir/physical-hardlink" \ + "$gitdir/physical-symlink" \ + "$gitdir/index.lock" && + cp "$gitdir/readonly.index" "$gitdir/index" + fi && + test_write_lines next >"$worktree/snapshot-next" && + printf "%s\0" snapshot-next | + GIT_OPTIONAL_LOCKS=0 \ GIT_INDEX_FILE="$gitdir/snapshot.index" \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_LITERAL_PATHSPECS=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/snapshot-add.trace" \ + git -C "$worktree" add --sparse \ + --pathspec-from-file=- \ + --pathspec-file-nul && + test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/snapshot-add.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/snapshot-add.trace" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/snapshot.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/snapshot-tree.trace" \ git -C "$worktree" write-tree \ >"$gitdir/snapshot-tree" && test_file_not_empty "$gitdir/snapshot-tree" && + test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/snapshot-tree.trace" && ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <"$gitdir/snapshot-tree.trace" && git -C "$worktree" ls-tree \ - "$(cat "$gitdir/snapshot-tree")" snapshot-new \ + "$(cat "$gitdir/snapshot-tree")" \ + snapshot-new snapshot-next \ >"$gitdir/snapshot-entry" && test_grep "snapshot-new$" "$gitdir/snapshot-entry" && + test_grep "snapshot-next$" "$gitdir/snapshot-entry" && + cp "$gitdir/readonly.index" "$gitdir/control.index" && + printf "%s\0" snapshot-new snapshot-next | + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/control.index" \ + GIT_LITERAL_PATHSPECS=1 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -C "$worktree" add --sparse \ + --pathspec-from-file=- \ + --pathspec-file-nul && + GIT_INDEX_FILE="$gitdir/control.index" \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -C "$worktree" write-tree \ + >"$gitdir/snapshot-expect" && + test_cmp "$gitdir/snapshot-expect" "$gitdir/snapshot-tree" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_write_lines "*.filtered filter=snapshot" \ + >"$worktree/.gitattributes" && + test_write_lines raw >"$worktree/snapshot.filtered" && + printf "%s\0" .gitattributes snapshot.filtered | + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/snapshot.index" \ + GIT_LITERAL_PATHSPECS=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/filter-add.trace" \ + git -c filter.snapshot.clean="sed s/raw/converted/" \ + -c filter.snapshot.required=true \ + -C "$worktree" add --sparse \ + --pathspec-from-file=- \ + --pathspec-file-nul && + test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/filter-add.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/filter-add.trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/snapshot.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/filter-tree.trace" \ + git -c filter.snapshot.clean="sed s/raw/converted/" \ + -c filter.snapshot.required=true \ + -C "$worktree" write-tree \ + >"$gitdir/filter-tree" && + test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/filter-tree.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/filter-tree.trace" && + cp "$gitdir/readonly.index" "$gitdir/filter-control.index" && + printf "%s\0" snapshot-new snapshot-next \ + .gitattributes snapshot.filtered | + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/filter-control.index" \ + GIT_LITERAL_PATHSPECS=1 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c filter.snapshot.clean="sed s/raw/converted/" \ + -c filter.snapshot.required=true \ + -C "$worktree" add --sparse \ + --pathspec-from-file=- \ + --pathspec-file-nul && + GIT_INDEX_FILE="$gitdir/filter-control.index" \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c filter.snapshot.clean="sed s/raw/converted/" \ + -c filter.snapshot.required=true \ + -C "$worktree" write-tree \ + >"$gitdir/filter-expect" && + test_cmp "$gitdir/filter-expect" "$gitdir/filter-tree" && + git -C "$worktree" cat-file blob \ + "$(cat "$gitdir/filter-tree"):snapshot.filtered" \ + >"$gitdir/filter-actual-blob" && + test_write_lines converted >"$gitdir/filter-expect-blob" && + test_cmp "$gitdir/filter-expect-blob" \ + "$gitdir/filter-actual-blob" && + test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + test_write_lines raw >"$worktree/required-failure.filtered" && + printf "%s\0" required-failure.filtered \ + >"$gitdir/required-failure.paths" && + cp "$gitdir/snapshot.index" "$gitdir/filter-failure.index" && + cp "$gitdir/filter-failure.index" \ + "$gitdir/filter-failure.before" && + test_must_fail env \ + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/filter-failure.index" \ + GIT_LITERAL_PATHSPECS=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/filter-failure.trace" \ + git -c filter.snapshot.clean=false \ + -c filter.snapshot.required=true \ + -C "$worktree" add --sparse \ + --pathspec-from-file="$gitdir/required-failure.paths" \ + --pathspec-file-nul \ + 2>"$gitdir/filter-failure.error" && + test_grep "clean filter .snapshot. failed" \ + "$gitdir/filter-failure.error" && + test_trace2_data fsmonitor \ + semantic/temporary-index-stat-fallback 1 \ + <"$gitdir/filter-failure.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/filter-failure.trace" && + test_cmp_bin "$gitdir/filter-failure.before" \ + "$gitdir/filter-failure.index" && + cp "$gitdir/readonly.index" \ + "$gitdir/filter-control-failure.index" && + test_must_fail env \ + GIT_OPTIONAL_LOCKS=0 \ + GIT_INDEX_FILE="$gitdir/filter-control-failure.index" \ + GIT_LITERAL_PATHSPECS=1 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c filter.snapshot.clean=false \ + -c filter.snapshot.required=true \ + -C "$worktree" add --sparse \ + --pathspec-from-file="$gitdir/required-failure.paths" \ + --pathspec-file-nul \ + 2>"$gitdir/filter-control-failure.error" && + test_grep "clean filter .snapshot. failed" \ + "$gitdir/filter-control-failure.error" && + test_cmp_bin "$gitdir/readonly.index" \ + "$gitdir/filter-control-failure.index" && test_cmp_bin "$gitdir/readonly.index" "$gitdir/index" && + rm -f "$worktree/.gitattributes" \ + "$worktree/snapshot.filtered" \ + "$worktree/required-failure.filtered" && GIT_INDEX_FILE="$gitdir/manifestless.index" \ git -C "$worktree" read-tree HEAD && test_grep ! FSCF "$gitdir/manifestless.index" && From b208bc4f2298bdd37279f3b8e8835a07b82254fb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:10:18 -0500 Subject: [PATCH 337/403] t7519: disarm completed query-barrier cleanups The query-barrier tests added by ef6cdb5039 (diff: reuse pinned observations during provider-reset repair, 2026-08-15) and 7c19bf467b (fsmonitor: retain checked manifests across scoped directory deltas, 2026-08-15) leave their subshell EXIT traps armed after successfully waiting for the background Git process. Under dash with --verbose-log -x, those now-empty cleanups can emit xtrace lines outside the test body's log redirection. The linux32 and linux-TEST-vars jobs pass all 97 assertions, but prove rejects the six extra lines as invalid TAP. Disarm each trap after a successful wait and clearing the saved PID. Keep it armed while the child is running, so a failed assertion still terminates and reaps the process. No production behavior changes. --- t/t7519-status-fsmonitor.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 096702c35025f2..27f8a22692afb7 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3079,6 +3079,7 @@ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ printf x >"$resume" && wait "$fsmonitor_query_pid" && fsmonitor_query_pid= && + trap - 0 && test_must_be_empty .git/actual && test_cmp_bin .git/competing.index .git/index && ! test_region index do_write_index .git/diff.trace && @@ -4153,6 +4154,7 @@ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ printf x >"$resume" && wait "$fsmonitor_query_pid" && fsmonitor_query_pid= && + trap - 0 && test_cmp .git/expect .git/actual && test_grep "^1 \\.M .* cached/tracked$" .git/actual && test_grep "^? sibling-1/visible$" .git/actual && From 8def7dcd435be69b494239d576de7bd3db73cec2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:17:49 -0500 Subject: [PATCH 338/403] t7519: verify reuse after writable legacy-cache repair In e36890c2b6 (status: reuse bulk results after discarding legacy caches, 2026-08-15), writable status deliberately kept the ordinary directory walk so that it could rebuild durable untracked metadata. The regression verifies the resulting paired proof, but not whether the next process can reuse it. Follow that repair with a read-only status. Require the same independent oracle output and a coherent paired proof, with no new manifest scan, physical directory opens, tracked-file stats, bulk walk, or index write. Also compare the physical index and all existing history sidecars before and after the command. Cached untracked-directory nodes may still be visited to produce the requested output. --- t/t7519-status-fsmonitor.sh | 38 ++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 27f8a22692afb7..79443f84c1bd23 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1334,7 +1334,43 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ! test_trace2_data status untracked/bulk-recovery 1 \ <.git/writable.trace && test_region index do_write_index .git/writable.trace && - test_fsmonitor_full_proof .git/index paired + test_fsmonitor_full_proof .git/index paired && + cp .git/index .git/writable.index && + find .git -maxdepth 1 -type f \ + \( -name "index.csts" -o -name "index.csh1.*" \ + -o -name "index.cswi.*" \) | + sort >.git/sidecars.before && + git hash-object --no-filters --stdin-paths \ + <.git/sidecars.before >.git/sidecar-hashes.before && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/follower.trace" \ + git status --porcelain=v2 >.git/follower && + test_cmp .git/expect .git/follower && + test_cmp_bin .git/writable.index .git/index && + test_fsmonitor_full_proof .git/index paired && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/follower.trace && + ! test_trace2_data fsmonitor untracked/legacy-discarded 1 \ + <.git/follower.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <.git/follower.trace && + ! test_trace2_data read_directory opendir \ + "[1-9][0-9]*" <.git/follower.trace && + ! test_trace2_data index preload/bulk_dirs \ + "[1-9][0-9]*" <.git/follower.trace && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" <.git/follower.trace && + ! test_region index do_write_index .git/follower.trace && + find .git -maxdepth 1 -type f \ + \( -name "index.csts" -o -name "index.csh1.*" \ + -o -name "index.cswi.*" \) | + sort >.git/sidecars.after && + test_cmp .git/sidecars.before .git/sidecars.after && + git hash-object --no-filters --stdin-paths \ + <.git/sidecars.after >.git/sidecar-hashes.after && + test_cmp .git/sidecar-hashes.before .git/sidecar-hashes.after ) ' From d079f66b8340c8c86a46dfc073622b36f0a6cf35 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:23:42 -0500 Subject: [PATCH 339/403] fsmonitor: avoid repository-wide bootstrap for bounded readers The physical-index recovery added in 2181cb2ea9 (fsmonitor: seed missing-history baselines from legacy tokens, 2026-07-24) can rebuild the complete attribute manifest before an index-only or path-limited reader starts its actual work. Repeating such commands against an incomplete proof repeats the same repository-wide scan. Let a narrowly classified reader request conservative bootstrap for its first index read. Expire the tracked-entry semantics and untracked cache instead of reconstructing a proof, so the requested paths still receive ordinary content checks. Restrict this to the canonical, non-split, non-sparse index with a reliable native provider. Cached diffs, bounded regular-file diffs, explicit check-attr requests, and stage-only ls-files requests opt in; other command forms keep the existing recovery path. Check the parsed index representation before skipping external-history restoration, which would otherwise hash a stale checkpoint before the fallback runs. Suppress diff's opportunistic index refresh only when this noncertifying fallback was actually used. Exercise main and linked worktrees in both hash formats. Compare each reader with a provider-disabled oracle, including same-stat content changes, changed attributes, and a failing required filter. Require no manifest reconstruction, checkpoint digest, or physical index write, and retain the original path for an on-disk split index and an unbounded worktree diff. --- builtin/check-attr.c | 12 ++ builtin/diff.c | 53 ++++++- builtin/ls-files.c | 29 ++++ clean-status-history.c | 4 +- fsmonitor.c | 79 ++++++++++ fsmonitor.h | 5 + t/t7534-status-scoped-readers.sh | 263 +++++++++++++++++++++++++++++++ 7 files changed, 442 insertions(+), 3 deletions(-) create mode 100755 t/t7534-status-scoped-readers.sh diff --git a/builtin/check-attr.c b/builtin/check-attr.c index 217d83ea7d5de0..f7083585fbfa95 100644 --- a/builtin/check-attr.c +++ b/builtin/check-attr.c @@ -3,6 +3,7 @@ #include "config.h" #include "attr.h" #include "environment.h" +#include "fsmonitor.h" #include "gettext.h" #include "object-name.h" #include "quote.h" @@ -115,6 +116,7 @@ int cmd_check_attr(int argc, struct attr_check *check; struct object_id initialized_oid; int cnt, i, doubledash, filei; + int scoped_bootstrap = 0; if (!is_bare_repository(the_repository)) setup_work_tree(the_repository); @@ -127,9 +129,19 @@ int cmd_check_attr(int argc, prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; + if (!stdin_paths && !source) { + for (i = 0; i < argc && strcmp(argv[i], "--"); i++) + ; + scoped_bootstrap = i < argc && + argc - i - 1 > 0 && argc - i - 1 <= 64; + } + if (scoped_bootstrap) + fsmonitor_begin_scoped_bootstrap(the_repository->index); if (repo_read_index(the_repository) < 0) { die("invalid cache"); } + if (scoped_bootstrap) + fsmonitor_end_scoped_bootstrap(the_repository->index); if (cached_attrs) git_attr_set_direction(GIT_ATTR_INDEX); diff --git a/builtin/diff.c b/builtin/diff.c index d384b3b7383604..643a4925e12fbd 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -20,6 +20,7 @@ #include "environment.h" #include "gettext.h" #include "fsmonitor-ll.h" +#include "fsmonitor.h" #include "fsmonitor-settings.h" #include "tag.h" #include "trace2.h" @@ -32,6 +33,7 @@ #include "revision.h" #include "log-tree.h" #include "setup.h" +#include "symlinks.h" #include "thread-utils.h" #include "oid-array.h" #include "tree.h" @@ -51,6 +53,32 @@ static const char builtin_diff_usage[] = "\n" COMMON_DIFF_OPTIONS_HELP; +static int scoped_diff_bootstrap_used; + +static int diff_has_bounded_regular_pathspec(const struct pathspec *pathspec) +{ + int i; + + if (pathspec->nr <= 0 || pathspec->nr > 64 || + pathspec->has_wildcard || + (pathspec->magic & + (PATHSPEC_GLOB | PATHSPEC_ICASE | + PATHSPEC_EXCLUDE | PATHSPEC_ATTR))) + return 0; + for (i = 0; i < pathspec->nr; i++) { + const struct pathspec_item *item = &pathspec->items[i]; + struct stat st; + + if (!item->match || item->len <= 0 || + item->match[item->len - 1] == '/' || + !strcmp(item->match, ".") || + has_symlink_leading_path(item->match, item->len) || + lstat(item->match, &st) || !S_ISREG(st.st_mode)) + return 0; + } + return 1; +} + static const char *blob_path(struct object_array_entry *entry) { return entry->path ? entry->path : entry->name; @@ -152,6 +180,8 @@ static void builtin_diff_index(struct rev_info *revs, int argc, const char **argv) { unsigned int option = 0; + int scoped_bootstrap; + while (1 < argc) { const char *arg = argv[1]; if (!strcmp(arg, "--cached") || !strcmp(arg, "--staged")) @@ -170,8 +200,13 @@ static void builtin_diff_index(struct rev_info *revs, revs->max_count != -1 || revs->min_age != -1 || revs->max_age != -1) usage(builtin_diff_usage); - if (!(option & DIFF_INDEX_CACHED)) { + if (!(option & DIFF_INDEX_CACHED)) setup_work_tree(the_repository); + scoped_bootstrap = (option & DIFF_INDEX_CACHED) || + diff_has_bounded_regular_pathspec(&revs->diffopt.pathspec); + if (scoped_bootstrap) + fsmonitor_begin_scoped_bootstrap(the_repository->index); + if (!(option & DIFF_INDEX_CACHED)) { if (repo_read_index_preload(the_repository, &revs->diffopt.pathspec, 0) < 0) { die_errno("repo_read_index_preload"); @@ -179,6 +214,9 @@ static void builtin_diff_index(struct rev_info *revs, } else if (repo_read_index(the_repository) < 0) { die_errno("repo_read_cache"); } + if (scoped_bootstrap) + scoped_diff_bootstrap_used |= + fsmonitor_end_scoped_bootstrap(the_repository->index); run_diff_index(revs, option); } @@ -459,6 +497,7 @@ static void refresh_index_quietly(void) static void builtin_diff_files(struct rev_info *revs, int argc, const char **argv) { unsigned int options = 0; + int scoped_bootstrap; while (1 < argc && argv[1][0] == '-') { if (!strcmp(argv[1], "--base")) @@ -489,10 +528,17 @@ static void builtin_diff_files(struct rev_info *revs, int argc, const char **arg diff_merges_set_dense_combined_if_unset(revs); setup_work_tree(the_repository); + scoped_bootstrap = + diff_has_bounded_regular_pathspec(&revs->diffopt.pathspec); + if (scoped_bootstrap) + fsmonitor_begin_scoped_bootstrap(the_repository->index); if (repo_read_index_preload(the_repository, &revs->diffopt.pathspec, 0) < 0) { die_errno("repo_read_index_preload"); } + if (scoped_bootstrap) + scoped_diff_bootstrap_used |= + fsmonitor_end_scoped_bootstrap(the_repository->index); run_diff_files(revs, options); } @@ -639,6 +685,8 @@ int cmd_diff(int argc, int result; struct symdiff sdiff; + scoped_diff_bootstrap_used = 0; + /* * We could get N tree-ish in the rev.pending_objects list. * Also there could be M blobs there, and P pathspecs. --cached may @@ -875,7 +923,8 @@ int cmd_diff(int argc, ent.objects, ent.nr, first_non_parent); result = diff_result_code(&rev); - if (1 < rev.diffopt.skip_stat_unmatch) + if (1 < rev.diffopt.skip_stat_unmatch && + !scoped_diff_bootstrap_used) refresh_index_quietly(); release_revisions(&rev); object_array_clear(&ent); diff --git a/builtin/ls-files.c b/builtin/ls-files.c index b044520f9e3c39..5c2656c43f5fe1 100644 --- a/builtin/ls-files.c +++ b/builtin/ls-files.c @@ -12,6 +12,7 @@ #include "config.h" #include "convert.h" #include "environment.h" +#include "fsmonitor.h" #include "quote.h" #include "dir.h" #include "gettext.h" @@ -587,6 +588,28 @@ static int option_parse_exclude_standard(const struct option *opt, return 0; } +static int ls_files_has_bounded_stage_request(int argc, const char **argv) +{ + int i; + int stage = 0; + + for (i = 1; i < argc && strcmp(argv[i], "--"); i++) { + if (!strcmp(argv[i], "--stage") || !strcmp(argv[i], "-s")) + stage = 1; + else if (strcmp(argv[i], "-z")) + return 0; + } + if (!stage || i == argc || argc - i - 1 <= 0 || + argc - i - 1 > 64) + return 0; + for (i++; i < argc; i++) { + if (!*argv[i] || starts_with(argv[i], ":(") || + strpbrk(argv[i], "*?[")) + return 0; + } + return 1; +} + int cmd_ls_files(int argc, const char **argv, const char *cmd_prefix, @@ -666,6 +689,7 @@ int cmd_ls_files(int argc, OPT_END() }; int ret = 0; + int scoped_bootstrap; show_usage_with_options_if_asked(argc, argv, ls_files_usage, builtin_ls_files_options); @@ -678,8 +702,13 @@ int cmd_ls_files(int argc, prefix_len = strlen(prefix); repo_config(repo, git_default_config, NULL); + scoped_bootstrap = ls_files_has_bounded_stage_request(argc, argv); + if (scoped_bootstrap) + fsmonitor_begin_scoped_bootstrap(repo->index); if (repo_read_index(repo) < 0) die("index file corrupt"); + if (scoped_bootstrap) + fsmonitor_end_scoped_bootstrap(repo->index); argc = parse_options(argc, argv, prefix, builtin_ls_files_options, ls_files_usage, 0); diff --git a/clean-status-history.c b/clean-status-history.c index e02d8830ecb67d..073182c9ed8f27 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1503,7 +1503,9 @@ int clean_status_restore_external_history(struct index_state *istate) int provider_reset_recovery = 0; int restored = 0; - if (!clean_status_external_history_enabled(istate) || !state || + /* Split and sparse representations are known only after parsing. */ + if (fsmonitor_scoped_bootstrap_is_active(istate) || + !clean_status_external_history_enabled(istate) || !state || state->disk_config_invalid || !state->config_enforced || !state->current_config_valid || !state->current_semantic_valid || diff --git a/fsmonitor.c b/fsmonitor.c index 5dccb81b25f62b..b1c07929d98581 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -16,6 +16,7 @@ #include "hashmap.h" #include "hex-ll.h" #include "name-hash.h" +#include "replace-object.h" #include "repository.h" #include "run-command.h" #include "strbuf.h" @@ -30,6 +31,75 @@ struct trace_key trace_fsmonitor = TRACE_KEY_INIT(FSMONITOR); +static struct index_state *scoped_bootstrap_index; +static int scoped_bootstrap_eligible; +static int scoped_bootstrap_used; + +static int fsmonitor_scoped_bootstrap_is_eligible(struct index_state *istate) +{ + struct repository *repo = istate->repo; + struct stat st; + char *physical, *selected, *canonical; + int eligible; + + if (istate != repo->index || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + getenv(INDEX_ENVIRONMENT) || + getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || + getenv(DB_ENVIRONMENT) || + getenv(ALTERNATE_DB_ENVIRONMENT) || + !fstat_is_reliable() || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC || + repo_config_get_split_index(repo) > 0 || + repo_config_values(repo)->apply_sparse_checkout || + repo_has_replace_refs_uncached(repo)) + return 0; + + physical = xstrfmt("%s/index", repo_get_git_dir(repo)); + selected = real_pathdup(repo_get_index_file(repo), 0); + canonical = real_pathdup(physical, 0); + eligible = selected && canonical && + !fspathcmp(selected, canonical) && + !lstat(physical, &st) && S_ISREG(st.st_mode) && + st.st_nlink == 1; + free(canonical); + free(selected); + free(physical); + return eligible; +} + +void fsmonitor_begin_scoped_bootstrap(struct index_state *istate) +{ + if (scoped_bootstrap_index) + BUG("nested scoped fsmonitor bootstrap"); + scoped_bootstrap_index = istate; + scoped_bootstrap_eligible = + fsmonitor_scoped_bootstrap_is_eligible(istate); + scoped_bootstrap_used = 0; +} + +int fsmonitor_scoped_bootstrap_is_active(const struct index_state *istate) +{ + return scoped_bootstrap_index == istate && + scoped_bootstrap_eligible && !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + !repo_config_values(istate->repo)->apply_sparse_checkout; +} + +int fsmonitor_end_scoped_bootstrap(struct index_state *istate) +{ + int used; + + if (scoped_bootstrap_index != istate) + BUG("scoped fsmonitor bootstrap index changed"); + used = scoped_bootstrap_used; + scoped_bootstrap_index = NULL; + scoped_bootstrap_eligible = 0; + scoped_bootstrap_used = 0; + return used; +} + static void assert_index_minimum(struct index_state *istate, size_t pos) { if (pos > istate->cache_nr) @@ -1268,6 +1338,15 @@ static void invalidate_fsmonitor_for_bootstrap( } } + if (fsmonitor_scoped_bootstrap_is_active(istate)) { + fsmonitor_invalidate_semantics(istate); + untracked_cache_invalidate_all(istate); + scoped_bootstrap_used = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "semantic/scoped-reader-stat-fallback", 1); + return; + } + if (physical_history_unavailable) { int authenticated_manifest = clean_status_has_authenticated_worktree_manifest(istate); diff --git a/fsmonitor.h b/fsmonitor.h index ef178d61a225d9..be8136767a9bbe 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -56,6 +56,11 @@ static inline int fsmonitor_stat_can_be_valid(const struct stat *st) void fsmonitor_invalidate_semantics(struct index_state *istate); +/* Bound conservative bootstrap to one index read; never issue a proof. */ +void fsmonitor_begin_scoped_bootstrap(struct index_state *istate); +int fsmonitor_scoped_bootstrap_is_active(const struct index_state *istate); +int fsmonitor_end_scoped_bootstrap(struct index_state *istate); + /* * Check if refresh_fsmonitor has been called at least once. * refresh_fsmonitor is idempotent. Returns true if fsmonitor is diff --git a/t/t7534-status-scoped-readers.sh b/t/t7534-status-scoped-readers.sh new file mode 100755 index 00000000000000..4d2081b4def702 --- /dev/null +++ b/t/t7534-status-scoped-readers.sh @@ -0,0 +1,263 @@ +#!/bin/sh + +test_description='bounded readers do not certify partial fsmonitor history' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh + +test_lazy_prereq UNTRACKED_CACHE ' + { git update-index --test-untracked-cache; ret=$?; } && + test $ret -ne 1 +' + +test_scoped_partial_proof () { + perl - "$1" <<-\EOF + open my $input, "<", $ARGV[0] or die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my $offset = index($index, "FSCF"); + die "missing FSCF extension\n" if $offset < 0; + my $flags = unpack("N", substr($index, $offset + 16, 4)); + die "unexpected FSCF flags $flags\n" if $flags != 9; + EOF +} + +test_scoped_remove_fscf () { + perl - "$1" "$2" <<-\EOF + use Digest::SHA qw(sha1 sha256); + open my $input, "<", $ARGV[0] or die "cannot read index: $!\n"; + binmode $input; + binmode STDOUT; + local $/; + my $index = <$input>; + my $rawsz = $ARGV[1] eq "sha256" ? 32 : 20; + my $offset = index($index, "FSCF"); + die "missing FSCF extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + my $payload = substr($index, 0, -$rawsz); + print $payload, $rawsz == 32 ? sha256($payload) : sha1($payload); + EOF +} + +assert_scoped_reader () { + scoped_label=$1 && + scoped_locks=$2 && + shift 2 && + cp "$gitdir/index" "$gitdir/$scoped_label.index" && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + -C "$worktree" "$@" \ + >"$gitdir/$scoped_label.expect" && + GIT_OPTIONAL_LOCKS=$scoped_locks \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$scoped_label.trace" \ + git -C "$worktree" "$@" \ + >"$gitdir/$scoped_label.actual" && + test_cmp "$gitdir/$scoped_label.expect" \ + "$gitdir/$scoped_label.actual" && + test_trace2_data fsmonitor \ + semantic/scoped-reader-stat-fallback 1 \ + <"$gitdir/$scoped_label.trace" && + test_grep ! "\"label\":\"history_logical_digest\"" \ + "$gitdir/$scoped_label.trace" && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <"$gitdir/$scoped_label.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/$scoped_label.trace" && + test_region ! index do_write_index \ + "$gitdir/$scoped_label.trace" && + test_cmp_bin "$gitdir/$scoped_label.index" "$gitdir/index" && + test_cmp_bin "$gitdir/checkpoint.pristine" "$scoped_checkpoint" +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'bounded physical-index readers reject incomplete history without a manifest' ' + test_when_finished "rm -rf scoped-readers scoped-readers-linked" && + test_create_repo scoped-readers && + ( + cd scoped-readers && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "tracked text" >.gitattributes && + test_write_lines aaaa >tracked && + test_write_lines side >sibling && + git add .gitattributes tracked sibling && + git commit -m base && + git worktree add --detach ../scoped-readers-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../scoped-readers-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + test-tool chmtime -120 "$worktree/.gitattributes" \ + "$worktree/tracked" "$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ + git -C "$worktree" status --short \ + >"$gitdir/checkpoint.status" && + test_must_be_empty "$gitdir/checkpoint.status" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/checkpoint.trace" && + find "$gitdir" -maxdepth 1 -type f \ + -name "index.csh1.*" >"$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/checkpoints" && + scoped_checkpoint=$(cat "$gitdir/checkpoints") && + cp "$scoped_checkpoint" "$gitdir/checkpoint.pristine" && + cp "$gitdir/index" "$gitdir/private.index" && + test_write_lines staged >"$worktree/staged" && + GIT_INDEX_FILE="$gitdir/private.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" add --sparse -- staged && + test_scoped_partial_proof "$gitdir/private.index" && + cp "$gitdir/private.index" "$gitdir/index" && + assert_scoped_reader cached-readonly 0 \ + diff --no-ext-diff --no-textconv \ + --cached HEAD --name-only -z && + assert_scoped_reader cached-default 1 \ + diff --no-ext-diff --no-textconv \ + --cached HEAD --name-only -z && + assert_scoped_reader worktree-readonly 0 \ + diff --no-ext-diff --no-textconv -- tracked && + assert_scoped_reader worktree-default 1 \ + diff --no-ext-diff --no-textconv -- tracked && + test_must_be_empty "$gitdir/worktree-default.actual" && + if test "$worktree" != "$PWD" + then + cp "$gitdir/index" "$gitdir/partial.saved" && + test_scoped_remove_fscf "$gitdir/index" \ + "$(test_oid algo)" >"$gitdir/stale.index" && + mv "$gitdir/stale.index" "$gitdir/index" && + test_grep ! FSCF "$gitdir/index" && + assert_scoped_reader stale-linked-checkpoint 0 \ + diff --no-ext-diff --no-textconv -- tracked && + cp "$gitdir/partial.saved" "$gitdir/index" || + return 1 + fi && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/root-path.trace" \ + git -C "$worktree" diff \ + --no-ext-diff --no-textconv -- . \ + >"$gitdir/root-path.actual" && + ! test_trace2_data fsmonitor \ + semantic/scoped-reader-stat-fallback 1 \ + <"$gitdir/root-path.trace" && + test_cmp_bin "$gitdir/private.index" "$gitdir/index" && + assert_scoped_reader attributes 0 \ + check-attr -a -- tracked sibling && + assert_scoped_reader cached-attributes 0 \ + check-attr --cached -a -- tracked && + assert_scoped_reader index-stage-readonly 0 \ + ls-files --stage -- tracked staged && + assert_scoped_reader index-stage-default 1 \ + ls-files --stage -- tracked staged && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/fsmonitor-mode.trace" \ + git -C "$worktree" ls-files -f -- tracked \ + >"$gitdir/fsmonitor-mode" && + ! test_trace2_data fsmonitor \ + semantic/scoped-reader-stat-fallback 1 \ + <"$gitdir/fsmonitor-mode.trace" && + test_cmp_bin "$gitdir/private.index" "$gitdir/index" && + git -C "$worktree" config core.trustctime false && + git -C "$worktree" config core.checkStat minimal && + mtime=$(test-tool chmtime --get "$worktree/tracked") && + test_write_lines bbbb >"$worktree/tracked" && + test-tool chmtime =$mtime "$worktree/tracked" && + assert_scoped_reader same-stat 0 \ + diff --no-ext-diff --no-textconv -- tracked && + test_grep "^diff --git a/tracked b/tracked$" \ + "$gitdir/same-stat.actual" && + test_write_lines "tracked custom=changed" \ + >"$worktree/.gitattributes" && + assert_scoped_reader changed-attributes 0 \ + check-attr -a -- tracked && + test_grep "tracked: custom: changed" \ + "$gitdir/changed-attributes.actual" && + test_write_lines "tracked filter=required" \ + >"$worktree/.gitattributes" && + git -C "$worktree" config filter.required.clean false && + git -C "$worktree" config filter.required.required true && + cp "$gitdir/index" "$gitdir/filter.before" && + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/required-filter.trace" \ + git -C "$worktree" diff \ + --no-ext-diff --no-textconv -- tracked \ + >"$gitdir/required-filter.actual" \ + 2>"$gitdir/required-filter.error" && + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + -C "$worktree" diff \ + --no-ext-diff --no-textconv -- tracked \ + >"$gitdir/required-filter.expect" \ + 2>"$gitdir/required-filter.oracle-error" && + test_grep "clean filter .required. failed" \ + "$gitdir/required-filter.error" && + test_grep "clean filter .required. failed" \ + "$gitdir/required-filter.oracle-error" && + test_trace2_data fsmonitor \ + semantic/scoped-reader-stat-fallback 1 \ + <"$gitdir/required-filter.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/required-filter.trace" && + test_cmp_bin "$gitdir/filter.before" "$gitdir/index" && + git -C "$worktree" config --unset filter.required.clean && + git -C "$worktree" config --unset filter.required.required && + git -C "$worktree" config --unset core.trustctime && + git -C "$worktree" config --unset core.checkStat && + cp "$gitdir/index" "$gitdir/unsplit.before" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --split-index && + test_must_fail git -C "$worktree" \ + config --get core.splitIndex && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" rev-parse --shared-index-path \ + >"$gitdir/split.shared" && + test_file_not_empty "$gitdir/split.shared" && + cp "$gitdir/index" "$gitdir/split.before" && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + -C "$worktree" diff \ + --no-ext-diff --no-textconv -- tracked \ + >"$gitdir/split.expect" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/split.trace" \ + git -C "$worktree" diff \ + --no-ext-diff --no-textconv -- tracked \ + >"$gitdir/split.actual" && + test_cmp "$gitdir/split.expect" "$gitdir/split.actual" && + ! test_trace2_data fsmonitor \ + semantic/scoped-reader-stat-fallback 1 \ + <"$gitdir/split.trace" && + test_cmp_bin "$gitdir/split.before" "$gitdir/index" && + cp "$gitdir/unsplit.before" "$gitdir/index" || + return 1 + done + ) +' + +test_done From 04abe794a3c7b2ad85f549d734dbe863d2e67c5f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:40:31 -0500 Subject: [PATCH 340/403] dir: persist unresolved fsmonitor events as invalidation In d067582291 (status: reuse closed proofs for scoped queries, 2026-08-11), a changed file can leave its cached directory valid while queuing a targeted refresh in memory. The UNTR format records the valid bit and cached names, but not that queue. If another command writes the index before consuming the event, a later status can trust the advanced provider token and omit a newly created untracked file. Mark each still-dirty directory invalid before serializing it. The existing invalid-directory path discards its stale names and omits the valid bitmap bit. Its validated descendants remain available, but a new reader must inspect the affected directory instead of treating the lost queue as completed work. No index-format change is needed. Add a regression in which update-index receives the event and rewrites the paired index, then a fresh read-only status receives no new events. Require the same untracked output as an independent provider-disabled reader, and verify that the reader does not write the index. The old code reports a clean tree in both object formats. --- dir.c | 7 +++++ t/t7519-status-fsmonitor.sh | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/dir.c b/dir.c index 0c474dfad0c46b..5c9a4a4f6b96d5 100644 --- a/dir.c +++ b/dir.c @@ -5199,6 +5199,13 @@ static void write_one_dir(struct untracked_cache_dir *untracked, uint8_t intlen; int i = wd->index++; + /* Pending provider paths are process-local and cannot survive index I/O. */ + if (untracked->fsmonitor_dirty) { + untracked->valid = 0; + untracked->valid_recursive = 0; + untracked->fsmonitor_dirty = 0; + } + /* * untracked_nr should be reset whenever valid is clear, but * for safety.. diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 79443f84c1bd23..eb1711f7149e94 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -5297,4 +5297,63 @@ test_expect_success PTHREADS,UNTRACKED_CACHE,SHA1 'load cache-tree and untracked ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'untracked provider events do not disappear across an index rewrite' ' + test_when_finished "rm -rf untracked-provider-index-rewrite" && + test_create_repo untracked-provider-index-rewrite && + ( + cd untracked-provider-index-rewrite && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines tracked >cached/tracked && + test_write_lines outside >outside && + git add cached/tracked outside && + git commit -qm base && + test-tool chmtime -120 cached/tracked outside && + git update-index --refresh && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_fsmonitor_full_proof .git/index paired && + + test_write_lines visible >cached/new-visible && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/new-visible \ + GIT_TRACE2_EVENT="$PWD/.git/writer.trace" \ + git update-index --force-write-index && + test_trace2_data fsmonitor apply_count 1 <.git/writer.trace && + test_region index do_write_index .git/writer.trace && + test_fsmonitor_full_proof .git/index paired && + cp .git/index .git/index.snapshot && + + git --no-optional-locks \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 >.git/expect && + test_grep "^? cached/new-visible$" .git/expect && + test_cmp_bin .git/index.snapshot .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reader.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/actual && + test_cmp .git/expect .git/actual && + test_cmp_bin .git/index.snapshot .git/index && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/reader.trace && + test_trace2_data fsmonitor apply_count 0 \ + <.git/reader.trace && + ! test_trace2_data read_directory opendir 0 \ + <.git/reader.trace && + ! test_region index do_write_index .git/reader.trace + ) +' + test_done From 4922f4b11ecb04d37efd99b0f886db7600e00b2f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:58:02 -0500 Subject: [PATCH 341/403] read-cache: allow authenticated same-path replacements The provider-reset recovery in 49d03b6235 (fsmonitor: retain untracked candidates across expired provider tokens, 2026-08-14) can replace a semantically unchanged index entry without invalidating its untracked directory. An ordinary indexed patch needs the same operation, but must not borrow the reset-revalidation flag to obtain it. Add an explicit add_index_entry() option for this case. Require the canonical strong-stat index, native fsmonitor, a complete semantic proof, equal authenticated tracked and untracked tokens, and a safe same-name regular-file replacement. Existing callers keep their old behavior. The replacement still clears CE_FSMONITOR_VALID. Keep the untracked cache's existing invalid and dirty state intact. In 04abe794a3 (dir: persist unresolved fsmonitor events as invalidation, 2026-08-16), serialization was made to expire unresolved dirty nodes, so preserving this state cannot turn pending work into a valid cache. --- read-cache-ll.h | 1 + read-cache.c | 46 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/read-cache-ll.h b/read-cache-ll.h index f721e1caf4e540..242474ce9861f5 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -423,6 +423,7 @@ static inline int index_pos_to_insert_pos(uintmax_t pos) #define ADD_CACHE_NEW_ONLY 16 /* Do not replace existing ones */ #define ADD_CACHE_KEEP_CACHE_TREE 32 /* Do not invalidate cache-tree */ #define ADD_CACHE_RENORMALIZE 64 /* Pass along HASH_RENORMALIZE */ +#define ADD_CACHE_PRESERVE_CLEAN_HISTORY 128 /* Preserve safe replacements */ int add_index_entry(struct index_state *, struct cache_entry *ce, int option); void rename_index_entry_at(struct index_state *, int pos, const char *new_name); diff --git a/read-cache.c b/read-cache.c index 7cda0ff5a8f9db..7e85e45dc75a94 100644 --- a/read-cache.c +++ b/read-cache.c @@ -33,6 +33,7 @@ #include "path.h" #include "preload-index.h" #include "read-cache.h" +#include "replace-object.h" #include "repository.h" #include "resolve-undo.h" #include "revision.h" @@ -143,12 +144,42 @@ static void set_index_entry(struct index_state *istate, int nr, struct cache_ent add_name_hash(istate, ce); } -static void replace_index_entry(struct index_state *istate, int nr, struct cache_entry *ce) +static void replace_index_entry(struct index_state *istate, int nr, + struct cache_entry *ce, int options) { struct cache_entry *old = istate->cache[nr]; + int preserve_paired_history = + (options & ADD_CACHE_PRESERVE_CLEAN_HISTORY) && + fstat_is_reliable() && istate == istate->repo->index && + !alternate_index_output && !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(DB_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && + !istate->split_index && + istate->sparse_index == INDEX_EXPANDED && + !repo_config_values(istate->repo)->apply_sparse_checkout && + istate->repo->config_values_private_.trust_ctime && + istate->repo->config_values_private_.check_stat && + fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + !repo_has_replace_refs_uncached(istate->repo) && + istate->fsmonitor_token_valid && + istate->fsmonitor_untracked_valid && + istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + istate->fsmonitor_last_update && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token) && + clean_status_has_persistent_fsmonitor_semantic_history(istate) && + clean_status_revalidated_token_matches(istate); + /* Keep invalid nodes invalid; write_one_dir() expires pending events. */ int preserve_untracked = istate->untracked && - istate->untracked->fsmonitor_revalidation && - istate->untracked->root && istate->untracked->root->valid && + istate->untracked->root && + ((istate->untracked->fsmonitor_revalidation && + istate->untracked->root->valid) || + (preserve_paired_history && + istate->untracked->use_fsmonitor)) && S_ISREG(old->ce_mode) && S_ISREG(ce->ce_mode) && clean_status_index_entry_is_semantically_safe(istate, old, ce); @@ -162,6 +193,9 @@ static void replace_index_entry(struct index_state *istate, int nr, struct cache ce->ce_flags &= ~CE_FSMONITOR_VALID; else mark_fsmonitor_invalid(istate, ce); + if (preserve_paired_history && preserve_untracked) + trace2_data_intmax("fsmonitor", istate->repo, + "apply/untracked-replacement-preserved", 1); istate->cache_changed |= CE_ENTRY_CHANGED; } @@ -236,7 +270,7 @@ void refresh_index_entry_stat(struct index_state *istate, int nr, struct stat *st) { replace_index_entry(istate, nr, make_refreshed_cache_entry( - istate, istate->cache[nr], st, 1)); + istate, istate->cache[nr], st, 1), 0); } static unsigned int st_mode_from_ce(const struct cache_entry *ce) @@ -1351,7 +1385,7 @@ static int add_index_entry_with_check(struct index_state *istate, struct cache_e /* existing match? Just replace it. */ if (pos >= 0) { if (!new_only) - replace_index_entry(istate, pos, ce); + replace_index_entry(istate, pos, ce, option); return 0; } pos = -pos-1; @@ -1708,7 +1742,7 @@ int refresh_index(struct index_state *istate, unsigned int flags, clean_status_fsmonitor_semantic_baseline_pending( istate)); - replace_index_entry(istate, i, new_entry); + replace_index_entry(istate, i, new_entry, 0); if (fsmonitor_valid) mark_fsmonitor_valid(istate, istate->cache[i]); From 428d8292400731c7f9d6367fee88c84378afe505 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:58:13 -0500 Subject: [PATCH 342/403] stash: retain paired history for scoped regular-file changes A path-specific stash removes and re-adds an existing index entry while applying its reverse patch. That unnecessarily invalidates the rooted untracked cache even when the path, type, attributes, and provider proof are unchanged. The next indexed stash apply then has to reconstruct the missing history. Enable authenticated history before the first canonical indexed-apply read. Preflight the complete patch batch and use the explicit safe replacement option only for ordinary same-path regular-file edits. An unsafe member retains the existing two-phase removal behavior for the whole batch. Attribute and ignore changes, active filters, renames, conflicts, alternate indexes, and weak stat configurations remain conservative. Give scoped stash inspection its own history guard, without changing the deliberate invalidation for dirty whole-worktree stashes. Reuse the existing digest suspension while writing the synthetic stash tree, and permit the final physical indexed restore to use unpack-trees' guarded semantic transfer. The synthetic index never receives that permission. Compare the complete stash cycle with an independent disabled-provider repository, including both stash trees, staged entries, and worktree bytes. Cover an unrelated staged sibling, mixed attribute/ignore patch orders, required-filter failure, alternate indexes, and pending untracked changes. Retained proofs must stay paired without losing untracked output or requiring a complete attribute-manifest scan. --- apply.c | 86 +++- builtin/apply.c | 25 ++ builtin/stash.c | 52 ++- t/t7533-status-scoped-stash.sh | 749 +++++++++++++++++++++++++++++++++ 4 files changed, 901 insertions(+), 11 deletions(-) create mode 100755 t/t7533-status-scoped-stash.sh diff --git a/apply.c b/apply.c index 1f5dda3b6f3fcc..5aad604fc9c6d1 100644 --- a/apply.c +++ b/apply.c @@ -13,12 +13,14 @@ #include "git-compat-util.h" #include "abspath.h" #include "base85.h" +#include "clean-status.h" #include "config.h" #include "odb.h" #include "delta.h" #include "diff.h" #include "dir.h" #include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "hex.h" #include "xdiff-interface.h" @@ -31,6 +33,7 @@ #include "path.h" #include "quote.h" #include "read-cache.h" +#include "replace-object.h" #include "repository.h" #include "rerere.h" #include "apply.h" @@ -4440,9 +4443,66 @@ static void patch_stats(struct apply_state *state, struct patch *patch) } } +static int patch_preserves_clean_history(struct apply_state *state, + struct patch *patch) +{ + struct index_state *istate = state->repo->index; + const struct cache_entry *old; + int pos; + + if (!state->update_index || state->ita_only || state->threeway || + state->apply_with_reject || state->fake_ancestor || + state->index_file || !fstat_is_reliable() || + getenv(INDEX_ENVIRONMENT) || + getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || + getenv(DB_ENVIRONMENT) || getenv(ALTERNATE_DB_ENVIRONMENT) || + istate != istate->repo->index || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + repo_config_values(istate->repo)->apply_sparse_checkout || + !istate->repo->config_values_private_.trust_ctime || + !istate->repo->config_values_private_.check_stat || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + repo_has_replace_refs_uncached(istate->repo) || + patch->is_new > 0 || patch->is_delete > 0 || patch->is_copy || + patch->is_rename || patch->conflicted_threeway || + !patch->old_name || !patch->new_name || + strcmp(patch->old_name, patch->new_name) || + !S_ISREG(patch->old_mode) || !S_ISREG(patch->new_mode) || + create_ce_mode(patch->old_mode) != + create_ce_mode(patch->new_mode) || + !clean_status_external_history_enabled(istate) || + !clean_status_has_persistent_fsmonitor_semantic_history(istate) || + !clean_status_revalidated_token_matches(istate) || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_untracked_valid || + !istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + !istate->fsmonitor_last_update || + !istate->fsmonitor_untracked_token || + strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token) || + !istate->untracked || !istate->untracked->use_fsmonitor || + !istate->untracked->root) + return 0; + + pos = index_name_pos(istate, patch->old_name, + strlen(patch->old_name)); + if (pos < 0) + return 0; + old = istate->cache[pos]; + return S_ISREG(old->ce_mode) && + old->ce_mode == create_ce_mode(patch->new_mode) && + clean_status_index_entry_is_semantically_safe( + istate, old, old); +} + static int remove_file(struct apply_state *state, struct patch *patch, int rmdir_empty) { - if (state->update_index && !state->ita_only) { + if (state->update_index && !state->ita_only && + !patch_preserves_clean_history(state, patch)) { + if (clean_status_external_history_enabled(state->repo->index)) + clean_status_invalidate_current_proof(state->repo->index); if (remove_file_from_index(state->repo->index, patch->old_name) < 0) return error(_("unable to remove %s from index"), patch->old_name); } @@ -4455,6 +4515,7 @@ static int remove_file(struct apply_state *state, struct patch *patch, int rmdir } static int add_index_file(struct apply_state *state, + struct patch *patch, const char *path, unsigned mode, void *buf, @@ -4463,6 +4524,7 @@ static int add_index_file(struct apply_state *state, struct stat st; struct cache_entry *ce; int namelen = strlen(path); + int options = ADD_CACHE_OK_TO_ADD; ce = make_empty_cache_entry(state->repo->index, namelen); memcpy(ce->name, path, namelen); @@ -4497,7 +4559,13 @@ static int add_index_file(struct apply_state *state, "for newly created file %s"), path); } } - if (add_index_entry(state->repo->index, ce, ADD_CACHE_OK_TO_ADD) < 0) { + if (patch_preserves_clean_history(state, patch)) { + options |= ADD_CACHE_OK_TO_REPLACE | + ADD_CACHE_PRESERVE_CLEAN_HISTORY; + } else if (clean_status_external_history_enabled(state->repo->index)) { + clean_status_invalidate_current_proof(state->repo->index); + } + if (add_index_entry(state->repo->index, ce, options) < 0) { discard_cache_entry(ce); return error(_("unable to add cache entry for %s"), path); } @@ -4697,7 +4765,7 @@ static int create_file(struct apply_state *state, struct patch *patch) if (patch->conflicted_threeway) return add_conflicted_stages_file(state, patch); else if (state->check_index || (state->ita_only && patch->is_new > 0)) - return add_index_file(state, path, mode, buf, size); + return add_index_file(state, patch, path, mode, buf, size); return 0; } @@ -4828,6 +4896,18 @@ static int write_out_results(struct apply_state *state, struct patch *list) struct patch *l; struct string_list cpath = STRING_LIST_INIT_DUP; + if (state->update_index && + clean_status_external_history_enabled(state->repo->index)) { + for (l = list; l; l = l->next) { + if (l->rejected || + !patch_preserves_clean_history(state, l)) { + clean_status_invalidate_current_proof( + state->repo->index); + break; + } + } + } + for (phase = 0; phase < 2; phase++) { l = list; while (l) { diff --git a/builtin/apply.c b/builtin/apply.c index d642a402516f30..4cc2ac83369b35 100644 --- a/builtin/apply.c +++ b/builtin/apply.c @@ -1,7 +1,12 @@ #define USE_THE_REPOSITORY_VARIABLE #include "builtin.h" +#include "clean-status-config.h" +#include "clean-status.h" +#include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "hash.h" +#include "replace-object.h" #include "apply.h" static const char * const apply_usage[] = { @@ -17,6 +22,7 @@ int cmd_apply(int argc, int force_apply = 0; int options = 0; int ret; + struct clean_status_config_digest clean_digest; struct apply_state state; if (init_apply_state(&state, the_repository, prefix)) @@ -43,6 +49,25 @@ int cmd_apply(int argc, if (check_apply_state(&state, force_apply)) exit(128); + if (state.apply && state.check_index && !state.threeway && + !state.apply_with_reject && !state.ita_only && + !state.fake_ancestor && !state.index_file && + !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(DB_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && fstat_is_reliable() && + !repo_config_values(the_repository)->apply_sparse_checkout && + the_repository->config_values_private_.trust_ctime && + the_repository->config_values_private_.check_stat && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + !repo_has_replace_refs_uncached(the_repository) && + !clean_status_config_read_repository(the_repository, + &clean_digest)) { + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, &clean_digest); + } + ret = apply_all_patches(&state, argc, argv, options); clear_apply_state(&state); diff --git a/builtin/stash.c b/builtin/stash.c index f5bb8b37ac18c3..3eb63d18faa0fd 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -6,6 +6,7 @@ #include "clean-status-config.h" #include "config.h" #include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "hash.h" #include "hex.h" @@ -22,6 +23,7 @@ #include "entry.h" #include "preload-index.h" #include "read-cache.h" +#include "replace-object.h" #include "repository.h" #include "rerere.h" #include "revision.h" @@ -334,7 +336,8 @@ static int clear_stash(int argc, const char **argv, const char *prefix, return do_clear_stash(); } -static int reset_tree(struct object_id *i_tree, int update, int reset) +static int reset_tree(struct object_id *i_tree, int update, int reset, + int preserve_semantic_history) { int nr_trees = 1; struct unpack_trees_options opts; @@ -359,6 +362,22 @@ static int reset_tree(struct object_id *i_tree, int update, int reset) opts.head_idx = 1; opts.src_index = the_repository->index; opts.dst_index = the_repository->index; + opts.preserve_semantic_history = preserve_semantic_history && + !update && !reset && fstat_is_reliable() && + !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(DB_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && + !repo_config_values(the_repository)->apply_sparse_checkout && + the_repository->config_values_private_.trust_ctime && + the_repository->config_values_private_.check_stat && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + !repo_has_replace_refs_uncached(the_repository) && + the_repository->index->fsmonitor_untracked_valid && + clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index) && + clean_status_revalidated_token_matches(the_repository->index); opts.merge = 1; opts.reset = reset ? UNPACK_RESET_PROTECT_UNTRACKED : 0; opts.update = update; @@ -754,7 +773,7 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, } if (has_index) { - if (reset_tree(&index_tree, 0, 0)) + if (reset_tree(&index_tree, 0, 0, 1)) ret = -1; } else { unstage_changes_unless_new(&c_tree); @@ -1473,7 +1492,7 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps copy_pathspec(&rev.prune_data, ps); set_alternate_index_output(stash_index_path.buf); - if (reset_tree(&info->i_tree, 0, 0)) { + if (reset_tree(&info->i_tree, 0, 0, 0)) { ret = -1; goto done; } @@ -1629,7 +1648,9 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b } } else { if (stash_working_tree(info, ps, - !ps->nr && !include_untracked)) { + !include_untracked && + clean_status_external_history_enabled( + the_repository->index))) { if (!quiet) fprintf_ln(stderr, _("Cannot save the current " "worktree state")); @@ -1703,6 +1724,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q { int ret = 0; int preserve_clean_history = !ps->nr && !include_untracked; + int preserve_scoped_history = 0; struct lock_file index_lock = LOCK_INIT; struct stash_info info = STASH_INFO_INIT; struct strbuf patch = STRBUF_INIT; @@ -1731,12 +1753,26 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q goto done; } + preserve_scoped_history = ps->nr && !include_untracked && + !patch_mode && !only_staged && keep_index != 1 && + !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_WORK_TREE_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(DB_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && fstat_is_reliable() && + !repo_config_values(the_repository)->apply_sparse_checkout && + the_repository->config_values_private_.trust_ctime && + the_repository->config_values_private_.check_stat && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + !repo_has_replace_refs_uncached(the_repository); + /* - * Keep whole-worktree history bound while inspecting the worktree. - * If changes are found, invalidate it before stash machinery - * mutates the index or worktree. + * Keep authenticated history bound while inspecting the worktree. + * Whole-worktree changes still invalidate their proof below. A scoped + * regular-file replacement keeps it only when each writer proves that + * its provider, semantic inputs, and untracked cache remain paired. */ - if (preserve_clean_history) { + if (preserve_clean_history || preserve_scoped_history) { clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &stash_clean_digest); diff --git a/t/t7533-status-scoped-stash.sh b/t/t7533-status-scoped-stash.sh new file mode 100755 index 00000000000000..81bdf080cabdf4 --- /dev/null +++ b/t/t7533-status-scoped-stash.sh @@ -0,0 +1,749 @@ +#!/bin/sh + +test_description='authenticated fsmonitor history across scoped stash writers' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh + +test_lazy_prereq UNTRACKED_CACHE ' + { git update-index --test-untracked-cache; ret=$?; } && + test $ret -ne 1 +' + +scoped_stash_full_proof () { + perl - "$1" <<-\EOF + open my $input, "<", $ARGV[0] or die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + die "invalid $name token\n" if $end < 0; + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "mismatched provider tokens\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"} && + $tokens{"FSMN"} eq $tokens{"FSCF"}; + EOF +} + +scoped_stash_prime () { + worktree=$1 && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + test-tool chmtime -120 "$worktree/tracked" "$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + scoped_stash_full_proof "$gitdir/index" +} + +scoped_stash_setup () { + test_create_repo "$1" && + ( + cd "$1" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + git config core.untrackedCache true && + git config core.fsmonitor true && + scoped_stash_prime "$PWD" + ) +} + +scoped_stash_control_git () { + git -C "$scoped_control" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default "$@" +} + +scoped_stash_control () { + scoped_source=$1 && + scoped_control=$2 && + scoped_output=$3 && + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + clone -q --no-hardlinks "$scoped_source" \ + "$scoped_control" && + test_write_lines scoped >"$scoped_control/tracked" && + scoped_stash_control_git add -- tracked && + scoped_stash_control_git stash push -q \ + -m independent-control -- tracked && + scoped_stash_control_git rev-parse \ + "stash@{0}^{tree}" "stash@{0}^2^{tree}" \ + >"$scoped_output/control.trees" && + scoped_stash_control_git --no-optional-locks \ + status --porcelain=v2 \ + >"$scoped_output/control.pushed" && + scoped_stash_control_git stash apply --index -q "stash@{0}" && + scoped_stash_control_git --no-optional-locks \ + status --porcelain=v2 \ + >"$scoped_output/control.applied" && + scoped_stash_control_git --no-optional-locks \ + ls-files --stage \ + >"$scoped_output/control.staged" +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'scoped stash push and indexed apply preserve paired worktree proofs' ' + test_when_finished "rm -rf scoped-stash scoped-stash-linked \ + scoped-stash-control-1 scoped-stash-control-2" && + test_create_repo scoped-stash && + ( + cd scoped-stash && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + git worktree add --detach ../scoped-stash-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + control_nr=0 && + for worktree in "$PWD" "$PWD/../scoped-stash-linked" + do + control_nr=$((control_nr + 1)) && + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + scoped_stash_prime "$worktree" && + scoped_stash_control "$worktree" \ + "$PWD/../scoped-stash-control-$control_nr" \ + "$gitdir" && + test_write_lines scoped >"$worktree/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git -C "$worktree" add -- tracked && + scoped_stash_full_proof "$gitdir/index" && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/push.trace" \ + git -C "$worktree" stash push -q \ + -m scoped-proof -- tracked && + scoped_stash_full_proof "$gitdir/index" && + test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <"$gitdir/push.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/push.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/push.trace" && + + stash=$(git -C "$worktree" rev-parse stash@{0}) && + git -C "$worktree" rev-parse \ + "$stash^{tree}" "$stash^2^{tree}" \ + >"$gitdir/push.trees" && + test_cmp "$gitdir/control.trees" \ + "$gitdir/push.trees" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 \ + >"$gitdir/push.actual" && + test_cmp "$gitdir/control.pushed" \ + "$gitdir/push.actual" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/apply.trace" \ + git -C "$worktree" stash apply --index -q \ + "$stash" && + scoped_stash_full_proof "$gitdir/index" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/apply.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/apply.trace" && + + cp "$gitdir/index" "$gitdir/index.before" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/actual" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >"$gitdir/expect" && + test_cmp "$gitdir/expect" "$gitdir/actual" && + test_cmp "$gitdir/control.applied" \ + "$gitdir/actual" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + ls-files --stage \ + >"$gitdir/staged.actual" && + test_cmp "$gitdir/control.staged" \ + "$gitdir/staged.actual" && + test_cmp "$scoped_control/tracked" \ + "$worktree/tracked" && + test_cmp "$scoped_control/sibling" \ + "$worktree/sibling" && + test_grep "^1 M\\. .* tracked$" "$gitdir/actual" && + test_cmp_bin "$gitdir/index.before" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/status.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/status.trace" || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'scoped stash push preserves an unrelated staged sibling' ' + test_when_finished "rm -rf scoped-stash-staged \ + scoped-stash-staged-control" && + scoped_stash_setup scoped-stash-staged && + ( + cd scoped-stash-staged && + gitdir="$PWD/.git" && + scoped_control="$PWD/../scoped-stash-staged-control" && + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + clone -q --no-hardlinks "$PWD" \ + "$scoped_control" && + test_write_lines scoped >"$scoped_control/tracked" && + test_write_lines independently-staged \ + >"$scoped_control/sibling" && + scoped_stash_control_git add -- tracked sibling && + scoped_stash_control_git stash push -q \ + -m independent-staged-control -- tracked && + scoped_stash_control_git rev-parse \ + "stash@{0}^{tree}" "stash@{0}^2^{tree}" \ + >.git/control.trees && + scoped_stash_control_git --no-optional-locks \ + status --porcelain=v2 >.git/control.status && + scoped_stash_control_git --no-optional-locks \ + ls-files --stage >.git/control.staged && + test_grep "^1 M\\. .* sibling$" .git/control.status && + + test_write_lines scoped >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add -- tracked && + test_write_lines independently-staged >sibling && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=sibling \ + git add -- sibling && + scoped_stash_full_proof .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/staged-push.trace" \ + git stash push -q -m staged-proof -- tracked && + scoped_stash_full_proof .git/index && + test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/staged-push.trace && + ! test_trace2_data fsmonitor \ + untracked/proof-missing 1 <.git/staged-push.trace && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <.git/staged-push.trace && + git rev-parse "stash@{0}^{tree}" \ + "stash@{0}^2^{tree}" >.git/staged.trees && + test_cmp .git/control.trees .git/staged.trees && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/staged.status && + test_cmp .git/control.status .git/staged.status && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + ls-files --stage >.git/staged.entries && + test_cmp .git/control.staged .git/staged.entries && + test_cmp "$scoped_control/tracked" tracked && + test_cmp "$scoped_control/sibling" sibling + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'scoped stash invalidates a changed attribute-source proof' ' + test_when_finished "rm -rf scoped-stash-attributes" && + test_create_repo scoped-stash-attributes && + ( + cd scoped-stash-attributes && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + test_write_lines sibling >sibling && + test_write_lines "*.asset text" >.gitattributes && + git add tracked sibling .gitattributes && + git commit -qm base && + git config core.untrackedCache true && + git config core.fsmonitor true && + scoped_stash_prime "$PWD" && + test_write_lines "*.asset -text" >.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git add .gitattributes && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git status --porcelain=v2 >.git/staged && + scoped_stash_full_proof .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git stash push -q -m attributes -- .gitattributes && + ! test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/attributes.trace && + ! scoped_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git status --porcelain=v2 >.git/actual && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + test_cmp .git/expect .git/actual + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'mixed apply batches reject attribute and ignore proofs in either order' ' + test_when_finished "rm -rf scoped-stash-mixed-*" && + for source in .gitattributes .gitignore + do + for order in regular-first source-first + do + repo="scoped-stash-mixed-${source#.}-$order" && + test_create_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + test_write_lines sibling >sibling && + test_write_lines "*.asset text" \ + >.gitattributes && + test_write_lines ignored-before >.gitignore && + git add tracked sibling .gitattributes \ + .gitignore && + git commit -qm base && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_write_lines mixed >tracked && + case "$source" in + .gitattributes) + test_write_lines "*.asset -text" \ + >"$source" + ;; + .gitignore) + test_write_lines ignored-after \ + >"$source" + ;; + esac && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + diff -- tracked \ + >.git/regular.patch && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + diff -- "$source" \ + >.git/source.patch && + if test "$order" = regular-first + then + cat .git/regular.patch \ + .git/source.patch \ + >.git/mixed.patch + else + cat .git/source.patch \ + .git/regular.patch \ + >.git/mixed.patch + fi && + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + checkout -- tracked "$source" && + scoped_stash_prime "$PWD" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/mixed.trace" \ + git apply --index .git/mixed.patch && + ! test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/mixed.trace && + ! scoped_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 \ + >.git/actual && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 \ + >.git/expect && + test_cmp .git/expect .git/actual && + test_grep " tracked$" .git/actual && + test_grep " $source$" .git/actual + ) || return 1 + done + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'scoped stash never preserves an active required clean filter' ' + test_when_finished "rm -rf scoped-stash-filter" && + test_create_repo scoped-stash-filter && + ( + cd scoped-stash-filter && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + test_write_lines sibling >sibling && + test_write_lines "tracked text" >.gitattributes && + git add tracked sibling .gitattributes && + git commit -qm base && + git config core.untrackedCache true && + git config core.fsmonitor true && + scoped_stash_prime "$PWD" && + git config filter.scoped.clean cat && + git config filter.scoped.smudge cat && + git config filter.scoped.required true && + test_write_lines "tracked filter=scoped" >.gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git add -- .gitattributes && + test_write_lines filtered >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add tracked && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git status --porcelain=v2 >.git/staged && + ! scoped_stash_full_proof .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/filter.trace" \ + git stash push -q -m filtered -- tracked && + ! test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/filter.trace && + ! scoped_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git status --porcelain=v2 >.git/actual && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + test_cmp .git/expect .git/actual && + + git config filter.scoped.clean false && + test_write_lines rejected >tracked && + cp .git/index .git/required.before && + git rev-parse refs/stash >.git/stash.before && + test_must_fail env \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/required.trace" \ + git stash push -q -m rejected -- tracked \ + >.git/required.out 2>.git/required.err && + test_grep "clean filter .scoped. failed" \ + .git/required.err && + test_cmp_bin .git/required.before .git/index && + git rev-parse refs/stash >.git/stash.after && + test_cmp .git/stash.before .git/stash.after && + ! test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/required.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'whole-worktree stash retains its deliberate proof invalidation' ' + test_when_finished "rm -rf scoped-stash-whole" && + scoped_stash_setup scoped-stash-whole && + ( + cd scoped-stash-whole && + test_write_lines dirty >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/whole.trace" \ + git stash push -q -m whole && + ! test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/whole.trace && + ! scoped_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git status --porcelain=v2 >.git/actual && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + test_cmp .git/expect .git/actual + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'alternate indexed apply cannot transfer a primary worktree proof' ' + test_when_finished "rm -rf scoped-stash-alternate" && + scoped_stash_setup scoped-stash-alternate && + ( + cd scoped-stash-alternate && + test_write_lines alternate >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git diff >.git/alternate.patch && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout -- tracked && + scoped_stash_full_proof .git/index && + cp .git/index .git/index.before && + cp .git/index .git/alternate.index && + GIT_INDEX_FILE="$PWD/.git/alternate.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/alternate.trace" \ + git apply --cached .git/alternate.patch && + ! test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/alternate.trace && + test_cmp_bin .git/index.before .git/index && + scoped_stash_full_proof .git/index + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'nested scoped stash never resurrects dirty untracked siblings' ' + test_when_finished "rm -rf scoped-stash-nested \ + scoped-stash-nested-control" && + test_create_repo scoped-stash-nested && + ( + cd scoped-stash-nested && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + mkdir nested && + test_write_lines base >nested/tracked && + git add -- nested/tracked && + git commit -qm nested-base && + scoped_control="$PWD/../scoped-stash-nested-control" && + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + clone -q --no-hardlinks "$PWD" "$scoped_control" && + test_write_lines existing >nested/existing-untracked && + test_write_lines existing \ + >"$scoped_control/nested/existing-untracked" && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime -120 nested/tracked && + scoped_stash_prime "$PWD" && + test_grep "^? nested/existing-untracked$" .git/prime && + GIT_CONFIG_PARAMETERS="${SQ}core.fsmonitor=false${SQ}" \ + test-tool dump-untracked-cache >.git/initial-cache && + test_grep "^/nested/ .* valid" .git/initial-cache && + test_grep "^existing-untracked$" .git/initial-cache && + + test_write_lines staged >nested/tracked && + test_write_lines staged >"$scoped_control/nested/tracked" && + scoped_stash_control_git add -- nested/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=nested/tracked \ + git add -- nested/tracked && + scoped_stash_full_proof .git/index && + + test_write_lines new >nested/new-untracked && + test_write_lines new \ + >"$scoped_control/nested/new-untracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=nested/new-untracked \ + GIT_TRACE2_EVENT="$PWD/.git/nested-writer.trace" \ + git update-index --force-write-index && + test_trace2_data fsmonitor apply_count 1 \ + <.git/nested-writer.trace && + test_region index do_write_index .git/nested-writer.trace && + scoped_stash_full_proof .git/index && + GIT_CONFIG_PARAMETERS="${SQ}core.fsmonitor=false${SQ}" \ + test-tool dump-untracked-cache >.git/dirty-cache && + test_grep "^/nested/ .* recurse$" .git/dirty-cache && + ! test_grep "^/nested/ .* valid" .git/dirty-cache && + ! test_grep "^existing-untracked$" .git/dirty-cache && + cp .git/index .git/before-reader && + scoped_stash_control_git --no-optional-locks \ + status --porcelain=v2 >.git/control-before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/nested-reader.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/candidate-before && + test_cmp .git/control-before .git/candidate-before && + test_grep "^? nested/existing-untracked$" \ + .git/candidate-before && + test_grep "^? nested/new-untracked$" \ + .git/candidate-before && + test_cmp_bin .git/before-reader .git/index && + ! test_region index do_write_index .git/nested-reader.trace && + + test_write_lines unstaged >nested/tracked && + test_write_lines unstaged \ + >"$scoped_control/nested/tracked" && + scoped_stash_control_git stash push -q \ + -m nested-control -- nested/tracked && + scoped_stash_control_git rev-parse \ + "stash@{0}^{tree}" "stash@{0}^2^{tree}" \ + >.git/control-trees && + scoped_stash_control_git --no-optional-locks \ + status --porcelain=v2 >.git/control-pushed && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=nested/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/nested-push.trace" \ + git stash push -q -m nested-proof -- nested/tracked && + scoped_stash_full_proof .git/index && + test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/nested-push.trace && + ! test_trace2_data fsmonitor \ + untracked/proof-missing 1 <.git/nested-push.trace && + git rev-parse "stash@{0}^{tree}" "stash@{0}^2^{tree}" \ + >.git/candidate-trees && + test_cmp .git/control-trees .git/candidate-trees && + cp .git/index .git/after-push && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git --no-optional-locks status --porcelain=v2 \ + >.git/candidate-pushed && + test_cmp .git/control-pushed .git/candidate-pushed && + test_grep "^? nested/existing-untracked$" \ + .git/candidate-pushed && + test_grep "^? nested/new-untracked$" \ + .git/candidate-pushed && + test_cmp_bin .git/after-push .git/index && + + scoped_stash_control_git stash apply --index -q "stash@{0}" && + scoped_stash_control_git --no-optional-locks \ + status --porcelain=v2 >.git/control-applied && + scoped_stash_control_git --no-optional-locks \ + ls-files --stage >.git/control-staged && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=nested/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/nested-apply.trace" \ + git stash apply --index -q "stash@{0}" && + scoped_stash_full_proof .git/index && + ! test_trace2_data fsmonitor \ + untracked/proof-missing 1 <.git/nested-apply.trace && + cp .git/index .git/after-apply && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git --no-optional-locks status --porcelain=v2 \ + >.git/candidate-applied && + test_cmp .git/control-applied .git/candidate-applied && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git --no-optional-locks ls-files --stage \ + >.git/candidate-staged && + test_cmp .git/control-staged .git/candidate-staged && + test_grep "^? nested/existing-untracked$" \ + .git/candidate-applied && + test_grep "^? nested/new-untracked$" \ + .git/candidate-applied && + test_cmp "$scoped_control/nested/tracked" nested/tracked && + test_cmp_bin .git/after-apply .git/index + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'cached apply safely expires a pending nested provider event' ' + test_when_finished "rm -rf scoped-stash-soft-dirty \ + scoped-stash-soft-dirty-control" && + test_create_repo scoped-stash-soft-dirty && + ( + cd scoped-stash-soft-dirty && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + mkdir nested && + test_write_lines base >nested/tracked && + git add -- nested/tracked && + git commit -qm nested-base && + scoped_control="$PWD/../scoped-stash-soft-dirty-control" && + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + clone -q --no-hardlinks "$PWD" "$scoped_control" && + test_write_lines existing >nested/existing-untracked && + test_write_lines existing \ + >"$scoped_control/nested/existing-untracked" && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime -120 nested/tracked && + scoped_stash_prime "$PWD" && + test_grep "^? nested/existing-untracked$" .git/prime && + GIT_CONFIG_PARAMETERS="${SQ}core.fsmonitor=false${SQ}" \ + test-tool dump-untracked-cache >.git/initial-cache && + test_grep "^/nested/ .* valid" .git/initial-cache && + test_grep "^existing-untracked$" .git/initial-cache && + + test_write_lines indexed >"$scoped_control/nested/tracked" && + scoped_stash_control_git diff -- nested/tracked \ + >.git/nested.patch && + scoped_stash_control_git checkout -- nested/tracked && + test_cmp "$scoped_control/nested/tracked" nested/tracked && + test_write_lines visible >nested/new-visible && + test_write_lines visible \ + >"$scoped_control/nested/new-visible" && + scoped_stash_control_git apply --cached \ + "$PWD/.git/nested.patch" && + scoped_stash_control_git --no-optional-locks \ + status --porcelain=v2 >.git/control.status && + scoped_stash_control_git --no-optional-locks \ + ls-files --stage >.git/control.staged && + test_grep "^1 MM .* nested/tracked$" .git/control.status && + test_grep "^? nested/existing-untracked$" \ + .git/control.status && + test_grep "^? nested/new-visible$" .git/control.status && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=nested/new-visible \ + GIT_TRACE2_EVENT="$PWD/.git/apply.trace" \ + git apply --cached .git/nested.patch && + test_trace2_data fsmonitor apply_count 1 <.git/apply.trace && + test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/apply.trace && + ! test_trace2_data fsmonitor \ + untracked/proof-missing 1 <.git/apply.trace && + test_region index do_write_index .git/apply.trace && + scoped_stash_full_proof .git/index && + GIT_CONFIG_PARAMETERS="${SQ}core.fsmonitor=false${SQ}" \ + test-tool dump-untracked-cache >.git/serialized-cache && + test_grep "^/nested/ .* recurse$" .git/serialized-cache && + ! test_grep "^/nested/ .* valid" .git/serialized-cache && + ! test_grep "^existing-untracked$" \ + .git/serialized-cache && + cp .git/index .git/after-apply && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reader.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/candidate.status && + test_cmp .git/control.status .git/candidate.status && + test_grep "^? nested/existing-untracked$" \ + .git/candidate.status && + test_grep "^? nested/new-visible$" .git/candidate.status && + test_cmp_bin .git/after-apply .git/index && + ! test_region index do_write_index .git/reader.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git --no-optional-locks ls-files --stage \ + >.git/candidate.staged && + test_cmp .git/control.staged .git/candidate.staged && + test_cmp "$scoped_control/nested/tracked" nested/tracked && + test_cmp_bin .git/after-apply .git/index + ) +' + +test_done From 116bc025705e916d351f9cba75b3e20d3460fe00 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:03:19 -0500 Subject: [PATCH 343/403] clean-status: normalize redundant disabled submodule recursion In 062e636055 (status: preserve proofs across guarded Codex invocations, 2026-08-14), harmless command-scoped overrides were excluded from the clean-status configuration fingerprint. A redundant submodule.recurse=false override still creates a separate proof domain. After a guarded checkout rewrites a linked worktree's index, ordinary diffs can repeatedly hash the stale checkpoint from the original domain, especially when index.skipHash leaves no usable physical checksum. Track the last known submodule.recurse value while reading config. Ignore a command-scoped false value only when recursion already defaults to false or the preceding effective value is known to be false. Preserve true overrides, effective changes, invalid values, and unknown scopes in the fingerprint. This does not broaden the configuration-epoch shortcut or change the semantic and tracked-policy hashes. Cover precedence and boolean spellings with both hash algorithms. Add a linked-worktree regression with a zero index checksum and authenticated sidecars: the guarded checkout must leave a directly reusable proof, and two ordinary diffs must match an independent oracle without hashing the old checkpoint. A persistent true setting remains a counterexample. --- clean-status-config.c | 15 ++++ clean-status-config.h | 2 + t/t7519-status-fsmonitor.sh | 119 +++++++++++++++++++++++++++ t/unit-tests/u-clean-status-config.c | 88 ++++++++++++++++++++ 4 files changed, 224 insertions(+) diff --git a/clean-status-config.c b/clean-status-config.c index 3740bb929ef000..f927baa8ca4023 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -139,6 +139,21 @@ static int config_is_command_status_guard( digest->fsmonitor_value_enabled = boolean > 0; return redundant; } + if (!strcmp(key, "submodule.recurse")) { + int boolean = git_parse_maybe_bool(value); + int known_scope = ctx && ctx->kvi && + ctx->kvi->scope > CONFIG_SCOPE_UNKNOWN && + ctx->kvi->scope <= CONFIG_SCOPE_COMMAND; + int redundant = command && !boolean && + (!digest->submodule_recurse_seen || + digest->submodule_recurse_known_false); + + /* Recursion defaults to off; retain every effective change. */ + digest->submodule_recurse_seen = 1; + digest->submodule_recurse_known_false = + known_scope && !boolean; + return redundant; + } return command && value && ((!strcmp(key, "safe.barerepository") && diff --git a/clean-status-config.h b/clean-status-config.h index 779a45e720aed3..0c381ecd14a27b 100644 --- a/clean-status-config.h +++ b/clean-status-config.h @@ -25,6 +25,8 @@ struct clean_status_config_digest { unsigned fsmonitor_value_seen : 1; unsigned fsmonitor_value_boolean : 1; unsigned fsmonitor_value_enabled : 1; + unsigned submodule_recurse_seen : 1; + unsigned submodule_recurse_known_false : 1; }; void clean_status_config_init(struct clean_status_config_digest *digest, diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index eb1711f7149e94..3cc9ff531d0255 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -4304,6 +4304,125 @@ test_expect_success MACOS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success MACOS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'redundant disabled recursion preserves linked pre-commit diff proofs' ' + test_when_finished "rm -rf precommit-linked-proof precommit-linked-worktree" && + test_create_repo precommit-linked-proof && + ( + cd precommit-linked-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + git worktree add --detach ../precommit-linked-worktree HEAD && + git config index.skipHash true && + git config core.untrackedCache true && + git config core.fsmonitor true && + worktree="$PWD/../precommit-linked-worktree" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + test-tool chmtime -120 "$worktree/tracked" "$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ + git -C "$worktree" status >"$gitdir/checkpoint.out" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/checkpoint.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_trailing_hash "$gitdir/index" >"$gitdir/index.hash" && + test_oid zero >"$gitdir/zero" && + test_cmp "$gitdir/zero" "$gitdir/index.hash" && + find "$gitdir" -maxdepth 1 -type f -name "index.csh1.*" \ + >"$gitdir/checkpoints" && + find "$gitdir" -maxdepth 1 -type f -name "index.cswi.*" \ + >"$gitdir/witnesses" && + test_line_count = 1 "$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/witnesses" && + checkpoint=$(cat "$gitdir/checkpoints") && + witness=$(cat "$gitdir/witnesses") && + cp "$checkpoint" "$gitdir/checkpoint.before" && + cp "$witness" "$gitdir/witness.before" && + + test_write_lines dirty >"$worktree/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/checkout.trace" \ + git -C "$worktree" -c submodule.recurse=0 \ + checkout -- . && + test_region index do_write_index "$gitdir/checkout.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_trailing_hash "$gitdir/index" >"$gitdir/index.hash" && + test_cmp "$gitdir/zero" "$gitdir/index.hash" && + test_cmp_bin "$gitdir/checkpoint.before" "$checkpoint" && + test_cmp_bin "$gitdir/witness.before" "$witness" && + cp "$gitdir/index" "$gitdir/index.before-diff" && + git -C "$worktree" --no-optional-locks \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.trustctime=true -c core.checkStat=default \ + diff --no-ext-diff --no-textconv --ignore-submodules \ + >"$gitdir/expect" && + test_cmp_bin "$gitdir/index.before-diff" "$gitdir/index" && + for attempt in first second + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/diff-$attempt.trace" \ + git -C "$worktree" diff \ + --no-ext-diff --no-textconv --ignore-submodules \ + >"$gitdir/diff-$attempt.actual" && + test_cmp "$gitdir/expect" "$gitdir/diff-$attempt.actual" && + test_cmp_bin "$gitdir/index.before-diff" "$gitdir/index" && + test_cmp_bin "$gitdir/checkpoint.before" "$checkpoint" && + test_cmp_bin "$gitdir/witness.before" "$witness" && + test_grep ! "\"label\":\"history_logical_digest\"" \ + "$gitdir/diff-$attempt.trace" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/diff-$attempt.trace" && + ! test_trace2_data fsmonitor history/external-restored 1 \ + <"$gitdir/diff-$attempt.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/diff-$attempt.trace" && + ! test_region index do_write_index \ + "$gitdir/diff-$attempt.trace" || return 1 + done && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/checkout.trace" && + + git -C "$worktree" config submodule.recurse true && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/enabled.prime" && + test_must_be_empty "$gitdir/enabled.prime" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_write_lines dirty >"$worktree/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/enabled.checkout.trace" \ + git -C "$worktree" -c submodule.recurse=false \ + checkout -- . && + test_trace2_data fsmonitor config/coherent 0 \ + <"$gitdir/enabled.checkout.trace" && + cp "$gitdir/index" "$gitdir/enabled.index" && + git -C "$worktree" --no-optional-locks \ + -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.trustctime=true -c core.checkStat=default \ + diff --no-ext-diff --no-textconv --ignore-submodules \ + >"$gitdir/enabled.expect" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" diff \ + --no-ext-diff --no-textconv --ignore-submodules \ + >"$gitdir/enabled.actual" && + test_cmp "$gitdir/enabled.expect" "$gitdir/enabled.actual" && + test_cmp_bin "$gitdir/enabled.index" "$gitdir/index" + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'global second closing-query change rejects verified subtree reuse' ' test_when_finished "rm -rf second-query-global" && diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 2df1ac6fad30e0..6d19a46d079995 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -335,6 +335,94 @@ void test_clean_status_config__only_safe_command_guards_are_normalized(void) } } +void test_clean_status_config__only_redundant_disabled_submodule_recursion_is_normalized(void) +{ + static const struct { + const char *first; + enum config_scope first_scope; + const char *second; + enum config_scope second_scope; + const char *override; + int normalized; + } cases[] = { + { NULL, 0, NULL, 0, "false", 1 }, + { NULL, 0, NULL, 0, "0", 1 }, + { NULL, 0, NULL, 0, "off", 1 }, + { NULL, 0, NULL, 0, "no", 1 }, + { "false", CONFIG_SCOPE_LOCAL, NULL, 0, "0", 1 }, + { "no", CONFIG_SCOPE_GLOBAL, NULL, 0, "off", 1 }, + { "true", CONFIG_SCOPE_LOCAL, NULL, 0, "false", 0 }, + { "invalid", CONFIG_SCOPE_LOCAL, NULL, 0, "false", 0 }, + { "false", CONFIG_SCOPE_UNKNOWN, NULL, 0, "false", 0 }, + { "false", CONFIG_SCOPE_SUBMODULE, NULL, 0, "false", 0 }, + { "true", CONFIG_SCOPE_GLOBAL, + "false", CONFIG_SCOPE_LOCAL, "false", 1 }, + { "false", CONFIG_SCOPE_GLOBAL, + "true", CONFIG_SCOPE_LOCAL, "false", 0 }, + { NULL, 0, NULL, 0, "true", 0 }, + { NULL, 0, NULL, 0, "invalid", 0 }, + { "false", CONFIG_SCOPE_LOCAL, NULL, 0, "true", 0 }, + { "true", CONFIG_SCOPE_COMMAND, + "false", CONFIG_SCOPE_COMMAND, "false", 1 }, + }; + static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + + for (size_t a = 0; a < ARRAY_SIZE(algorithms); a++) { + const struct git_hash_algo *algo = &hash_algos[algorithms[a]]; + + for (size_t i = 0; i < ARRAY_SIZE(cases); i++) { + struct clean_status_config_digest baseline, digest; + + clean_status_config_init(&baseline, algo); + clean_status_config_init(&digest, algo); + if (cases[i].first) { + kvi.scope = cases[i].first_scope; + clean_status_config_add(&baseline, "submodule.recurse", + cases[i].first, &ctx); + clean_status_config_add(&digest, "submodule.recurse", + cases[i].first, &ctx); + } + if (cases[i].second) { + kvi.scope = cases[i].second_scope; + clean_status_config_add(&baseline, "submodule.recurse", + cases[i].second, &ctx); + clean_status_config_add(&digest, "submodule.recurse", + cases[i].second, &ctx); + } + clean_status_config_final(&baseline); + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_add(&digest, "submodule.recurse", + cases[i].override, &ctx); + clean_status_config_final(&digest); + cl_assert_equal_i(hasheq(digest.hash, baseline.hash, algo), + cases[i].normalized); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + } + + { + struct clean_status_config_digest baseline, digest; + + clean_status_config_init(&baseline, algo); + clean_status_config_init(&digest, algo); + clean_status_config_add(&baseline, "submodule.recurse", + "false", NULL); + clean_status_config_add(&digest, "submodule.recurse", + "false", NULL); + clean_status_config_final(&baseline); + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_add(&digest, "submodule.recurse", + "false", &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + } + } +} + void test_clean_status_config__command_empty_attributes_do_not_change_proof(void) { static const enum config_scope persistent_scopes[] = { From e658bba676e36fa3699cb81cac1c9b668260dccb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:22:16 -0500 Subject: [PATCH 344/403] tests: integrate scoped proof coverage with both harnesses The new reader and stash suites run directly, but Meson's explicit test list does not include them. Its configure-time inventory check rejects the source tree before any tests can run. Register both scripts next to the existing status and preload coverage, and keep the existing history unit-test entries in the order required by check-meson. The nested stash regressions also negate test_grep at the shell level. Use the helper's negative-match form so a grep error cannot be mistaken for an expected absence. This satisfies test-greplint without changing the assertions or product code. The old source reproduces both inventory omissions and all four lint errors. The corrected inventory matches the test files, check-meson and the complete test-lint target pass, and the nine stash cases pass with SHA-1 and SHA-256. --- t/meson.build | 4 +++- t/t7533-status-scoped-stash.sh | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/t/meson.build b/t/meson.build index 3a6263fefd00f9..e486e988bc9aaa 100644 --- a/t/meson.build +++ b/t/meson.build @@ -2,8 +2,8 @@ clar_test_suites = [ 'unit-tests/u-attr-fingerprint.c', 'unit-tests/u-attr-manifest.c', 'unit-tests/u-clean-status-config.c', - 'unit-tests/u-clean-status-history.c', 'unit-tests/u-clean-status-history-store.c', + 'unit-tests/u-clean-status-history.c', 'unit-tests/u-clean-status-identity.c', 'unit-tests/u-clean-status-index.c', 'unit-tests/u-clean-status-manifest.c', @@ -965,6 +965,8 @@ integration_tests = [ 't7530-status-clean-sidecar.sh', 't7531-semantic-verify.sh', 't7532-preload-index-linux.sh', + 't7533-status-scoped-stash.sh', + 't7534-status-scoped-readers.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7533-status-scoped-stash.sh b/t/t7533-status-scoped-stash.sh index 81bdf080cabdf4..659cc220889bb9 100755 --- a/t/t7533-status-scoped-stash.sh +++ b/t/t7533-status-scoped-stash.sh @@ -577,8 +577,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP GIT_CONFIG_PARAMETERS="${SQ}core.fsmonitor=false${SQ}" \ test-tool dump-untracked-cache >.git/dirty-cache && test_grep "^/nested/ .* recurse$" .git/dirty-cache && - ! test_grep "^/nested/ .* valid" .git/dirty-cache && - ! test_grep "^existing-untracked$" .git/dirty-cache && + test_grep ! "^/nested/ .* valid" .git/dirty-cache && + test_grep ! "^existing-untracked$" .git/dirty-cache && cp .git/index .git/before-reader && scoped_stash_control_git --no-optional-locks \ status --porcelain=v2 >.git/control-before && @@ -723,8 +723,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP GIT_CONFIG_PARAMETERS="${SQ}core.fsmonitor=false${SQ}" \ test-tool dump-untracked-cache >.git/serialized-cache && test_grep "^/nested/ .* recurse$" .git/serialized-cache && - ! test_grep "^/nested/ .* valid" .git/serialized-cache && - ! test_grep "^existing-untracked$" \ + test_grep ! "^/nested/ .* valid" .git/serialized-cache && + test_grep ! "^existing-untracked$" \ .git/serialized-cache && cp .git/index .git/after-apply && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ From bc0e68c4258b2a5a1dc9cafdb9fbee8ac40a1c9c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:42:39 -0500 Subject: [PATCH 345/403] t7534: force the same-stat oracle to read file contents The same-stat check added by d079f66b83 (fsmonitor: avoid repository-wide bootstrap for bounded readers, 2026-08-16) compares the bounded reader with an fsmonitor-disabled control. It rewrites a file without changing its size and restores the original mtime. Without USE_NSEC, the control compares ctime at second resolution. An edit in the same second can therefore leave its cached stat data unchanged. The control reports a clean file while the bounded reader correctly detects the changed contents, making the test fail spuriously. For this control only, copy the index and reinsert the tracked entry with its original mode and object ID using update-index --cacheinfo. The new entry has no cached stat data, so the ordinary diff must read the file contents. The staged contents and physical index are unchanged, and the existing index and sidecar immutability checks still apply. --- t/t7534-status-scoped-readers.sh | 44 +++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/t/t7534-status-scoped-readers.sh b/t/t7534-status-scoped-readers.sh index 4d2081b4def702..249a4ac7813de9 100755 --- a/t/t7534-status-scoped-readers.sh +++ b/t/t7534-status-scoped-readers.sh @@ -46,13 +46,43 @@ assert_scoped_reader () { scoped_locks=$2 && shift 2 && cp "$gitdir/index" "$gitdir/$scoped_label.index" && - GIT_OPTIONAL_LOCKS=0 \ - git -c core.fsmonitor=false \ - -c core.untrackedCache=false \ - -c core.trustctime=true \ - -c core.checkStat=default \ - -C "$worktree" "$@" \ - >"$gitdir/$scoped_label.expect" && + if test "$scoped_label" = same-stat + then + scoped_oracle_index="$gitdir/$scoped_label.oracle.index" && + cp "$gitdir/index" "$scoped_oracle_index" && + GIT_INDEX_FILE="$scoped_oracle_index" \ + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -C "$worktree" ls-files --stage -- tracked \ + >"$gitdir/$scoped_label.stage" && + test_line_count = 1 "$gitdir/$scoped_label.stage" && + read scoped_mode scoped_oid scoped_stage scoped_path \ + <"$gitdir/$scoped_label.stage" && + test "$scoped_stage" = 0 && + test "$scoped_path" = tracked && + GIT_INDEX_FILE="$scoped_oracle_index" \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -C "$worktree" update-index \ + --cacheinfo "$scoped_mode,$scoped_oid,$scoped_path" && + GIT_INDEX_FILE="$scoped_oracle_index" \ + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + -C "$worktree" "$@" \ + >"$gitdir/$scoped_label.expect" + else + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + -C "$worktree" "$@" \ + >"$gitdir/$scoped_label.expect" + fi && GIT_OPTIONAL_LOCKS=$scoped_locks \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/$scoped_label.trace" \ From 39647c8865314b2229f9ca6416478aee8ccd4215 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:59:10 -0500 Subject: [PATCH 346/403] t7534: force the required-filter control to read the file bc0e68c425 (t7534: force the same-stat oracle to read file contents, 2026-08-16) gives the same-stat comparison an index whose tracked entry has no cached stat data. The later required-filter comparison still uses the physical index, even though the same deliberately hidden edit remains in the worktree. On a filesystem whose cached timestamps match, that control can report a clean file without invoking the required filter. The candidate reads the file and correctly reports the filter failure, so the comparison fails for the wrong reason. Reuse the content-backed scratch index for the required-filter control. The candidate continues to read the physical index, and the existing filter-error and index-immutability assertions remain unchanged. --- t/t7534-status-scoped-readers.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/t/t7534-status-scoped-readers.sh b/t/t7534-status-scoped-readers.sh index 249a4ac7813de9..d4451eba636c2d 100755 --- a/t/t7534-status-scoped-readers.sh +++ b/t/t7534-status-scoped-readers.sh @@ -231,7 +231,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP --no-ext-diff --no-textconv -- tracked \ >"$gitdir/required-filter.actual" \ 2>"$gitdir/required-filter.error" && - test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + test_must_fail env \ + GIT_INDEX_FILE="$gitdir/same-stat.oracle.index" \ + GIT_OPTIONAL_LOCKS=0 \ git -c core.fsmonitor=false \ -c core.untrackedCache=false \ -c core.trustctime=true \ From 284a8ae410943c720dae2d37dd91ebba2a5829bd Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 14:05:43 -0500 Subject: [PATCH 347/403] t7533: keep the staged-sibling fixture out of split-index mode 428d829240 (stash: retain paired history for scoped regular-file changes, 2026-08-16) adds a full-proof test for an unrelated staged sibling. Its setup helper clears GIT_TEST_SPLIT_INDEX inside a subshell, but the test starts another subshell after that helper returns. The linux-TEST-vars job therefore restores the inherited split-index setting. The two ordinary adds enable split index and downgrade the proof before the stash operation under test. The full-proof assertion fails for a fixture that no longer exercises the intended index form. Clear GIT_TEST_SPLIT_INDEX in the subshell that runs the scenario, as the other full-proof tests do. Keep the proof and independent-control assertions intact. The original fails and the corrected test passes under the inherited CI setting with both object formats. --- t/t7533-status-scoped-stash.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/t/t7533-status-scoped-stash.sh b/t/t7533-status-scoped-stash.sh index 659cc220889bb9..67bf189c6e7d31 100755 --- a/t/t7533-status-scoped-stash.sh +++ b/t/t7533-status-scoped-stash.sh @@ -216,6 +216,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP scoped_stash_setup scoped-stash-staged && ( cd scoped-stash-staged && + sane_unset GIT_TEST_SPLIT_INDEX && gitdir="$PWD/.git" && scoped_control="$PWD/../scoped-stash-staged-control" && git -c core.fsmonitor=false \ From 810ab59f8d19ae85fe258e37b864314c014a3a8d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 14:58:01 -0500 Subject: [PATCH 348/403] am: retain authenticated worktree proofs before refresh e3c9a5b818 (fsmonitor: preserve proofs through filters, replay, and index locks, 2026-08-15) initializes authenticated history for cherry-pick and revert. But am invokes the apply machinery directly and never supplies the current clean-status configuration before its pre-apply refresh writes the index. The existing FSCF remains eligible for serialization, but the writer cannot bind it to the current configuration. It therefore clears the token and stat bindings even when the provider token has not changed. Each subsequent read-only status rebuilds the worktree manifest. Initialize the same guarded native-provider configuration and history before am first reads the index. Keep the existing checks for temporary three-way indexes, attribute changes, filters, and unsafe replacements. Cover primary and linked worktrees with an unchanged provider token, immediate physical-proof checks, independent commit and content oracles, and two immutable read-only followers. A patch that changes attribute semantics must still invalidate its proof. --- builtin/am.c | 11 +++ t/t7519-status-fsmonitor.sh | 154 +++++++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/builtin/am.c b/builtin/am.c index e9623b8307793f..0c1039070325e9 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -9,9 +9,12 @@ #include "builtin.h" #include "abspath.h" #include "advice.h" +#include "clean-status-config.h" +#include "clean-status.h" #include "config.h" #include "editor.h" #include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "hex.h" #include "parse-options.h" @@ -2315,6 +2318,7 @@ int cmd_am(int argc, struct repository *repo UNUSED) { struct am_state state; + struct clean_status_config_digest clean_digest; int binary = -1; int keep_cr = -1; int patch_format = PATCH_FORMAT_UNKNOWN; @@ -2464,6 +2468,13 @@ int cmd_am(int argc, /* Ensure a valid committer ident can be constructed */ git_committer_info(IDENT_STRICT); + if (fstat_is_reliable() && !getenv(INDEX_ENVIRONMENT) && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + !clean_status_config_read_repository(the_repository, &clean_digest)) { + clean_status_enable_external_history(the_repository); + clean_status_set_config_digest(the_repository, &clean_digest); + } + if (repo_read_index_preload(the_repository, NULL, 0) < 0) die(_("failed to read the index")); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 3cc9ff531d0255..ced1e7c1cc438d 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -757,7 +757,7 @@ prepare_builtin_closure_repo () { } test_fsmonitor_full_proof () { - perl - "$1" "$2" <<-\EOF + perl - "$@" <<-\EOF binmode STDIN; open my $input, "<", $ARGV[0] or die "cannot read index: $!\n"; binmode $input; @@ -788,6 +788,8 @@ test_fsmonitor_full_proof () { "pending:$suffix" : $tokens{"FSMN"}; die "mismatched untracked token\n" unless $tokens{"FSUC"} eq $untracked; + die "unexpected provider token\n" if defined($ARGV[2]) && + $tokens{"FSMN"} ne $ARGV[2]; EOF } @@ -5475,4 +5477,154 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'am preserves complete worktree proofs at an unchanged provider token' ' + test_when_finished "rm -rf am-provider-proof am-provider-proof-linked \ + am-provider-proof.patch \ + am-provider-proof.expected-tree" && + test_create_repo am-provider-proof && + ( + cd am-provider-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + test_write_lines sibling >sibling && + git add tracked sibling && + git commit -qm base && + test_write_lines patched >tracked && + git add tracked && + git commit -qm patched && + git rev-parse HEAD^{tree} >../am-provider-proof.expected-tree && + git format-patch -1 --stdout >../am-provider-proof.patch && + git reset --hard -q HEAD^ && + git worktree add --detach -q \ + ../am-provider-proof-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../am-provider-proof-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + test-tool chmtime -120 \ + "$worktree/tracked" "$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + test_fsmonitor_full_proof "$gitdir/index" paired && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index \ + --force-write-index && + test_fsmonitor_full_proof "$gitdir/index" paired \ + "builtin:test:1" && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/am.trace" \ + git -C "$worktree" am --quiet \ + "$PWD/../am-provider-proof.patch" && + test_fsmonitor_full_proof "$gitdir/index" paired \ + "builtin:test:1" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/am.trace" && + git -C "$worktree" rev-parse HEAD^{tree} \ + >"$gitdir/actual-tree" && + test_cmp "$PWD/../am-provider-proof.expected-tree" \ + "$gitdir/actual-tree" && + test_write_lines patched >"$gitdir/expected-tracked" && + test_cmp "$gitdir/expected-tracked" \ + "$worktree/tracked" && + cp "$gitdir/index" "$gitdir/index.snapshot" && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 >"$gitdir/expected" && + test_must_be_empty "$gitdir/expected" && + test_cmp_bin "$gitdir/index.snapshot" "$gitdir/index" && + for reader in first second + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$reader.trace" \ + git --no-optional-locks -C "$worktree" \ + status --porcelain=v2 \ + >"$gitdir/$reader.actual" && + test_cmp "$gitdir/expected" \ + "$gitdir/$reader.actual" && + test_cmp_bin "$gitdir/index.snapshot" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$reader.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$reader.trace" && + test_region ! index do_write_index \ + "$gitdir/$reader.trace" || return 1 + done || return 1 + done + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'am invalidates proofs when a patch changes attribute semantics' ' + test_when_finished "rm -rf am-provider-attributes \ + am-provider-attributes.patch" && + test_create_repo am-provider-attributes && + ( + cd am-provider-attributes && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + test_write_lines sibling >sibling && + test_write_lines "tracked text" >.gitattributes && + git add tracked sibling .gitattributes && + git commit -qm base && + test_write_lines patched >tracked && + test_write_lines "tracked -text" >.gitattributes && + git add tracked .gitattributes && + git commit -qm "change attribute semantics" && + git rev-parse HEAD^{tree} >.git/expected-tree && + git format-patch -1 --stdout >../am-provider-attributes.patch && + git reset --hard -q HEAD^ && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime -120 tracked sibling .gitattributes && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_fsmonitor_full_proof .git/index paired && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --force-write-index && + test_fsmonitor_full_proof .git/index paired \ + "builtin:test:1" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/am.trace" \ + git am --quiet ../am-provider-attributes.patch && + ! test_fsmonitor_full_proof .git/index paired && + ! test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <.git/am.trace && + git rev-parse HEAD^{tree} >.git/actual-tree && + test_cmp .git/expected-tree .git/actual-tree && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git --no-optional-locks status --porcelain=v2 \ + >.git/actual && + git --no-optional-locks \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 >.git/expected && + test_cmp .git/expected .git/actual + ) +' + test_done From fec9824447dce1f522b9907e788eada494a73a0c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 16:01:16 -0500 Subject: [PATCH 349/403] t7519: age the staged entry before checkpoint reissue 5f2a52ecb8 (t7519: cover stash creation from linked external history, 2026-08-14) ages the initial tracked files before creating its linked history checkpoint, but later adds a new file with a fresh timestamp. On builds without USE_NSEC, the final writable status can reach the checkpoint writer in the same second as that new index entry. The writer correctly rejects the racy index, so the test intermittently misses its expected history/external-stored marker. Set the new file's mtime to 120 seconds before the current time before adding it. This keeps the fixture eligible without weakening the checkpoint guard. A future-mtime control reproduces the rejection on both the current and parent-equivalent native builds in SHA-1 and SHA-256. The aged fixture publishes the checkpoint in both hash formats. --- t/t7519-status-fsmonitor.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index ced1e7c1cc438d..315b2ca3578372 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -4850,6 +4850,7 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO >"$gitdir/witnesses" && test_line_count = 1 "$gitdir/witnesses" && test_write_lines staged >"$worktree/existing/staged" && + test-tool chmtime =-120 "$worktree/existing/staged" && git -C "$worktree" add existing/staged && GIT_INDEX_FILE="$gitdir/index" \ git -C "$worktree" status --porcelain=v2 \ From 687de436f150abf15b8eaa32143d3fd8afc7b88c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 17:10:24 -0500 Subject: [PATCH 350/403] stash: leave room for the object ID terminator d9b6634589 (stash: be careful what we store, 2023-10-11) added a stash-like check before updating refs/stash. It formats the candidate object ID in a GIT_MAX_HEXSZ-byte stack buffer, but oid_to_hex_r() also writes a terminating NUL. SHA-1 fits in the buffer; SHA-256 writes one byte past its end when storing an otherwise valid stash. Reserve the extra byte required by the formatting API. Add an explicit SHA-256 repository test for stash store and ordinary push so the usual SHA-1 sanitizer jobs also exercise a maximum-width object ID. The old code aborts with a stack-buffer-overflow in hash_to_hex_algop_r(); the corrected buffer preserves the existing stash validation and updates. --- builtin/stash.c | 2 +- t/t3903-stash.sh | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/builtin/stash.c b/builtin/stash.c index 3eb63d18faa0fd..03838fee5424d2 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1169,7 +1169,7 @@ static int do_store_stash(const struct object_id *w_commit, const char *stash_ms int quiet) { struct stash_info info; - char revision[GIT_MAX_HEXSZ]; + char revision[GIT_MAX_HEXSZ + 1]; oid_to_hex_r(revision, w_commit); assert_stash_like(&info, revision); diff --git a/t/t3903-stash.sh b/t/t3903-stash.sh index 8ac681e41df64a..1401b82e4b1360 100755 --- a/t/t3903-stash.sh +++ b/t/t3903-stash.sh @@ -953,6 +953,31 @@ test_expect_success 'store called with non-stash commit' ' test_must_fail git stash store HEAD ' +test_expect_success 'stash store and push support explicit SHA-256 repositories' ' + test_when_finished "rm -rf stash-explicit-sha256" && + git init --object-format=sha256 stash-explicit-sha256 && + ( + cd stash-explicit-sha256 && + git config core.fsmonitor false && + test "$(git rev-parse --show-object-format)" = sha256 && + echo original >tracked && + git add tracked && + git commit -m base && + echo stored >tracked && + oid=$(git stash create) && + test "${#oid}" -eq 64 && + git stash store -m stored "$oid" && + test "$oid" = "$(git rev-parse refs/stash)" && + test "$oid" = "$(git reflog --format=%H -1 refs/stash)" && + git stash clear && + echo pushed >tracked && + git stash push -m pushed -- tracked && + test "$(cat tracked)" = original && + git stash pop && + test "$(cat tracked)" = pushed + ) +' + test_expect_success 'store updates stash ref and reflog' ' git stash clear && git reset --hard && From ef3af6c9783b46da009e2ef19098f7ac3bebbf16 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 17:51:27 -0500 Subject: [PATCH 351/403] t7530: probe fsmonitor without requiring an index write e4f0e5486f (status: issue sidecars after a verified full scan, 2026-07-28) added a prerequisite which runs status and expects a builtin fsmonitor token in the physical index. With a non-racy index, status can instead save external history and leave that index alone. The daemon works, but dump-fsmonitor sees no physical FSMN extension and the prerequisite skips every sidecar test. Query the running daemon with an explicit token, as t7527 already does, instead of requiring status to write the index. Keep the status smoke test and make its index non-racy so the probe covers this no-write case. The sidecar tests continue to check proof publication and reuse; no production behavior changes. --- t/t7530-status-clean-sidecar.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 40b21af45cb1f9..3714810732f746 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -20,11 +20,14 @@ test_lazy_prereq DURABLE_FSMONITOR ' ( cd durable-fsmonitor-probe && test_commit base tracked && + test-tool chmtime =-120 tracked && + git -c core.fsmonitor=false update-index --refresh && git config core.fsmonitor true && git fsmonitor--daemon start --start-timeout=10 && git status --porcelain=v2 >/dev/null && - test-tool dump-fsmonitor >token && - grep "^fsmonitor last update builtin:" token + test-tool fsmonitor-client query --token 0 >token && + nul_to_q token.filtered && + grep "^builtin:" token.filtered result=$? git fsmonitor--daemon stop >/dev/null 2>&1 || : exit $result From e94acd7cc2c7087a6e097e2b87251b04c374e59d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 18:47:23 -0500 Subject: [PATCH 352/403] t7530: isolate external exclude FIFO parents The FIFO tests added by 61f7705f80 (status: answer exact clean status before index deserialization, 2026-07-27) put their configured core.excludesFile directly in the shared temporary directory. Exclude proofs compare the parent directory's full stat data while reading a source. An unrelated process creating or removing a temporary file can therefore invalidate the initial capture. In the raced test, status can then take the conservative fast-excludes fallback and exit successfully before reaching the synchronization barrier. The test fails without having replaced the excludes file. Give each FIFO fixture a private temporary directory outside its worktree. The intended regular-file-to-FIFO replacement still changes the authenticated source and its parent, and all existing race and fallback assertions remain intact. Leave the production proof checks unchanged. --- t/t7530-status-clean-sidecar.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 3714810732f746..6de465a64bda35 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2211,9 +2211,11 @@ test_expect_success PIPE,DURABLE_FSMONITOR \ 'an existing exclude FIFO cannot block fast-path capture' ' test_when_finished "stop_daemon sidecar-exclude-fifo" && setup_repo sidecar-exclude-fifo && - exclude_file=$(mktemp \ + exclude_dir=$(mktemp -d \ "${TMPDIR:-/tmp}/git-status-exclude-fifo.XXXXXX") && - test_when_finished "rm -f \"$exclude_file\"" && + test_when_finished "rm -rf \"$exclude_dir\"" && + exclude_file=$exclude_dir/global && + : >"$exclude_file" && git -C sidecar-exclude-fifo config core.excludesFile \ "$exclude_file" && issue_sidecar sidecar-exclude-fifo && @@ -2231,9 +2233,10 @@ test_expect_success PIPE,DURABLE_FSMONITOR \ test_when_finished "stop_daemon sidecar-exclude-race" && test_when_finished "cleanup_fast_race" && setup_repo sidecar-exclude-race && - exclude_file=$(mktemp \ + exclude_dir=$(mktemp -d \ "${TMPDIR:-/tmp}/git-status-exclude-race.XXXXXX") && - test_when_finished "rm -f \"$exclude_file\"" && + test_when_finished "rm -rf \"$exclude_dir\"" && + exclude_file=$exclude_dir/global && test_write_lines ignored >"$exclude_file" && git -C sidecar-exclude-race config core.excludesFile \ "$exclude_file" && From cb6cbd4f5ebdd0981b07adde3a88d6bff32db08c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 02:19:10 -0500 Subject: [PATCH 353/403] status: defer Linux scoped history hashing until a repair 5684c457ea (status: refresh external history before exact proofs, 2026-08-10) captures the original logical index before refreshing its entries. That lets a later checkpoint authenticate the pre-repair source without losing a required physical stat update. On Linux, where the physical index has no durable identity, an ordinary tracked-only scoped status repeats that full-index hash even when it needs no repair. Two calls on an 858,001-entry dirty index spend about 350 ms each doing so. Defer the capture for explicit --untracked-files=no queries over a bounded set of literal, semantically safe tracked files. Require a full physical index, authenticated current provider and filter proofs, paired recursively valid untracked history, and a pinned original index. Keep the ordinary content checks, collection, and optional index lock. Omit the checkpoint only after those checks leave no persistent entry change, the complete proof still matches, and the entire index is non-racy. If refresh repairs an entry, read the original index through a duplicate of its pinned descriptor and recover the original logical hash. Then use the existing checkpoint and physical-index writers. Recheck the source under index.lock before publication and immediately before writing; if the source changed, roll back only our lock. This also handles a skipped index checksum without treating zero as a durable identity or allowing one zero-checksum writer to overwrite another. On a separately re-primed, 1,111,063-entry Linux index with a scripted provider, balanced old/new runs drop from 828-836 ms to 216 ms. The old logical digest takes 456-460 ms; the new path does not compute it. Both versions match an independent provider-disabled status and leave the physical index and existing checkpoints unchanged. The index read alone still takes 134-138 ms, and the whole-index racy check remains. Cover genuinely dirty and clean zero-stat entries, forwarded provider tokens, main and linked worktrees, both object formats, racy entries, active filters, and a competing zero-checksum writer. Other platforms, query shapes, and ordinary checkpoint publication keep their existing behavior. --- builtin/commit.c | 247 +++++++++++++++- clean-status-history.c | 67 +++++ clean-status.h | 6 + read-cache-ll.h | 3 + read-cache.c | 30 +- t/t7519-status-fsmonitor.sh | 563 ++++++++++++++++++++++++++++++++++++ 6 files changed, 908 insertions(+), 8 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 04bfacdb08354e..1d0f21138f8ca0 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -9,6 +9,7 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "builtin.h" +#include "abspath.h" #include "advice.h" #include "config.h" #include "lockfile.h" @@ -35,9 +36,11 @@ #include "preload-index.h" #include "read-cache.h" #include "refs.h" +#include "replace-object.h" #include "repository.h" #include "string-list.h" #include "submodule.h" +#include "symlinks.h" #include "rerere.h" #include "unpack-trees.h" #include "column.h" @@ -51,6 +54,7 @@ #include "pretty.h" #include "trace2.h" #include "trailer.h" +#include "wrapper.h" static const char * const builtin_commit_usage[] = { N_("git commit [-a | --interactive | --patch] [-s] [-v] [-u[]] [--amend]\n" @@ -1676,6 +1680,185 @@ static int clean_status_sidecar_needs_reissue(struct repository *repo, return reissue; } +#ifdef __linux__ +static int clean_status_scoped_history_test_barrier(void) +{ + const char *ready = + getenv("GIT_TEST_CLEAN_STATUS_SCOPED_HISTORY_BARRIER_READY"); + const char *resume = + getenv("GIT_TEST_CLEAN_STATUS_SCOPED_HISTORY_BARRIER_RESUME"); + const char *scripted = getenv("GIT_TEST_FSMONITOR_QUERY_SEQUENCE"); + struct stat fifo, opened; + char resumed; + int fd, failed; + + if (!ready && !resume) + return 0; + if (!scripted || !*scripted || !ready || !*ready || + !resume || !*resume || lstat(resume, &fifo) || + !S_ISFIFO(fifo.st_mode) || fifo.st_uid != geteuid()) + return -1; + fd = open(ready, O_WRONLY | O_CREAT | O_EXCL | + O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) + return -1; + failed = fstat(fd, &opened) || !S_ISREG(opened.st_mode) || + opened.st_uid != geteuid() || opened.st_nlink != 1 || + write_in_full(fd, "ready\n", 6) != 6; + if (close(fd) || failed) + return -1; + fd = open(resume, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + return -1; + failed = fstat(fd, &opened) || !S_ISFIFO(opened.st_mode) || + opened.st_uid != geteuid() || + opened.st_dev != fifo.st_dev || opened.st_ino != fifo.st_ino || + read_in_full(fd, &resumed, 1) != 1; + if (close(fd) || failed) + return -1; + return 0; +} + +static int clean_status_scoped_pathspec_is_bounded( + const struct wt_status *status) +{ + const struct pathspec *pathspec = &status->pathspec; + struct index_state *istate = status->repo->index; + int i; + + if (pathspec->nr <= 0 || pathspec->nr > 64 || + pathspec->has_wildcard || + (pathspec->magic & + (PATHSPEC_GLOB | PATHSPEC_ICASE | + PATHSPEC_EXCLUDE | PATHSPEC_ATTR))) + return 0; + for (i = 0; i < pathspec->nr; i++) { + const struct pathspec_item *item = &pathspec->items[i]; + const struct cache_entry *ce; + struct stat st; + int pos; + + if (!item->match || item->len <= 0 || + item->match[item->len - 1] == '/' || + !strcmp(item->match, ".") || + has_symlink_leading_path(item->match, item->len) || + lstat(item->match, &st) || !S_ISREG(st.st_mode)) + return 0; + pos = index_name_pos(istate, item->match, item->len); + if (pos < 0) + return 0; + ce = istate->cache[pos]; + if (!S_ISREG(ce->ce_mode) || ce_stage(ce) || + ce_intent_to_add(ce) || ce_skip_worktree(ce) || + (ce->ce_flags & CE_VALID) || + !clean_status_index_entry_is_semantically_safe( + istate, ce, ce)) + return 0; + } + return 1; +} +#endif + +static int clean_status_scoped_provider_is_current( + const struct index_state *istate) +{ + return clean_status_has_persistent_fsmonitor_semantic_history(istate) && + clean_status_has_current_full_fsmonitor_proof(istate) && + istate->fsmonitor_token_valid && + istate->fsmonitor_last_update && + !istate->fsmonitor_last_update_pending && + istate->fsmonitor_untracked_extension_seen && + !istate->fsmonitor_untracked_extension_invalid && + istate->fsmonitor_untracked_valid && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token) && + istate->untracked && istate->untracked->root && + istate->untracked->root->valid && + istate->untracked->root->valid_recursive && + !istate->untracked->root->fsmonitor_dirty && + !istate->untracked->fsmonitor_dirty_paths.len && + !istate->fsmonitor_untracked_must_persist && + !clean_status_worktree_manifest_needs_refresh(istate) && + !clean_status_manifest_global_fallback(istate) && + !clean_status_external_history_was_restored(istate); +} + +static int clean_status_defer_scoped_history_capture( + const struct wt_status *status, + struct clean_status_index_snapshot *snapshot) +{ +#ifdef __linux__ + struct repository *repo = status->repo; + struct index_state *istate = repo->index; + struct stat st; + char *physical, *selected, *canonical; + int eligible; + + /* + * Legacy checkpoints cannot authenticate a deferred source digest. + * Keep their ordinary lock and content verification; only a later + * clean proof may decide that publishing a checkpoint is unnecessary. + */ + if (clean_status_identity_is_durable() || + !untracked_files_arg || strcmp(untracked_files_arg, "no") || + status->show_untracked_files != SHOW_NO_UNTRACKED_FILES || + status->show_ignored_mode || status->submodule_summary || + !clean_status_scoped_pathspec_is_bounded(status) || + getenv(INDEX_ENVIRONMENT) || + getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || + getenv(DB_ENVIRONMENT) || + getenv(ALTERNATE_DB_ENVIRONMENT) || + istate != repo->index || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + repo_config_get_split_index(repo) > 0 || + repo_config_values(repo)->apply_sparse_checkout || + !fstat_is_reliable() || + !repo->config_values_private_.trust_ctime || + !repo->config_values_private_.check_stat || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC || + (istate->cache_changed & + ~(FSMONITOR_CHANGED | UNTRACKED_CHANGED)) || + !clean_status_scoped_provider_is_current(istate) || + repo_has_replace_refs_uncached(repo)) + return 0; + + physical = xstrfmt("%s/index", repo_get_git_dir(repo)); + selected = real_pathdup(repo_get_index_file(repo), 0); + canonical = real_pathdup(physical, 0); + eligible = selected && canonical && + !fspathcmp(selected, canonical) && + !lstat(physical, &st) && S_ISREG(st.st_mode) && + st.st_nlink == 1 && + !clean_status_index_snapshot_pin_proof_epoch(snapshot, istate); + if (!eligible) + clean_status_index_snapshot_release(snapshot); + free(canonical); + free(selected); + free(physical); + return eligible; +#else + (void)status; + (void)snapshot; + return 0; +#endif +} + +static int clean_status_scoped_history_can_rollback( + const struct wt_status *status, + const struct clean_status_index_snapshot *snapshot) +{ + struct index_state *istate = status->repo->index; + + return clean_status_scoped_provider_is_current(istate) && + !(istate->cache_changed & + ~(FSMONITOR_CHANGED | UNTRACKED_CHANGED)) && + clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate) && + !has_racy_timestamp(istate); +} + int cmd_status(int argc, const char **argv, const char *prefix, @@ -1698,6 +1881,11 @@ struct repository *repo UNUSED) int repository_inputs_changed = 0; int reissue_after_write = 0; int save_history_after_write = 0; + int deferred_scoped_history = 0; + int guarded_scoped_history_source = 0; + struct clean_status_index_snapshot scoped_history_source = { + .fd = -1, + }; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -1834,9 +2022,22 @@ struct repository *repo UNUSED) if (use_optional_locks()) clean_status_require_external_history_source(the_repository); repo_read_index(the_repository); - if (use_optional_locks()) - clean_status_capture_external_history_source( - the_repository->index); + if (use_optional_locks()) { + deferred_scoped_history = + clean_status_defer_scoped_history_capture( + &s, &scoped_history_source); + guarded_scoped_history_source = deferred_scoped_history; + if (deferred_scoped_history) { + trace2_data_intmax("fsmonitor", the_repository, + "history/scoped-source-capture-deferred", 1); +#ifdef __linux__ + if (clean_status_scoped_history_test_barrier()) + die("invalid clean status scoped-history test barrier"); +#endif + } else + clean_status_capture_external_history_source( + the_repository->index); + } if (normal_clean_query && use_optional_locks() && clean_status_identity_is_durable() && (reissue_clean_sidecar || @@ -1876,7 +2077,36 @@ struct repository *repo UNUSED) wt_status_collect(&s); - if (0 <= fd) { + if (0 <= fd && guarded_scoped_history_source && + !clean_status_index_snapshot_still_matches_proof_epoch( + &scoped_history_source, the_repository->index)) { + rollback_lock_file(&index_lock); + fd = -1; + trace2_data_intmax("fsmonitor", the_repository, + "history/scoped-source-epoch-mismatch", 1); + } + if (0 <= fd && deferred_scoped_history) { + if (clean_status_scoped_history_can_rollback( + &s, &scoped_history_source)) { + rollback_lock_file(&index_lock); + fd = -1; + trace2_data_intmax("fsmonitor", the_repository, + "history/scoped-source-capture-skipped", 1); + } else { + trace2_data_intmax("fsmonitor", the_repository, + "history/scoped-source-repair-required", 1); + if (!clean_status_capture_external_history_source_from_snapshot( + the_repository->index, &scoped_history_source)) + deferred_scoped_history = 0; + else { + rollback_lock_file(&index_lock); + fd = -1; + trace2_data_intmax("fsmonitor", the_repository, + "history/scoped-source-epoch-mismatch", 1); + } + } + } + if (0 <= fd && !deferred_scoped_history) { int external_restored = clean_status_external_history_was_restored( the_repository->index); @@ -1953,6 +2183,14 @@ struct repository *repo UNUSED) fd = -1; } } + if (0 <= fd && guarded_scoped_history_source && + !clean_status_index_snapshot_still_matches_proof_epoch( + &scoped_history_source, the_repository->index)) { + rollback_lock_file(&index_lock); + fd = -1; + trace2_data_intmax("fsmonitor", the_repository, + "history/scoped-source-epoch-mismatch", 1); + } if (0 <= fd) { repo_update_index_if_able(the_repository, &index_lock); if (save_history_after_write && @@ -1974,6 +2212,7 @@ struct repository *repo UNUSED) rollback_lock_file(&index_lock); } } + clean_status_index_snapshot_release(&scoped_history_source); if (s.relative_paths) s.prefix = prefix; diff --git a/clean-status-history.c b/clean-status-history.c index 073182c9ed8f27..f3d5041f16853c 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -407,6 +407,17 @@ static int current_proof_is_writable(const struct index_state *istate) clean_status_revalidated_token_matches(istate); } +int clean_status_has_current_full_fsmonitor_proof( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return current_proof_is_writable(istate) && + state->manifest.checked && + !state->manifest.current_invalidated && + !state->manifest.global_fallback; +} + void clean_status_advance_fsmonitor_config_token( struct index_state *istate, const char *next_token) { @@ -567,6 +578,62 @@ void clean_status_capture_external_history_source( clean_status_history_store_record_release(&record); } +int clean_status_capture_external_history_source_from_snapshot( + struct index_state *istate, + const struct clean_status_index_snapshot *snapshot) +{ +#ifdef __linux__ + struct index_state original = INDEX_STATE_INIT(istate->repo); + struct clean_status_state *state = istate->clean_status; + unsigned char hash[GIT_MAX_RAWSZ]; + int owned, captured = -1; + + /* + * The selected entry may already contain a legitimate stat repair. + * Recover its original logical source from the still-pinned physical + * descriptor instead of authenticating the mutated in-memory index. + */ + if (!state || + !clean_status_external_history_enabled(istate) || + getenv(INDEX_ENVIRONMENT) || istate != istate->repo->index || + snapshot->fd < 0 || + !clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + if (state->source_logical_hash_valid) { + captured = 0; + goto done; + } + owned = fcntl(snapshot->fd, F_DUPFD_CLOEXEC, 0); + if (owned < 0 || + do_read_index_from_fd(&original, owned, + istate->repo->index_file) < 0 || + original.repo != istate->repo || + original.version != snapshot->version || + original.cache_nr != snapshot->cache_nr || + !oideq(&original.oid, &snapshot->checksum) || + original.split_index || original.sparse_index != INDEX_EXPANDED || + clean_status_index_logical_digest(&original, hash) || + !clean_status_index_snapshot_still_matches_proof_epoch( + snapshot, istate)) + goto done; + memcpy(state->source_logical_hash, hash, + istate->repo->hash_algo->rawsz); + state->source_logical_hash_valid = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "history/scoped-original-source-restored", 1); + captured = 0; + +done: + release_index(&original); + return captured; +#else + (void)istate; + (void)snapshot; + return -1; +#endif +} + static struct clean_status_external_checkpoint * clean_status_prepare_external_history(struct index_state *istate) { diff --git a/clean-status.h b/clean-status.h index 8602e66f6213c0..5a9698a8f9accc 100644 --- a/clean-status.h +++ b/clean-status.h @@ -8,6 +8,7 @@ struct cache_entry; struct attr_source_snapshot; struct clean_status_progress; struct clean_status_proof_epoch; +struct clean_status_index_snapshot; struct lock_file; struct repository; struct stat; @@ -66,6 +67,8 @@ int clean_status_revalidated_token_matches( int clean_status_has_persistent_fsmonitor_semantic_history( const struct index_state *istate); +int clean_status_has_current_full_fsmonitor_proof( + const struct index_state *istate); int clean_status_has_worktree_manifest_history( const struct index_state *istate); int clean_status_fsmonitor_semantic_adoption_needed( @@ -143,6 +146,9 @@ int clean_status_external_history_owns_index( void clean_status_require_external_history_source(struct repository *repo); void clean_status_capture_external_history_source( struct index_state *istate); +int clean_status_capture_external_history_source_from_snapshot( + struct index_state *istate, + const struct clean_status_index_snapshot *snapshot); int clean_status_save_external_history(struct index_state *istate); void clean_status_copy_fsmonitor_history(struct index_state *dst, const struct index_state *src); diff --git a/read-cache-ll.h b/read-cache-ll.h index 242474ce9861f5..34405b6d5e18d3 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -317,6 +317,9 @@ void prefetch_cache_entries(const struct index_state *istate, struct lock_file; int do_read_index(struct index_state *istate, const char *path, int must_exist); /* for testting only! */ +/* Takes ownership of fd, including when the state is already initialized. */ +int do_read_index_from_fd(struct index_state *istate, int fd, + const char *path); int read_index_from(struct index_state *, const char *path, const char *gitdir); int is_index_unborn(struct index_state *); diff --git a/read-cache.c b/read-cache.c index 7e85e45dc75a94..cf13875574f23d 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2466,8 +2466,9 @@ static void set_new_index_sparsity(struct index_state *istate) istate->sparse_index = 1; } -/* remember to discard_cache() before reading a different cache! */ -int do_read_index(struct index_state *istate, const char *path, int must_exist) +/* A nonnegative source_fd is owned by this reader. */ +static int do_read_index_1(struct index_state *istate, const char *path, + int must_exist, int source_fd) { int fd; struct stat st; @@ -2481,12 +2482,15 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) struct index_entry_offset_table *ieot = NULL; clean_status_attach_config(istate); - if (istate->initialized) + if (istate->initialized) { + if (source_fd >= 0) + close(source_fd); return istate->cache_nr; + } istate->timestamp.sec = 0; istate->timestamp.nsec = 0; - fd = git_open_cloexec(path, O_RDONLY); + fd = source_fd >= 0 ? source_fd : git_open_cloexec(path, O_RDONLY); if (fd < 0) { if (!must_exist && errno == ENOENT) { set_new_index_sparsity(istate); @@ -2625,6 +2629,24 @@ int do_read_index(struct index_state *istate, const char *path, int must_exist) die(_("index file corrupt")); } +/* remember to discard_cache() before reading a different cache! */ +int do_read_index(struct index_state *istate, const char *path, int must_exist) +{ + return do_read_index_1(istate, path, must_exist, -1); +} + +int do_read_index_from_fd(struct index_state *istate, int fd, + const char *path) +{ + if (fd < 0) + return -1; + if (istate->initialized) { + close(fd); + return -1; + } + return do_read_index_1(istate, path, 1, fd); +} + /* * Signal that the shared index is used by updating its mtime. * diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 315b2ca3578372..ede5d51d630c0c 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -5628,4 +5628,567 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ) ' +test_lazy_prereq LINUX_SCOPED_HISTORY ' + test "$uname_s" = Linux +' + +test_expect_success LINUX_SCOPED_HISTORY,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'bounded tracked-only status verifies zero-stat entries before omitting history' ' + test_when_finished "rm -rf nondurable-scoped-repair \ + nondurable-scoped-repair-linked" && + test_create_repo nondurable-scoped-repair && + ( + cd nondurable-scoped-repair && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines indexed >cached/tracked && + test_write_lines sibling >cached/sibling && + test_write_lines "* -filter" "*.asset text" >.gitattributes && + test_write_lines ignored >.gitignore && + git add cached/tracked cached/sibling \ + .gitattributes .gitignore && + git commit -qm base && + git worktree add --detach -q \ + ../nondurable-scoped-repair-linked HEAD && + git config index.skipHash true && + git config core.untrackedCache true && + git config core.fsmonitor true && + git config filter.scoped.clean cat && + git config filter.scoped.smudge cat && + git config filter.scoped.required true && + git config status.showUntrackedFiles no && + for worktree in "$PWD" "$PWD/../nondurable-scoped-repair-linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + test_write_lines temporary \ + >"$worktree/cached/tracked" && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + diff -- cached/tracked >"$gitdir/scoped.patch" && + test_write_lines indexed \ + >"$worktree/cached/tracked" && + test-tool chmtime -120 \ + "$worktree/cached/tracked" && + test-tool chmtime -240 \ + "$worktree/cached/sibling" \ + "$worktree/.gitattributes" \ + "$worktree/.gitignore" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=normal \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + test_fsmonitor_full_proof "$gitdir/index" paired && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/forward.trace" \ + git -C "$worktree" apply --cached \ + "$gitdir/scoped.patch" && + test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <"$gitdir/forward.trace" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/reverse.trace" \ + git -C "$worktree" apply --cached --reverse \ + "$gitdir/scoped.patch" && + test_trace2_data fsmonitor \ + apply/untracked-replacement-preserved 1 \ + <"$gitdir/reverse.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + ls-files --debug -- cached/tracked \ + >"$gitdir/zero-stat" && + test_grep "ctime: 0:0" "$gitdir/zero-stat" && + test_grep "mtime: 0:0" "$gitdir/zero-stat" && + test_grep "size: 0" "$gitdir/zero-stat" && + ( + cd "$worktree" && + test-tool dump-fsmonitor + ) >"$gitdir/fsmonitor" && + test_grep "[-]$" "$gitdir/fsmonitor" && + ( + cd "$worktree" && + GIT_CONFIG_PARAMETERS="${SQ}core.fsmonitor=false${SQ}" \ + test-tool dump-untracked-cache + ) >"$gitdir/untracked" && + test_grep "^/ .* valid$" "$gitdir/untracked" && + test_grep "^/cached/ .* valid$" \ + "$gitdir/untracked" && + test_write_lines definitely-dirty \ + >"$worktree/cached/tracked" && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 --untracked-files=no \ + -- cached/tracked >"$gitdir/dirty.expected" && + test_grep "^1 \\.M .* cached/tracked$" \ + "$gitdir/dirty.expected" && + + # Normal-untracked pathspecs must still publish global history. + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/publish.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=normal -- cached/tracked \ + >"$gitdir/publish.actual" && + test_cmp "$gitdir/dirty.expected" \ + "$gitdir/publish.actual" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/publish.trace" && + test_region fsmonitor history_logical_digest \ + "$gitdir/publish.trace" && + find "$gitdir" -maxdepth 1 -type f \ + -name "index.csh1.*" >"$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/checkpoints" && + checkpoint=$(cat "$gitdir/checkpoints") && + cat >"$gitdir/retoken.pl" <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $rawsz = $ARGV[0] eq "sha256" ? 32 : 20; + for my $name ("FSMN", "FSUC", "FSCF") { + my $at = index($index, $name); + die "missing $name" if $at < 0; + my $size = unpack("N", substr($index, $at + 4, 4)); + my $payload = substr($index, $at + 8, $size); + my $count = ($payload =~ + s/builtin:test:[0-9]/builtin:test:3/g); + die "unexpected $name token" unless $count == 1; + if ($name eq "FSCF") { + my $proof = substr($payload, 0, -$rawsz); + my $checksum = $rawsz == 32 ? + sha256($proof) : sha1($proof); + substr($payload, -$rawsz, $rawsz, + $checksum); + } + substr($index, $at + 8, $size, $payload); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, "\0" x $rawsz; + EOF + perl "$gitdir/retoken.pl" "$(test_oid algo)" \ + <"$gitdir/index" >"$gitdir/index.retoken" && + mv "$gitdir/index.retoken" "$gitdir/index" && + test_fsmonitor_full_proof "$gitdir/index" paired \ + "builtin:test:3" && + test_trailing_hash "$gitdir/index" \ + >"$gitdir/initial-zero.hash" && + test_oid zero >"$gitdir/zero.expected" && + test_cmp "$gitdir/zero.expected" \ + "$gitdir/initial-zero.hash" && + cp "$gitdir/index" "$gitdir/zero.index" && + cp "$checkpoint" "$gitdir/checkpoint.snapshot" && + + for attempt in first second + do + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$attempt.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/$attempt.actual" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$attempt.trace" && + ! test_trace2_data fsmonitor config/invalid-extension 1 \ + <"$gitdir/$attempt.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$attempt.trace" && + test_cmp "$gitdir/dirty.expected" \ + "$gitdir/$attempt.actual" && + test_cmp_bin "$gitdir/zero.index" \ + "$gitdir/index" && + test_cmp_bin "$gitdir/checkpoint.snapshot" \ + "$checkpoint" && + test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/$attempt.trace" && + test_trace2_data fsmonitor \ + history/scoped-source-capture-skipped 1 \ + <"$gitdir/$attempt.trace" && + test_trace2_data fsmonitor config/token-advanced 1 \ + <"$gitdir/$attempt.trace" && + test_region ! fsmonitor history_logical_digest \ + "$gitdir/$attempt.trace" && + test_region ! index do_write_index \ + "$gitdir/$attempt.trace" || return 1 + done && + + # A zero-stat clean entry must still be physically repaired. + test_write_lines indexed \ + >"$worktree/cached/tracked" && + test-tool chmtime =-60 \ + "$worktree/cached/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/repair.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no -- cached/tracked \ + >"$gitdir/repair.actual" && + test_must_be_empty "$gitdir/repair.actual" && + test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/repair.trace" && + test_trace2_data fsmonitor \ + history/scoped-source-repair-required 1 \ + <"$gitdir/repair.trace" && + test_trace2_data fsmonitor \ + history/scoped-original-source-restored 1 \ + <"$gitdir/repair.trace" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/repair.trace" && + test_region fsmonitor history_logical_digest \ + "$gitdir/repair.trace" && + test_region index do_write_index \ + "$gitdir/repair.trace" && + ! test_cmp_bin "$gitdir/zero.index" \ + "$gitdir/index" && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + ls-files --debug -- cached/tracked \ + >"$gitdir/repaired-stat" && + test_grep ! "size: 0" "$gitdir/repaired-stat" && + test_fsmonitor_full_proof "$gitdir/index" paired && + + # That repaired source still survives a subsequent foreign writer. + cp "$gitdir/index" "$gitdir/repaired.index" && + cp "$checkpoint" "$gitdir/repaired.checkpoint" && + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + update-index --force-write-index && + test_grep ! FSUC "$gitdir/index" && + cp "$gitdir/index" "$gitdir/foreign-stripped.index" && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/follower.expected" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/follower.trace" \ + git --no-optional-locks -C "$worktree" \ + status --porcelain=v2 --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/follower.actual" && + test_cmp "$gitdir/follower.expected" \ + "$gitdir/follower.actual" && + test_cmp_bin "$gitdir/foreign-stripped.index" \ + "$gitdir/index" && + test_cmp_bin "$gitdir/repaired.checkpoint" \ + "$checkpoint" && + test_trace2_data fsmonitor history/external-restored 1 \ + <"$gitdir/follower.trace" && + test_region ! index do_write_index \ + "$gitdir/follower.trace" && + cp "$gitdir/repaired.index" "$gitdir/index" && + + # Neither a selected nor an unrelated racy CE may lose its write. + selected_mtime=$(test-tool chmtime --get \ + "$worktree/cached/tracked") && + test-tool chmtime =$selected_mtime "$gitdir/index" && + cp "$gitdir/index" "$gitdir/selected-racy.before" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/selected-racy.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no -- cached/tracked \ + >"$gitdir/selected-racy.actual" && + test_must_be_empty "$gitdir/selected-racy.actual" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-skipped 1 \ + <"$gitdir/selected-racy.trace" && + test_trace2_data fsmonitor history/external-save-reject \ + racy-index \ + <"$gitdir/selected-racy.trace" && + test_region index do_write_index \ + "$gitdir/selected-racy.trace" && + test-tool chmtime =-30 \ + "$worktree/cached/sibling" && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/sibling \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=normal \ + >"$gitdir/sibling-refresh.actual" && + test_must_be_empty "$gitdir/sibling-refresh.actual" && + test_fsmonitor_full_proof "$gitdir/index" paired && + sibling_mtime=$(test-tool chmtime --get \ + "$worktree/cached/sibling") && + test-tool chmtime =$sibling_mtime "$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/unselected-racy.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no -- cached/tracked \ + >"$gitdir/unselected-racy.actual" && + test_must_be_empty "$gitdir/unselected-racy.actual" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-skipped 1 \ + <"$gitdir/unselected-racy.trace" && + test_trace2_data fsmonitor history/external-save-reject \ + racy-index \ + <"$gitdir/unselected-racy.trace" && + test_region index do_write_index \ + "$gitdir/unselected-racy.trace" && + + # Dirt that disappears during refresh must take the repair path. + if test_have_prereq PIPE + then + cp "$gitdir/zero.index" "$gitdir/index" && + test_write_lines race-dirty \ + >"$worktree/cached/tracked" && + ready="$gitdir/dirty-clean.ready" && + resume="$gitdir/dirty-clean.resume" && + fsmonitor_query_pid= && + trap cleanup_fsmonitor_query_barrier 0 && + mkfifo "$resume" && + { + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TEST_CLEAN_STATUS_SCOPED_HISTORY_BARRIER_READY="$ready" \ + GIT_TEST_CLEAN_STATUS_SCOPED_HISTORY_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$gitdir/dirty-clean.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/dirty-clean.actual" \ + 2>"$gitdir/dirty-clean.err" & + fsmonitor_query_pid=$! + } && + wait_for_fsmonitor_query_barrier \ + "$ready" "$fsmonitor_query_pid" && + test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/dirty-clean.trace" && + test_write_lines indexed \ + >"$worktree/cached/tracked" && + test-tool chmtime =-60 \ + "$worktree/cached/tracked" && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/dirty-clean.expected" && + printf x >"$resume" && + wait "$fsmonitor_query_pid" && + fsmonitor_query_pid= && + trap - 0 && + test_cmp "$gitdir/dirty-clean.expected" \ + "$gitdir/dirty-clean.actual" && + test_trace2_data fsmonitor \ + history/scoped-source-repair-required 1 \ + <"$gitdir/dirty-clean.trace" && + test_trace2_data fsmonitor \ + history/scoped-original-source-restored 1 \ + <"$gitdir/dirty-clean.trace" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/dirty-clean.trace" && + test_region index do_write_index \ + "$gitdir/dirty-clean.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired + else + : + fi && + + # Root equivalents and implicit -uno retain the original writer. + for scope in root root-dot root-magic wildcard implicit + do + case "$scope" in + root) + set -- --untracked-files=no + ;; + root-dot) + set -- --untracked-files=no -- . + ;; + root-magic) + set -- --untracked-files=no -- :/ + ;; + wildcard) + set -- --untracked-files=no -- "cached/*" + ;; + implicit) + set -- -- cached/tracked + ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$scope.trace" \ + git -C "$worktree" status --porcelain=v2 \ + "$@" >"$gitdir/$scope.actual" && + test_must_be_empty "$gitdir/$scope.actual" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/$scope.trace" && + test_region fsmonitor history_logical_digest \ + "$gitdir/$scope.trace" || return 1 + done && + + # A bounded literal must not select an attribute or exclude source. + for source in .gitattributes .gitignore + do + case "$source" in + .gitattributes) + label=attributes + ;; + .gitignore) + label=ignore + ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$label.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no -- "$source" \ + >"$gitdir/$label.actual" && + test_must_be_empty "$gitdir/$label.actual" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/$label.trace" || return 1 + done && + + # A mismatched config and an actual provider delta fail closed. + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/config.trace" \ + git -C "$worktree" -c core.autocrlf=true \ + status --porcelain=v2 \ + --untracked-files=no -- cached/tracked \ + >"$gitdir/config.actual" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/config.trace" && + cp "$gitdir/zero.index" "$gitdir/index" && + test_write_lines provider-dirty \ + >"$worktree/cached/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/tracked \ + GIT_TRACE2_EVENT="$gitdir/provider.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no -- cached/tracked \ + >"$gitdir/provider.actual" && + test_grep "^1 \\.M .* cached/tracked$" \ + "$gitdir/provider.actual" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-skipped 1 \ + <"$gitdir/provider.trace" && + + # An active clean filter cannot enter this lane. + test_write_lines "cached/tracked filter=scoped" \ + >"$worktree/.gitattributes" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + git -C "$worktree" add -- .gitattributes && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=normal \ + >"$gitdir/active-prime.actual" && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/active.expected" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/active.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no -- cached/tracked \ + >"$gitdir/active.actual" && + test_cmp "$gitdir/active.expected" \ + "$gitdir/active.actual" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/active.trace" && + + # A subsequently failing required filter cannot be bypassed. + cp "$gitdir/index" "$gitdir/filter.before" && + test_must_fail env \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$gitdir/filter.trace" \ + git -C "$worktree" \ + -c filter.scoped.clean=false \ + -c filter.scoped.smudge=cat \ + -c filter.scoped.required=true \ + status --porcelain=v2 --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/filter.actual" \ + 2>"$gitdir/filter.err" && + test_grep "clean filter .scoped. failed" \ + "$gitdir/filter.err" && + test_cmp_bin "$gitdir/filter.before" "$gitdir/index" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-skipped 1 \ + <"$gitdir/filter.trace" && + + # A competing physical writer must never be overwritten. + if test_have_prereq PIPE + then + test_write_lines "* -filter" "*.asset text" \ + >"$worktree/.gitattributes" && + cp "$gitdir/zero.index" "$gitdir/index" && + test_write_lines race-dirty \ + >"$worktree/cached/tracked" && + ready="$gitdir/foreign.ready" && + resume="$gitdir/foreign.resume" && + fsmonitor_query_pid= && + trap cleanup_fsmonitor_query_barrier 0 && + mkfifo "$resume" && + { + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TEST_CLEAN_STATUS_SCOPED_HISTORY_BARRIER_READY="$ready" \ + GIT_TEST_CLEAN_STATUS_SCOPED_HISTORY_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$gitdir/foreign.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no \ + -- cached/tracked \ + >"$gitdir/foreign.actual" \ + 2>"$gitdir/foreign.err" & + fsmonitor_query_pid=$! + } && + wait_for_fsmonitor_query_barrier \ + "$ready" "$fsmonitor_query_pid" && + test_trace2_data fsmonitor \ + history/scoped-source-capture-deferred 1 \ + <"$gitdir/foreign.trace" && + test_path_is_missing "$gitdir/index.lock" && + test_write_lines competing \ + >"$worktree/cached/sibling" && + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + add cached/sibling && + cp "$gitdir/index" "$gitdir/foreign.index" && + test_trailing_hash "$gitdir/foreign.index" \ + >"$gitdir/foreign.zero" && + test_cmp "$gitdir/zero.expected" \ + "$gitdir/foreign.zero" && + printf x >"$resume" && + wait "$fsmonitor_query_pid" && + fsmonitor_query_pid= && + trap - 0 && + test_cmp_bin "$gitdir/foreign.index" \ + "$gitdir/index" && + ! test_trace2_data fsmonitor \ + history/scoped-source-capture-skipped 1 \ + <"$gitdir/foreign.trace" && + test_trace2_data fsmonitor \ + history/scoped-source-epoch-mismatch 1 \ + <"$gitdir/foreign.trace" && + ! test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/foreign.trace" && + test_region ! index do_write_index \ + "$gitdir/foreign.trace" + else + : + fi || return 1 + done + ) +' + test_done From aaa01b4ec19c0c269d0af240cdbe0a01d128b491 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 03:21:27 -0500 Subject: [PATCH 354/403] clean-status: release the raw fsmonitor shadow bitmap In cb6cbd4f5e (status: defer Linux scoped history hashing until a repair, 2026-08-17), the repair path began reading the pinned original index into a temporary index_state. This raw read deliberately skips post_read_index_from(), so it does not run tweak_fsmonitor(), which would normally consume and free the FSMN dirty bitmap. release_index() does not own that bitmap. Consequently, reading an FSMN extension into the temporary index leaks both the EWAH structure and its buffer. The Linux LeakSanitizer jobs report 56 bytes per capture from the existing zero-stat repair test in t7519. Free and clear the temporary bitmap at the common cleanup label before releasing the index, as the other parsed-index shadows in this file already do. This covers failed parses and successful captures without changing the proof, pinned descriptor, or index-write decisions. --- clean-status-history.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clean-status-history.c b/clean-status-history.c index f3d5041f16853c..8dcd0760909065 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -625,6 +625,9 @@ int clean_status_capture_external_history_source_from_snapshot( captured = 0; done: + if (original.fsmonitor_dirty) + ewah_free(original.fsmonitor_dirty); + original.fsmonitor_dirty = NULL; release_index(&original); return captured; #else From d313b5f240ee3f224899ad77bb38ab4c19e70982 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:21:01 -0500 Subject: [PATCH 355/403] commit: restore external proofs before preparing the index A foreign index writer can preserve FSMN and UNTR while removing the paired FSUC and FSCF extensions. Commit reads that index before it has enabled external history, so an authenticated checkpoint cannot prevent another worktree-manifest scan. Partial commits also discard the proof when constructing their temporary base index. Enable external history before the first index read for an ordinary worktree using reliable stat data and builtin fsmonitor. Carry that history into a partial commit's base index only when its provider token has already been revalidated. Alternate indexes retain their existing behavior. Exercise all, include, amend, and only commits in main and linked worktrees after removing the paired physical extensions. Require the external proof to survive without another manifest scan, and retain a changed-attributes control which must invalidate it. --- builtin/commit.c | 6 ++ t/t7519-status-fsmonitor.sh | 147 ++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/builtin/commit.c b/builtin/commit.c index 1d0f21138f8ca0..1de778261812d5 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -358,6 +358,9 @@ static void create_base_index(const struct commit *current_head) opts.head_idx = 1; opts.index_only = 1; opts.merge = 1; + opts.preserve_semantic_history = + clean_status_external_history_enabled(the_repository->index) && + clean_status_revalidated_token_matches(the_repository->index); opts.src_index = the_repository->index; opts.dst_index = the_repository->index; @@ -2368,6 +2371,9 @@ int cmd_commit(int argc, &s, git_commit_config, &clean_digest); clean_status_config_final(&clean_digest); clean_status_set_config_digest(the_repository, &clean_digest); + if (!getenv(INDEX_ENVIRONMENT) && fstat_is_reliable() && + fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC) + clean_status_enable_external_history(the_repository); s.commit_template = 1; status_format = STATUS_FORMAT_NONE; /* Ignore status.short */ s.colopts = 0; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index ede5d51d630c0c..a253bd28ce6302 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1569,6 +1569,153 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'commit modes restore external-only worktree proofs' ' + test_when_finished "rm -rf commit-external-only commit-external-linked" && + test_create_repo commit-external-only && + ( + cd commit-external-only && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines "tracked -text" >.gitattributes && + git add .gitattributes && + git commit -qm attributes && + git worktree add --detach ../commit-external-linked HEAD && + test-tool chmtime -120 tracked .gitattributes \ + ../commit-external-linked/tracked \ + ../commit-external-linked/.gitattributes && + git update-index --refresh && + git -C ../commit-external-linked update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + cat >.git/remove-paired-proofs.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $rawsz = $ARGV[0] eq "sha256" ? 32 : 20; + for my $name ("FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, $rawsz == 32 ? sha256($payload) : sha1($payload); + EOF + for worktree in "$PWD" "$PWD/../commit-external-linked" + do + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + for mode in all include amend only attributes + do + test-tool chmtime -120 "$worktree/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git -C "$worktree" update-index --refresh && + rm -f "$gitdir"/index.csh1.* "$gitdir"/index.cswi.* && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$mode.prime" && + test_must_be_empty "$gitdir/$mode.prime" && + test_fsmonitor_full_proof "$gitdir/index" paired && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkpoint.trace" \ + git -C "$worktree" status --short \ + >"$gitdir/$mode.checkpoint" && + test_must_be_empty "$gitdir/$mode.checkpoint" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/$mode.checkpoint.trace" && + find "$gitdir" -maxdepth 1 -type f \ + -name "index.csh1.*" >"$gitdir/$mode.csh" && + test_line_count = 1 "$gitdir/$mode.csh" && + perl "$PWD/.git/remove-paired-proofs.pl" \ + "$(test_oid algo)" <"$gitdir/index" \ + >"$gitdir/index.foreign" && + mv "$gitdir/index.foreign" "$gitdir/index" && + test_grep FSMN "$gitdir/index" && + test_grep ! FSUC "$gitdir/index" && + test_grep ! FSCF "$gitdir/index" && + query=DDDDCCCCCCCCCCCC && + case "$mode" in + all) + test_write_lines all >"$worktree/tracked" && + path=tracked && + set -- -a -qm commit-all + ;; + include) + test_write_lines include >"$worktree/tracked" && + path=tracked && + set -- --include tracked -qm commit-include + ;; + amend) + test_write_lines amend >"$worktree/tracked" && + path=tracked && + set -- -a --amend --no-edit -q + ;; + only) + test_write_lines only >"$worktree/tracked" && + path=tracked && + query=DDDDDDCCCCCCCCCCCC && + set -- --only tracked -qm commit-only + ;; + attributes) + test_write_lines "tracked text" \ + >"$worktree/.gitattributes" && + path=.gitattributes && + set -- -a -qm changed-attributes + ;; + esac && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE="$query" \ + GIT_TEST_FSMONITOR_QUERY_PATH="$path" \ + GIT_TRACE2_EVENT="$gitdir/$mode.commit.trace" \ + git -C "$worktree" commit "$@" && + test_trace2_data fsmonitor history/external-restored 1 \ + <"$gitdir/$mode.commit.trace" && + if test "$mode" = attributes + then + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/$mode.commit.trace" && + test_trace2_data fsmonitor semantic/manifest-invalidated 1 \ + <"$gitdir/$mode.commit.trace" && + test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/$mode.commit.trace" + else + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/$mode.commit.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/$mode.commit.trace" && + if test "$mode" = only + then + test_trace2_data fsmonitor history/untracked-paired-transfer 1 \ + <"$gitdir/$mode.commit.trace" || return 1 + fi && + test_fsmonitor_full_proof "$gitdir/index" paired + fi && + cp "$gitdir/index" "$gitdir/$mode.before-status" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$mode.status" && + test_must_be_empty "$gitdir/$mode.status" && + test_cmp_bin "$gitdir/$mode.before-status" "$gitdir/index" && + if test "$mode" != attributes + then + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$mode.status.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$gitdir/$mode.status.trace" || return 1 + fi || return 1 + done || return 1 + done + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'write-tree preserves authenticated primary and linked index proofs' ' test_when_finished "rm -rf write-tree-bound-proof write-tree-linked" && From 88f1686b27819d8bc70f13fabb22f612f4694292 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:29:19 -0500 Subject: [PATCH 356/403] fsmonitor: recognize equivalent index-only reader requests d079f66b83 (fsmonitor: avoid repository-wide bootstrap for bounded readers, 2026-08-16) lets selected readers use ordinary stat checks when the index has only a partial semantic proof. Its argument checks miss equivalent check-attr and ls-files syntax, and the diff plumbing never opts in. These commands can rebuild the whole worktree manifest before answering an index-only or single-path request. Decide from the parsed operation instead. Pure index-name and stage output does not depend on fsmonitor validity bits, so ls-files can also accept deleted paths, globs, and an unrestricted index listing. Keep worktree, attribute, tag, debug, and other state-sensitive modes out. Use check-attr's parsed filename boundary and share porcelain diff's existing regular-path restriction with diff-files and diff-index. Extend the partial-proof regression with equivalent spellings, index-only and plumbing forms, and unsupported-mode controls. Both formats reproduce the old manifest scan and pass with the expanded admission. --- builtin/check-attr.c | 29 ++++++------- builtin/diff-files.c | 8 ++++ builtin/diff-index.c | 15 ++++++- builtin/diff.c | 25 ----------- builtin/ls-files.c | 37 ++++++---------- diff-lib.c | 24 +++++++++++ diff.h | 1 + t/t7534-status-scoped-readers.sh | 72 ++++++++++++++++++++++++++++++++ 8 files changed, 144 insertions(+), 67 deletions(-) diff --git a/builtin/check-attr.c b/builtin/check-attr.c index f7083585fbfa95..d000e8d0f221dc 100644 --- a/builtin/check-attr.c +++ b/builtin/check-attr.c @@ -129,23 +129,6 @@ int cmd_check_attr(int argc, prepare_repo_settings(the_repository); the_repository->settings.command_requires_full_index = 0; - if (!stdin_paths && !source) { - for (i = 0; i < argc && strcmp(argv[i], "--"); i++) - ; - scoped_bootstrap = i < argc && - argc - i - 1 > 0 && argc - i - 1 <= 64; - } - if (scoped_bootstrap) - fsmonitor_begin_scoped_bootstrap(the_repository->index); - if (repo_read_index(the_repository) < 0) { - die("invalid cache"); - } - if (scoped_bootstrap) - fsmonitor_end_scoped_bootstrap(the_repository->index); - - if (cached_attrs) - git_attr_set_direction(GIT_ATTR_INDEX); - doubledash = -1; for (i = 0; doubledash < 0 && i < argc; i++) { if (!strcmp(argv[i], "--")) @@ -188,6 +171,18 @@ int cmd_check_attr(int argc, error_with_usage("No file specified"); } + scoped_bootstrap = !stdin_paths && !source && + argc - filei > 0 && argc - filei <= 64; + if (scoped_bootstrap) + fsmonitor_begin_scoped_bootstrap(the_repository->index); + if (repo_read_index(the_repository) < 0) + die("invalid cache"); + if (scoped_bootstrap) + fsmonitor_end_scoped_bootstrap(the_repository->index); + + if (cached_attrs) + git_attr_set_direction(GIT_ATTR_INDEX); + check = attr_check_alloc(); if (!all_attrs) { for (i = 0; i < cnt; i++) { diff --git a/builtin/diff-files.c b/builtin/diff-files.c index 0de2094ca2a62d..267bbe3a41e6b8 100644 --- a/builtin/diff-files.c +++ b/builtin/diff-files.c @@ -12,6 +12,7 @@ #include "diff.h" #include "diff-merges.h" #include "commit.h" +#include "fsmonitor.h" #include "preload-index.h" #include "revision.h" @@ -27,6 +28,7 @@ int cmd_diff_files(int argc, { struct rev_info rev; int result; + int scoped_bootstrap; unsigned options = 0; show_usage_if_asked(argc, argv, diff_files_usage); @@ -85,8 +87,14 @@ int cmd_diff_files(int argc, diff_merges_set_dense_combined_if_unset(&rev); prepare_diff_external_history(the_repository); + scoped_bootstrap = + diff_has_bounded_regular_pathspec(&rev.diffopt.pathspec); + if (scoped_bootstrap) + fsmonitor_begin_scoped_bootstrap(the_repository->index); if (repo_read_index_preload(the_repository, &rev.diffopt.pathspec, 0) < 0) die_errno("repo_read_index_preload"); + if (scoped_bootstrap) + fsmonitor_end_scoped_bootstrap(the_repository->index); run_diff_files(&rev, options); result = diff_result_code(&rev); release_revisions(&rev); diff --git a/builtin/diff-index.c b/builtin/diff-index.c index 880a12d34b258f..701e900f78086f 100644 --- a/builtin/diff-index.c +++ b/builtin/diff-index.c @@ -6,6 +6,7 @@ #include "diff.h" #include "diff-merges.h" #include "commit.h" +#include "fsmonitor.h" #include "preload-index.h" #include "revision.h" #include "setup.h" @@ -25,6 +26,7 @@ int cmd_diff_index(int argc, unsigned int option = 0; int i; int result; + int scoped_bootstrap; show_usage_if_asked(argc, argv, diff_cache_usage); @@ -69,16 +71,27 @@ int cmd_diff_index(int argc, rev.max_count != -1 || rev.min_age != -1 || rev.max_age != -1) usage(diff_cache_usage); prepare_diff_external_history(the_repository); - if (!(option & DIFF_INDEX_CACHED)) { + if (!(option & DIFF_INDEX_CACHED)) setup_work_tree(the_repository); + scoped_bootstrap = (option & DIFF_INDEX_CACHED) || + diff_has_bounded_regular_pathspec(&rev.diffopt.pathspec); + if (scoped_bootstrap) + fsmonitor_begin_scoped_bootstrap(the_repository->index); + if (!(option & DIFF_INDEX_CACHED)) { if (repo_read_index_preload(the_repository, &rev.diffopt.pathspec, 0) < 0) { + if (scoped_bootstrap) + fsmonitor_end_scoped_bootstrap(the_repository->index); perror("repo_read_index_preload"); return -1; } } else if (repo_read_index(the_repository) < 0) { + if (scoped_bootstrap) + fsmonitor_end_scoped_bootstrap(the_repository->index); perror("repo_read_index"); return -1; } + if (scoped_bootstrap) + fsmonitor_end_scoped_bootstrap(the_repository->index); run_diff_index(&rev, option); result = diff_result_code(&rev); release_revisions(&rev); diff --git a/builtin/diff.c b/builtin/diff.c index 643a4925e12fbd..4d983985e00bd6 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -33,7 +33,6 @@ #include "revision.h" #include "log-tree.h" #include "setup.h" -#include "symlinks.h" #include "thread-utils.h" #include "oid-array.h" #include "tree.h" @@ -55,30 +54,6 @@ COMMON_DIFF_OPTIONS_HELP; static int scoped_diff_bootstrap_used; -static int diff_has_bounded_regular_pathspec(const struct pathspec *pathspec) -{ - int i; - - if (pathspec->nr <= 0 || pathspec->nr > 64 || - pathspec->has_wildcard || - (pathspec->magic & - (PATHSPEC_GLOB | PATHSPEC_ICASE | - PATHSPEC_EXCLUDE | PATHSPEC_ATTR))) - return 0; - for (i = 0; i < pathspec->nr; i++) { - const struct pathspec_item *item = &pathspec->items[i]; - struct stat st; - - if (!item->match || item->len <= 0 || - item->match[item->len - 1] == '/' || - !strcmp(item->match, ".") || - has_symlink_leading_path(item->match, item->len) || - lstat(item->match, &st) || !S_ISREG(st.st_mode)) - return 0; - } - return 1; -} - static const char *blob_path(struct object_array_entry *entry) { return entry->path ? entry->path : entry->name; diff --git a/builtin/ls-files.c b/builtin/ls-files.c index 5c2656c43f5fe1..2d5b6bbade54e9 100644 --- a/builtin/ls-files.c +++ b/builtin/ls-files.c @@ -588,25 +588,17 @@ static int option_parse_exclude_standard(const struct option *opt, return 0; } -static int ls_files_has_bounded_stage_request(int argc, const char **argv) +static int ls_files_is_index_only(const struct dir_struct *dir, int show_tag) { - int i; - int stage = 0; - - for (i = 1; i < argc && strcmp(argv[i], "--"); i++) { - if (!strcmp(argv[i], "--stage") || !strcmp(argv[i], "-s")) - stage = 1; - else if (strcmp(argv[i], "-z")) - return 0; - } - if (!stage || i == argc || argc - i - 1 <= 0 || - argc - i - 1 > 64) + if (show_deleted || show_others || show_unmerged || + show_resolve_undo || show_modified || show_killed || + show_valid_bit || show_fsmonitor_bit || show_eol || + recurse_submodules || show_tag || debug_mode || + with_tree || format || exc_given || dir->exclude_per_dir || + (dir->flags & DIR_SHOW_IGNORED) || + (pathspec.magic & PATHSPEC_ATTR)) return 0; - for (i++; i < argc; i++) { - if (!*argv[i] || starts_with(argv[i], ":(") || - strpbrk(argv[i], "*?[")) - return 0; - } + return 1; } @@ -702,7 +694,10 @@ int cmd_ls_files(int argc, prefix_len = strlen(prefix); repo_config(repo, git_default_config, NULL); - scoped_bootstrap = ls_files_has_bounded_stage_request(argc, argv); + argc = parse_options(argc, argv, prefix, builtin_ls_files_options, + ls_files_usage, 0); + parse_pathspec(&pathspec, 0, PATHSPEC_PREFER_CWD, prefix, argv); + scoped_bootstrap = ls_files_is_index_only(&dir, show_tag); if (scoped_bootstrap) fsmonitor_begin_scoped_bootstrap(repo->index); if (repo_read_index(repo) < 0) @@ -710,8 +705,6 @@ int cmd_ls_files(int argc, if (scoped_bootstrap) fsmonitor_end_scoped_bootstrap(repo->index); - argc = parse_options(argc, argv, prefix, builtin_ls_files_options, - ls_files_usage, 0); pl = add_pattern_list(&dir, EXC_CMDL, "--exclude option"); for (i = 0; i < exclude_list.nr; i++) { add_pattern(exclude_list.items[i].string, "", 0, pl, --exclude_args); @@ -758,10 +751,6 @@ int cmd_ls_files(int argc, die("ls-files --recurse-submodules does not support " "--error-unmatch"); - parse_pathspec(&pathspec, 0, - PATHSPEC_PREFER_CWD, - prefix, argv); - /* * Find common prefix for all pathspec's * This is used as a performance optimization which unfortunately cannot diff --git a/diff-lib.c b/diff-lib.c index 8f8aba3038a8a6..c6175021c81f9f 100644 --- a/diff-lib.c +++ b/diff-lib.c @@ -30,6 +30,30 @@ * diff-files */ +int diff_has_bounded_regular_pathspec(const struct pathspec *pathspec) +{ + int i; + + if (pathspec->nr <= 0 || pathspec->nr > 64 || + pathspec->has_wildcard || + (pathspec->magic & + (PATHSPEC_GLOB | PATHSPEC_ICASE | + PATHSPEC_EXCLUDE | PATHSPEC_ATTR))) + return 0; + for (i = 0; i < pathspec->nr; i++) { + const struct pathspec_item *item = &pathspec->items[i]; + struct stat st; + + if (!item->match || item->len <= 0 || + item->match[item->len - 1] == '/' || + !strcmp(item->match, ".") || + has_symlink_leading_path(item->match, item->len) || + lstat(item->match, &st) || !S_ISREG(st.st_mode)) + return 0; + } + return 1; +} + /* * Has the work tree entity been removed? * diff --git a/diff.h b/diff.h index eb81289415f8f3..cc2b4e00ecc8d8 100644 --- a/diff.h +++ b/diff.h @@ -701,6 +701,7 @@ void diff_get_merge_base(const struct rev_info *revs, struct object_id *mb); /* update index stat data for content-checked entries */ #define DIFF_UPDATE_INDEX_STAT 04 void run_diff_files(struct rev_info *revs, unsigned int option); +int diff_has_bounded_regular_pathspec(const struct pathspec *pathspec); #define DIFF_INDEX_CACHED 01 #define DIFF_INDEX_MERGE_BASE 02 diff --git a/t/t7534-status-scoped-readers.sh b/t/t7534-status-scoped-readers.sh index d4451eba636c2d..5797a16674442c 100755 --- a/t/t7534-status-scoped-readers.sh +++ b/t/t7534-status-scoped-readers.sh @@ -105,6 +105,22 @@ assert_scoped_reader () { test_cmp_bin "$gitdir/checkpoint.pristine" "$scoped_checkpoint" } +assert_unscoped_reader () { + scoped_label=$1 && + shift && + cp "$gitdir/index" "$gitdir/$scoped_label.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$scoped_label.trace" \ + git -C "$worktree" "$@" \ + >"$gitdir/$scoped_label.actual" && + ! test_trace2_data fsmonitor \ + semantic/scoped-reader-stat-fallback 1 \ + <"$gitdir/$scoped_label.trace" && + test_cmp_bin "$gitdir/$scoped_label.index" "$gitdir/index" && + test_cmp_bin "$gitdir/checkpoint.pristine" "$scoped_checkpoint" +} + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ 'bounded physical-index readers reject incomplete history without a manifest' ' test_when_finished "rm -rf scoped-readers scoped-readers-linked" && @@ -165,6 +181,24 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP assert_scoped_reader worktree-default 1 \ diff --no-ext-diff --no-textconv -- tracked && test_must_be_empty "$gitdir/worktree-default.actual" && + assert_scoped_reader plumbing-files-readonly 0 \ + diff-files --no-ext-diff --no-textconv -- tracked && + assert_scoped_reader plumbing-files-default 1 \ + diff-files --no-ext-diff --no-textconv -- tracked && + assert_scoped_reader plumbing-index-readonly 0 \ + diff-index --no-ext-diff --no-textconv HEAD -- tracked && + assert_scoped_reader plumbing-index-default 1 \ + diff-index --no-ext-diff --no-textconv HEAD -- tracked && + assert_scoped_reader plumbing-cached-readonly 0 \ + diff-index --cached --name-only -z HEAD -- && + assert_scoped_reader plumbing-cached-default 1 \ + diff-index --cached --name-only -z HEAD -- && + assert_unscoped_reader plumbing-files-root \ + diff-files --no-ext-diff --no-textconv -- . && + assert_unscoped_reader plumbing-index-root \ + diff-index --no-ext-diff --no-textconv HEAD -- . && + assert_unscoped_reader plumbing-files-wildcard \ + diff-files --no-ext-diff --no-textconv -- "track*" && if test "$worktree" != "$PWD" then cp "$gitdir/index" "$gitdir/partial.saved" && @@ -189,12 +223,50 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP test_cmp_bin "$gitdir/private.index" "$gitdir/index" && assert_scoped_reader attributes 0 \ check-attr -a -- tracked sibling && + assert_scoped_reader implicit-all-attributes 0 \ + check-attr -a tracked sibling && + assert_scoped_reader named-attributes 0 \ + check-attr text tracked sibling && + assert_scoped_reader literal-pattern-attributes 0 \ + check-attr text "tracked*" && + assert_scoped_reader absent-path-attributes 0 \ + check-attr text missing && assert_scoped_reader cached-attributes 0 \ check-attr --cached -a -- tracked && + assert_scoped_reader cached-implicit-attributes 0 \ + check-attr --cached text tracked && + assert_unscoped_reader source-attributes \ + check-attr --source=HEAD -a -- tracked && assert_scoped_reader index-stage-readonly 0 \ ls-files --stage -- tracked staged && assert_scoped_reader index-stage-default 1 \ ls-files --stage -- tracked staged && + assert_scoped_reader index-stage-combined 0 \ + ls-files -sz tracked staged && + assert_scoped_reader index-stage-abbreviated 0 \ + ls-files --stage --abbrev=12 tracked staged && + assert_scoped_reader index-stage-literal 0 \ + ls-files --stage -- ":(literal)tracked" && + assert_scoped_reader index-stage-wildcard 0 \ + ls-files --stage -- "track*" && + assert_scoped_reader index-stage-root 0 \ + ls-files --stage -- . && + assert_scoped_reader index-cached 0 \ + ls-files --cached -- tracked staged && + assert_scoped_reader index-cached-all 0 \ + ls-files --cached && + assert_scoped_reader index-default-all 1 \ + ls-files && + rm "$worktree/staged" && + assert_scoped_reader index-deleted-stage 0 \ + ls-files --stage -- staged && + test_write_lines staged >"$worktree/staged" && + assert_unscoped_reader index-debug \ + ls-files --debug -- tracked && + assert_unscoped_reader index-format \ + ls-files --format="%(path)" -- tracked && + assert_unscoped_reader index-attribute-pathspec \ + ls-files -- ":(attr:text)tracked" && GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/fsmonitor-mode.trace" \ From 81eaddb26844301eb7728f8cb8b68659bcecd68d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:40:31 -0500 Subject: [PATCH 357/403] exclude: tolerate sibling churn around a pinned regular source An external excludes proof pins its parent and source file, but compares the parent's complete stat data when it reopens the path. Creating an unrelated sibling changes the parent's timestamps or link count and rejects an otherwise unchanged regular file. A busy parent can therefore force another repository-wide metadata scan. For a regular source held open throughout verification, compare the parent using the existing directory-namespace identity instead. Recheck the held source and its anchored pathname after reopening the parent; the source's full identity and content checks remain unchanged. Keep the stricter parent comparison for absent and nonregular sources. In particular, an absent path appearing and disappearing between two lookups must not be mistaken for harmless sibling churn. Deterministic unit tests cover both benign regular-file races and hostile parent, target, permission, symlink, and absence transitions. --- exclude-source-proof.c | 31 ++- path-namespace.c | 12 + path-namespace.h | 2 + t/unit-tests/u-exclude-source-proof.c | 306 +++++++++++++++++++++++++- t/unit-tests/u-path-namespace.c | 46 ++++ 5 files changed, 386 insertions(+), 11 deletions(-) diff --git a/exclude-source-proof.c b/exclude-source-proof.c index 8dba2273540a4a..c46452b35c209a 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -113,25 +113,34 @@ static int open_source_at(int parent_fd, const char *relative, int nofollow, static int parent_identity_stable( struct exclude_source_proof *proof, const char *parent, - int held_fd, const struct stat *expected) + int held_fd, const struct stat *expected, int regular_source) { struct stat held, reopened; int fd = proof->open_parent(proof->open_data, parent); + /* + * A regular source has its own held descriptor and repeated target + * identity checks. Absence and nonregular sources cannot distinguish a + * transient target change from harmless parent-directory churn. + */ int stable = !fstat(held_fd, &held) && fd >= 0 && !fstat(fd, &reopened) && - path_namespace_stat_equal(expected, &held) && - path_namespace_stat_equal(expected, &reopened); + (regular_source ? + (path_namespace_directory_stat_equal(expected, &held) && + path_namespace_directory_stat_equal(expected, &reopened)) : + (path_namespace_stat_equal(expected, &held) && + path_namespace_stat_equal(expected, &reopened))); if (fd >= 0) close(fd); return stable; } -static int parent_stable(struct exclude_source_capture *capture) +static int parent_stable(struct exclude_source_capture *capture, + int regular_source) { return parent_identity_stable( capture->proof, capture->parent, capture->parent_fd, - &capture->parent_stat); + &capture->parent_stat, regular_source); } static void capture_free(struct exclude_source_capture *capture) @@ -334,7 +343,7 @@ void exclude_source_capture_record( if (!source_stat) { if (!exclude_source_capture_absent(capture) || - !parent_stable(capture) || + !parent_stable(capture, 0) || !exclude_source_capture_absent(capture)) { proof->invalid = 1; return; @@ -348,8 +357,10 @@ void exclude_source_capture_record( xsize_t(source_stat->st_size) != size || fstat(source_fd, &final) || !path_namespace_stat_equal(source_stat, &final) || - !source_matches(capture, &final) || - !parent_stable(capture)) { + !parent_stable(capture, S_ISREG(final.st_mode)) || + fstat(source_fd, &final) || + !path_namespace_stat_equal(source_stat, &final) || + !source_matches(capture, &final)) { proof->invalid = 1; return; } @@ -386,7 +397,7 @@ static int proof_entry_matches( goto done; if (!entry->exists) { ret = exclude_source_capture_absent(capture) && - parent_stable(capture) && + parent_stable(capture, 0) && exclude_source_capture_absent(capture); goto done; } @@ -406,7 +417,7 @@ static int proof_entry_matches( hash_object_file(proof->istate->repo->hash_algo, buf, size, OBJ_BLOB, &oid); if (!oideq(&oid, &entry->oid) || - !parent_stable(capture) || + !parent_stable(capture, S_ISREG(after.st_mode)) || fstat(fd, &final) || !path_namespace_stat_equal(&after, &final) || !source_matches(capture, &final)) diff --git a/path-namespace.c b/path-namespace.c index 533c8b53262899..3ff199ada72917 100644 --- a/path-namespace.c +++ b/path-namespace.c @@ -205,6 +205,18 @@ int path_namespace_stat_equal(const struct stat *a, const struct stat *b) return path_stat_identity_equal(&first, &second); } +int path_namespace_directory_stat_equal(const struct stat *a, + const struct stat *b) +{ + struct stat_fingerprint first, second; + + if (!S_ISDIR(a->st_mode) || !S_ISDIR(b->st_mode)) + return 0; + stat_fingerprint_init(&first, a); + stat_fingerprint_init(&second, b); + return stat_fingerprint_equal(&first, &second); +} + int path_namespace_reopen_component( int parent_fd, const char *component, int flags, path_namespace_open_fn open_fn, const struct stat *expected) diff --git a/path-namespace.h b/path-namespace.h index d702b2570aae73..23a155e3ad999c 100644 --- a/path-namespace.h +++ b/path-namespace.h @@ -28,6 +28,8 @@ void path_namespace_hash(struct git_hash_ctx *ctx, void path_namespace_hash_stat(struct git_hash_ctx *ctx, const struct stat *st); int path_namespace_stat_equal(const struct stat *a, const struct stat *b); +int path_namespace_directory_stat_equal(const struct stat *a, + const struct stat *b); int path_namespace_reopen_component( int parent_fd, const char *component, int flags, path_namespace_open_fn open_fn, const struct stat *expected); diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index 21e7cc33c5870c..d081549ab5fd63 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -16,6 +16,8 @@ static struct index_state istate = { }; static char *trash; static int fail_open_parent; +static void (*mutate_before_open_parent)(const char *parent); +static unsigned int mutate_before_open_parent_after; static int open_parent(void *data UNUSED, const char *path) { @@ -23,7 +25,14 @@ static int open_parent(void *data UNUSED, const char *path) errno = EACCES; return -1; } - return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (mutate_before_open_parent && + !--mutate_before_open_parent_after) { + void (*mutate)(const char *) = mutate_before_open_parent; + + mutate_before_open_parent = NULL; + mutate(path); + } + return open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); } static struct exclude_source_proof *new_proof(void) @@ -40,6 +49,62 @@ static char *make_path(const char *name) return strbuf_detach(&path, NULL); } +static void create_sibling_entries(const char *parent) +{ + char *file = xstrfmt("%s/sibling", parent); + char *directory = xstrfmt("%s/sibling-directory", parent); + + write_file_buf(file, "noise", 5); + cl_must_pass(mkdir(directory, 0700)); + free(directory); + free(file); +} + +static void replace_parent_with_same_source(const char *parent) +{ + char *previous = xstrfmt("%s-old", parent); + char *source = xstrfmt("%s/source", parent); + + cl_must_pass(rename(parent, previous)); + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + free(source); + free(previous); +} + +static void replace_parent_with_symlink(const char *parent) +{ + char *previous = xstrfmt("%s-old", parent); + + cl_must_pass(rename(parent, previous)); + cl_must_pass(symlink(previous, parent)); + free(previous); +} + +static void create_and_remove_absent_source(const char *parent) +{ + char *source = xstrfmt("%s/missing", parent); + char *directory = xstrfmt("%s/sibling-directory", parent); + + write_file_buf(source, "briefly present", 15); + cl_must_pass(unlink(source)); + cl_must_pass(mkdir(directory, 0700)); + free(directory); + free(source); +} + +static void replace_source_during_parent_churn(const char *parent) +{ + char *source = xstrfmt("%s/source", parent); + char *replacement = xstrfmt("%s/replacement", parent); + + write_file_buf(replacement, "changed", 7); + cl_must_pass(rename(replacement, source)); + create_sibling_entries(parent); + free(replacement); + free(source); +} + static void record_file(struct exclude_source_proof *proof, const char *path) { struct exclude_source_capture *capture = @@ -83,6 +148,8 @@ void test_exclude_source_proof__initialize(void) char template[] = "/tmp/exclude-source-proof-XXXXXX"; fail_open_parent = 0; + mutate_before_open_parent = NULL; + mutate_before_open_parent_after = 0; cl_assert(mkdtemp(template) != NULL); trash = xstrdup(template); } @@ -117,6 +184,233 @@ void test_exclude_source_proof__accepts_same_content_replacement(void) free(parent); } +void test_exclude_source_proof__accepts_sibling_churn_during_regular_capture(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + struct stat source_stat; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &source_stat)); + create_sibling_entries(parent); + exclude_source_capture_record(capture, fd, &source_stat, "content", 7); + exclude_source_capture_release(capture); + cl_must_pass(close(fd)); + cl_assert(exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__accepts_sibling_churn_during_regular_validation(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + mutate_before_open_parent = create_sibling_entries; + mutate_before_open_parent_after = 2; + cl_assert(exclude_source_proof_validate(proof)); + cl_assert(mutate_before_open_parent == NULL); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_parent_replacement_during_regular_capture(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + struct stat source_stat; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &source_stat)); + replace_parent_with_same_source(parent); + exclude_source_capture_record(capture, fd, &source_stat, "content", 7); + exclude_source_capture_release(capture); + cl_must_pass(close(fd)); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_parent_replacement_during_regular_validation(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + mutate_before_open_parent = replace_parent_with_same_source; + mutate_before_open_parent_after = 2; + cl_assert(!exclude_source_proof_validate(proof)); + cl_assert(mutate_before_open_parent == NULL); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_symlinked_parent_during_regular_validation(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + mutate_before_open_parent = replace_parent_with_symlink; + mutate_before_open_parent_after = 2; + cl_assert(!exclude_source_proof_validate(proof)); + cl_assert(mutate_before_open_parent == NULL); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_target_replacement_during_parent_churn(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + record_file(proof, source); + mutate_before_open_parent = replace_source_during_parent_churn; + mutate_before_open_parent_after = 2; + cl_assert(!exclude_source_proof_validate(proof)); + cl_assert(mutate_before_open_parent == NULL); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_sibling_churn_during_absent_capture(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + char *parent = make_path("parent"); + char *source = make_path("parent/missing"); + + cl_must_pass(mkdir(parent, 0700)); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + cl_assert(exclude_source_capture_absent(capture)); + create_sibling_entries(parent); + exclude_source_capture_record(capture, -1, NULL, NULL, 0); + exclude_source_capture_release(capture); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_sibling_churn_during_fifo_capture(void) +{ + struct exclude_source_proof *proof = + exclude_source_proof_create(&istate, NULL, open_parent, + EXCLUDE_SOURCE_PROOF_NONBLOCKING); + struct exclude_source_capture *capture; + struct stat source_stat; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + cl_must_pass(mkfifo(source, 0600)); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &source_stat)); + cl_assert(S_ISFIFO(source_stat.st_mode)); + create_sibling_entries(parent); + exclude_source_capture_record(capture, fd, &source_stat, NULL, 0); + exclude_source_capture_release(capture); + cl_must_pass(close(fd)); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_transient_absent_source_during_validation(void) +{ + struct exclude_source_proof *proof = new_proof(); + char *parent = make_path("parent"); + char *source = make_path("parent/missing"); + + cl_must_pass(mkdir(parent, 0700)); + record_absence(proof, source); + mutate_before_open_parent = create_and_remove_absent_source; + mutate_before_open_parent_after = 2; + cl_assert(!exclude_source_proof_validate(proof)); + cl_assert(mutate_before_open_parent == NULL); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + +void test_exclude_source_proof__rejects_parent_permission_change_during_capture(void) +{ + struct exclude_source_proof *proof = new_proof(); + struct exclude_source_capture *capture; + struct stat source_stat; + char *parent = make_path("parent"); + char *source = make_path("parent/source"); + int fd; + + cl_must_pass(mkdir(parent, 0700)); + write_file_buf(source, "content", 7); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &source_stat)); + cl_must_pass(chmod(parent, 0500)); + exclude_source_capture_record(capture, fd, &source_stat, "content", 7); + exclude_source_capture_release(capture); + cl_must_pass(close(fd)); + cl_must_pass(chmod(parent, 0700)); + cl_assert(!exclude_source_proof_validate(proof)); + + exclude_source_proof_release(proof); + free(source); + free(parent); +} + void test_exclude_source_proof__rejects_different_content_replacement(void) { struct exclude_source_proof *proof = new_proof(); @@ -485,6 +779,16 @@ void test_exclude_source_proof__captures_fifo_without_blocking(void) EMPTY_TEST(test_exclude_source_proof__initialize) EMPTY_TEST(test_exclude_source_proof__cleanup) SKIP_TEST(test_exclude_source_proof__accepts_same_content_replacement) +SKIP_TEST(test_exclude_source_proof__accepts_sibling_churn_during_regular_capture) +SKIP_TEST(test_exclude_source_proof__accepts_sibling_churn_during_regular_validation) +SKIP_TEST(test_exclude_source_proof__rejects_parent_replacement_during_regular_capture) +SKIP_TEST(test_exclude_source_proof__rejects_parent_replacement_during_regular_validation) +SKIP_TEST(test_exclude_source_proof__rejects_symlinked_parent_during_regular_validation) +SKIP_TEST(test_exclude_source_proof__rejects_target_replacement_during_parent_churn) +SKIP_TEST(test_exclude_source_proof__rejects_sibling_churn_during_absent_capture) +SKIP_TEST(test_exclude_source_proof__rejects_sibling_churn_during_fifo_capture) +SKIP_TEST(test_exclude_source_proof__rejects_transient_absent_source_during_validation) +SKIP_TEST(test_exclude_source_proof__rejects_parent_permission_change_during_capture) SKIP_TEST(test_exclude_source_proof__rejects_different_content_replacement) SKIP_TEST(test_exclude_source_proof__accepts_repeated_observation) SKIP_TEST(test_exclude_source_proof__rejects_missing_source_buffer) diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c index 3c80140a8bbf72..b3735df2c4a4a1 100644 --- a/t/unit-tests/u-path-namespace.c +++ b/t/unit-tests/u-path-namespace.c @@ -73,6 +73,52 @@ void test_path_namespace__stat_fields(void) #endif } +void test_path_namespace__directory_identity_ignores_unrelated_entries(void) +{ + struct stat original, changed; + + cl_must_pass(stat(".", &original)); + cl_assert(S_ISDIR(original.st_mode)); + cl_assert(path_namespace_directory_stat_equal(&original, &original)); + + changed = original; + changed.st_nlink++; + changed.st_size++; + changed.st_mtime++; + changed.st_ctime++; + cl_assert(!path_namespace_stat_equal(&original, &changed)); + cl_assert(path_namespace_directory_stat_equal(&original, &changed)); + + changed = original; + changed.st_dev++; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); + changed = original; + changed.st_ino++; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); + changed = original; + changed.st_mode ^= S_IXGRP; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); + changed = original; + changed.st_uid++; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); + changed = original; + changed.st_gid++; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); +#ifdef __APPLE__ + changed = original; + changed.st_birthtimespec.tv_sec++; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); + changed = original; + changed.st_gen++; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); +#endif + + changed = original; + changed.st_mode = S_IFREG | 0600; + cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); + cl_assert(!path_namespace_directory_stat_equal(&changed, &changed)); +} + static int source_fd = -1; static int reopen_source(int dirfd UNUSED, const char *path, int flags UNUSED) From 40798d47ec36593cd114ce7b8874ab242f5bfb74 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:43:55 -0500 Subject: [PATCH 358/403] fsmonitor: release raw index bitmaps with their owner aaa01b4ec1 (clean-status: release the raw fsmonitor shadow bitmap, 2026-08-17) releases the bitmap owned by one raw index reader. The two external-history witness readers and split-index bases can own the same kind of bitmap, but release_index() does not free it. Repeated bitmap generation can also overwrite an allocation left by an earlier write. Make release_index() release and clear the owned bitmap, and replace an existing bitmap safely when generating the next one. Existing transfers already clear the donor, and callers which consume the bitmap clear it after freeing it. Extend the FSMN parser test with raw-read/release and repeated-fill ownership checks. The old cleanup fails the focused test; the corrected cleanup passes it under both object formats. --- fsmonitor.c | 5 ++++- read-cache.c | 3 +++ t/helper/test-read-cache.c | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/fsmonitor.c b/fsmonitor.c index b1c07929d98581..d075305a8fa1b6 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -374,7 +374,10 @@ static struct ewah_bitmap *fsmonitor_bitmap_from_index( void fill_fsmonitor_bitmap(struct index_state *istate) { - istate->fsmonitor_dirty = fsmonitor_bitmap_from_index(istate); + struct ewah_bitmap *bitmap = fsmonitor_bitmap_from_index(istate); + + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = bitmap; } static void serialize_fsmonitor_extension(struct strbuf *sb, diff --git a/read-cache.c b/read-cache.c index cf13875574f23d..bf0866a6e5540f 100644 --- a/read-cache.c +++ b/read-cache.c @@ -26,6 +26,7 @@ #include "tree.h" #include "commit.h" #include "environment.h" +#include "ewah/ewok.h" #include "gettext.h" #include "mem-pool.h" #include "name-hash.h" @@ -2754,6 +2755,8 @@ void release_index(struct index_state *istate) free(istate->fsmonitor_last_update); free(istate->fsmonitor_last_update_pending); free(istate->fsmonitor_untracked_token); + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; clean_status_release(istate); free(istate->preload_bulk_tracked_state); free(istate->preload_bulk_stat_updates); diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 3009e38d3b0bb6..2f8ce51bf39a2f 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -191,6 +191,31 @@ static int check_invalid_fsmn(const struct strbuf *encoded, return 0; } +static int test_fsmn_bitmap_ownership(const struct strbuf *encoded) +{ + struct index_state parsed = INDEX_STATE_INIT(the_repository); + struct index_state regenerated = INDEX_STATE_INIT(the_repository); + + parsed.cache_nr = 1; + read_fsmonitor_extension(&parsed, encoded->buf, encoded->len); + if (!parsed.fsmonitor_token_valid || !parsed.fsmonitor_dirty) + return error("raw FSMN bitmap was not published"); + parsed.cache_nr = 0; + release_index(&parsed); + + fill_fsmonitor_bitmap(®enerated); + if (!regenerated.fsmonitor_dirty) + return error("initial FSMN bitmap was not published"); + fill_fsmonitor_bitmap(®enerated); + if (!regenerated.fsmonitor_dirty) + return error("regenerated FSMN bitmap did not replace its owner"); + release_index(®enerated); + + if (parsed.fsmonitor_dirty || regenerated.fsmonitor_dirty) + return error("released index retained its FSMN bitmap"); + return 0; +} + static int test_fsmn_parser(void) { struct index_state duplicate = INDEX_STATE_INIT(the_repository); @@ -244,6 +269,8 @@ static int test_fsmn_parser(void) make_raw_fsmn(&malformed, 1, words, 2, 1); if (check_invalid_fsmn(&malformed, "non-final RLW")) return 1; + if (test_fsmn_bitmap_ownership(&encoded)) + return 1; strbuf_release(&malformed); strbuf_release(&encoded); From 0a5f399b17c11e3bdfad29025158250c4b9a9a6e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:35:06 -0500 Subject: [PATCH 359/403] status: carry a failed sidecar probe into the index refresh 61f7705f80 (status: answer exact clean status before index deserialization, 2026-07-27) lets a clean sidecar answer status before reading the index. When its provider query reports a reset or an error, we fall back to reading the index, which makes another provider query. An empty second response must not revive the boundary rejected by the first one. Otherwise stale tracked and untracked proofs can hide a changed file or newly activated attributes. Carry that lost boundary to cmd_status(). After reading the index, discard the semantic manifest and strongly invalidate both tracked and untracked state before collecting or publishing status. Preserve an already authenticated stat baseline only when the reader also obtained its closing token from the provider. This avoids rebuilding the same manifest after two resets without trusting a later empty delta. Ordinary nonempty deltas retain their existing scoped handling. Exercise the reset-then-empty and error-then-empty sequences with a filter-induced modification and an independent strong-stat oracle. Also cover clean hits, consecutive resets, a second provider error, ordinary deltas, read-only index preservation, and writable status followed by another read-only query. --- builtin/commit.c | 23 +++++- clean-status-fast.c | 15 ++-- clean-status.h | 2 +- t/t7530-status-clean-sidecar.sh | 119 ++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 6 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 1de778261812d5..e44f0f76ca81f6 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -23,6 +23,7 @@ #include "environment.h" #include "diff.h" #include "commit.h" +#include "fsmonitor.h" #include "fsmonitor-settings.h" #include "add-interactive.h" #include "gettext.h" @@ -1882,6 +1883,7 @@ struct repository *repo UNUSED) int normal_has_head; int reissue_clean_sidecar = 0; int repository_inputs_changed = 0; + int sidecar_provider_reset = 0; int reissue_after_write = 0; int save_history_after_write = 0; int deferred_scoped_history = 0; @@ -2003,7 +2005,8 @@ struct repository *repo UNUSED) s.certify_clean_status = exact_clean_query; if (reusable_clean_query && clean_status_try_sidecar(the_repository, &clean_digest, - &repository_inputs_changed)) { + &repository_inputs_changed, + &sidecar_provider_reset)) { if (exact_clean_query || print_clean_sidecar(&s, prefix)) { wt_status_collect_free_buffers(&s); @@ -2025,6 +2028,24 @@ struct repository *repo UNUSED) if (use_optional_locks()) clean_status_require_external_history_source(the_repository); repo_read_index(the_repository); + if (sidecar_provider_reset) { + /* + * The fast probe already lost the old provider boundary. Even + * if the index reader's second query is empty, it must not + * revive the tracked or untracked proof we just rejected. A + * provider-owned, authenticated stat baseline has already + * invalidated that proof and must retain its closing token. + */ + if (!clean_status_fsmonitor_semantic_baseline_pending( + the_repository->index) || + !fsmonitor_pending_token_from_provider(the_repository->index)) { + clean_status_invalidate_current_manifest(the_repository->index); + fsmonitor_invalidate_semantics(the_repository->index); + untracked_cache_invalidate_all(the_repository->index); + } + trace2_data_intmax("status", the_repository, + "clean-proof/provider-reset-carried", 1); + } if (use_optional_locks()) { deferred_scoped_history = clean_status_defer_scoped_history_capture( diff --git a/clean-status-fast.c b/clean-status-fast.c index e0e2661b2be726..53f05ce1df41a8 100644 --- a/clean-status-fast.c +++ b/clean-status-fast.c @@ -22,9 +22,10 @@ int clean_status_try_sidecar( struct repository *repo UNUSED, const struct clean_status_config_digest *config UNUSED, - int *repository_inputs_changed) + int *repository_inputs_changed, int *provider_reset) { *repository_inputs_changed = 0; + *provider_reset = 0; return 0; } @@ -199,7 +200,7 @@ static int current_worktree_is_main(struct repository *repo) int clean_status_try_sidecar( struct repository *repo, const struct clean_status_config_digest *config, - int *repository_inputs_changed) + int *repository_inputs_changed, int *provider_reset) { struct clean_status_sidecar_record record = CLEAN_STATUS_SIDECAR_RECORD_INIT; @@ -216,6 +217,7 @@ int clean_status_try_sidecar( int ret = 0; *repository_inputs_changed = 0; + *provider_reset = 0; if (!config->finalized || (config->filter_configured && config->normalized_filter_disable) || getenv(INDEX_ENVIRONMENT) || is_bare_repository(repo) || @@ -281,8 +283,13 @@ int clean_status_try_sidecar( query_token = xmemdupz( record.sidecar.token, record.sidecar.token_len); if (query_builtin_fsmonitor(query_token, &query) != - FSMONITOR_QUERY_DELTA || - query.paths.len) { + FSMONITOR_QUERY_DELTA) { + /* A later successful query cannot erase this lost boundary. */ + *provider_reset = 1; + trace_miss(repo, "fast-provider-changed"); + goto done; + } + if (query.paths.len) { trace_miss(repo, "fast-provider-changed"); goto done; } diff --git a/clean-status.h b/clean-status.h index 5a9698a8f9accc..24f07807d2ee99 100644 --- a/clean-status.h +++ b/clean-status.h @@ -110,7 +110,7 @@ int clean_status_issue_sidecar( int clean_status_try_sidecar( struct repository *repo, const struct clean_status_config_digest *config, - int *repository_inputs_changed); + int *repository_inputs_changed, int *provider_reset); int clean_status_read_fsmonitor_config(struct index_state *istate, const void *data, unsigned long size); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 6de465a64bda35..4fb103c2dcff72 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2612,4 +2612,123 @@ test_expect_success DURABLE_FSMONITOR \ done ' +test_expect_success PERL_TEST_HELPERS \ + 'a trivial fast probe survives a later empty provider delta' ' + test_when_finished "rm -rf sidecar-trivial-probe" && + test_create_repo sidecar-trivial-probe && + ( + cd sidecar-trivial-probe && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime -120 tracked && + git update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config filter.sidecar.clean "sed s/base/converted/" && + git config filter.sidecar.required true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issue && + test_must_be_empty .git/issue && + test_path_is_file .git/index.csts && + cp .git/index .git/index.before && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean.trace" \ + git status --porcelain=v2 >.git/clean && + test_must_be_empty .git/clean && + test_trace2_data status clean-proof/hit 1 <.git/clean.trace && + test_region ! index do_read_index .git/clean.trace && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TTCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/clean-reset.trace" \ + git status --porcelain=v2 >.git/clean-reset && + test_must_be_empty .git/clean-reset && + test_trace2_data status clean-proof/provider-reset-carried 1 \ + <.git/clean-reset.trace && + test_trace2_data fsmonitor semantic/token-reset-stat-baseline 1 \ + <.git/clean-reset.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/clean-reset.trace >.git/clean-reset.scans && + test_line_count = 1 .git/clean-reset.scans && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 2 \ + <.git/clean-reset.trace && + test_cmp_bin .git/index.before .git/index && + test_write_lines "tracked filter=sidecar" >.gitattributes && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.trustctime=true -c core.checkStat=default \ + status --porcelain=v2 >.git/expect && + test_grep "^1 \\.M .* tracked$" .git/expect && + test_grep "^? \\.gitattributes$" .git/expect && + for outcome in T E TT TE + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE="$outcome"CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$outcome.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data status clean-proof/miss fast-provider-changed \ + <".git/$outcome.trace" && + test_trace2_data status clean-proof/provider-reset-carried 1 \ + <".git/$outcome.trace" && + test_grep ! "\"key\":\"clean-proof/hit\"" \ + ".git/$outcome.trace" && + test_cmp_bin .git/index.before .git/index && + case "$outcome" in + TT) + test_trace2_data fsmonitor \ + semantic/token-reset-stat-baseline 1 \ + <.git/TT.trace && + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <.git/TT.trace >.git/TT.scans && + test_line_count = 1 .git/TT.scans && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 2 \ + <.git/TT.trace + ;; + TE) + ! test_trace2_data fsmonitor \ + semantic/token-reset-stat-baseline 1 \ + <.git/TE.trace && + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <.git/TE.trace && + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 2 \ + <.git/TE.trace + ;; + esac || return 1 + done && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/delta.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + ! test_trace2_data status clean-proof/provider-reset-carried 1 \ + <.git/delta.trace && + test_cmp_bin .git/index.before .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/writable.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data status clean-proof/provider-reset-carried 1 \ + <.git/writable.trace && + ! test_trace2_data status clean-proof/sidecar 1 \ + <.git/writable.trace && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/follower && + test_cmp .git/expect .git/follower + ) +' + test_done From 2444e3525032a654f9e718f661b24d459ffac515 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:36:03 -0500 Subject: [PATCH 360/403] fsmonitor: retire the provider token after a failed cookie 56cef9cb1a (fsmonitor: use pthread_cond_timedwait for cookie wait, 2026-04-15) bounds the wait for a filesystem event. A timeout or cookie creation error means the daemon cannot prove that it has drained the event queue. Returning a trivial response with the same token leaves that token usable by another request. A later successful cookie can therefore make an already rejected boundary look complete. Force the existing resynchronization path while holding the main lock when a cookie returns FCIR_ERROR. This rotates the token and aborts other waiters before replying. Do not resynchronize again for FCIR_ABORT: that waiter already belongs to a listener-initiated reset. Force cookie creation to fail by temporarily removing its directory. Require a different token in the trivial response and reject the old token after restoring the directory. Check successful replay separately when the filesystem actually delivers the recovery cookie. --- builtin/fsmonitor--daemon.c | 8 +++++ t/meson.build | 1 + t/t7535-fsmonitor-cookie-reset.sh | 56 +++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100755 t/t7535-fsmonitor-cookie-reset.sh diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index d96d7e8fb9d0e5..b52421f672c930 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -851,6 +851,14 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, error(_("fsmonitor: cookie_result '%d' != SEEN"), cookie_result); do_trivial = 1; + /* + * This boundary could not be synchronized. Retire it so + * a later successful cookie cannot make an old client's + * token appear complete again. An aborted cookie already + * belongs to a listener-initiated reset. + */ + if (cookie_result == FCIR_ERROR) + do_flush = 1; } } diff --git a/t/meson.build b/t/meson.build index e486e988bc9aaa..d97af43eda3ac1 100644 --- a/t/meson.build +++ b/t/meson.build @@ -967,6 +967,7 @@ integration_tests = [ 't7532-preload-index-linux.sh', 't7533-status-scoped-stash.sh', 't7534-status-scoped-readers.sh', + 't7535-fsmonitor-cookie-reset.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7535-fsmonitor-cookie-reset.sh b/t/t7535-fsmonitor-cookie-reset.sh new file mode 100755 index 00000000000000..d83013928c80a2 --- /dev/null +++ b/t/t7535-fsmonitor-cookie-reset.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +test_description='failed fsmonitor cookies retire the provider boundary' + +. ./test-lib.sh + +if ! test_have_prereq FSMONITOR_DAEMON +then + skip_all='fsmonitor--daemon is not supported on this platform' + test_done +fi + +test_expect_success 'a failed cookie permanently invalidates the old token' ' + test_when_finished "git -C cookie-reset fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo cookie-reset && + GIT_TRACE2_EVENT="$PWD/cookie-daemon.trace" \ + git -C cookie-reset fsmonitor--daemon start --start-timeout=10 && + mv cookie-reset/.git/fsmonitor--daemon/cookies \ + cookie-reset/.git/fsmonitor--daemon/cookies.saved && + test_when_finished "test ! -d cookie-reset/.git/fsmonitor--daemon/cookies.saved || + mv cookie-reset/.git/fsmonitor--daemon/cookies.saved \ + cookie-reset/.git/fsmonitor--daemon/cookies" && + test-tool -C cookie-reset fsmonitor-client flush >before && + nul_to_q before.q && + test_grep "^builtin:.*:0Q/Q$" before.q && + old_token=$(sed "s/Q.*//" before.q) && + test-tool -C cookie-reset fsmonitor-client query \ + --token "$old_token" >failed && + nul_to_q failed.q && + test_grep "^builtin:.*:0Q/Q$" failed.q && + new_token=$(sed "s/Q.*//" failed.q) && + test "$old_token" != "$new_token" && + mv cookie-reset/.git/fsmonitor--daemon/cookies.saved \ + cookie-reset/.git/fsmonitor--daemon/cookies && + test-tool -C cookie-reset fsmonitor-client query \ + --token "$old_token" >recovered && + nul_to_q recovered.q && + test_grep "^builtin:.*Q/Q$" recovered.q && + recovered_token=$(sed "s/Q.*//" recovered.q) && + test "$recovered_token" != "$old_token" && + if test "$recovered_token" = "$new_token" && + test_trace2_data fsmonitor response/token different \ + Date: Mon, 17 Aug 2026 12:36:49 -0500 Subject: [PATCH 361/403] fsmonitor: encode Darwin worktree-root events without overreading 65723b305a (compat/fsmonitor/fsm-listen-darwin: implement FSEvent listener on MacOS, 2022-03-25) converts absolute events to worktree-relative paths. The classifier accepts the watched root itself, but the listener skips the root and a presumed slash. That reads past the terminating NUL for an exact-root event and can queue arbitrary trailing bytes or the reserved trivial-response marker. Handle the root before advancing to a relative pathname. Encode it as the existing global-invalidation path, leaving ordinary file, directory, and merged file/directory events unchanged. Keep alias resolution and metadata-only event filtering at their existing boundaries. Exercise the formatter through the builtin response parser, including stale bytes after a root's NUL. A root event must remain a global DELTA, not a TRIVIAL response. --- compat/fsmonitor/fsm-listen-darwin.c | 31 ++++++++----------- fsmonitor.c | 29 ++++++++++++++++++ fsmonitor.h | 9 ++++++ t/unit-tests/u-fsmonitor-response.c | 45 ++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index 57f27faefa3eb2..f25d7cdd907af9 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -24,7 +24,7 @@ #endif #include "git-compat-util.h" -#include "fsmonitor-ll.h" +#include "fsmonitor.h" #include "fsm-listen.h" #include "fsmonitor--daemon.h" #include "fsmonitor-path-utils.h" @@ -420,26 +420,19 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, * know how much to invalidate/refresh. */ - if (event_flags[k] & (kFSEventStreamEventFlagItemIsFile | kFSEventStreamEventFlagItemIsSymlink)) { - const char *rel = path_k + - state->path_worktree_watch.len + 1; - + fsmonitor_format_worktree_paths( + &tmp, path_k, state->path_worktree_watch.len, + !!(event_flags[k] & + (kFSEventStreamEventFlagItemIsFile | + kFSEventStreamEventFlagItemIsSymlink)), + !!(event_flags[k] & + kFSEventStreamEventFlagItemIsDir)); + for (const char *relative = tmp.buf; + relative < tmp.buf + tmp.len; + relative += strlen(relative) + 1) { if (!batch) batch = fsmonitor_batch__new(); - my_add_path(batch, rel); - } - - if (event_flags[k] & kFSEventStreamEventFlagItemIsDir) { - const char *rel = path_k + - state->path_worktree_watch.len + 1; - - strbuf_reset(&tmp); - strbuf_addstr(&tmp, rel); - strbuf_addch(&tmp, '/'); - - if (!batch) - batch = fsmonitor_batch__new(); - my_add_path(batch, tmp.buf); + my_add_path(batch, relative); } break; diff --git a/fsmonitor.c b/fsmonitor.c index d075305a8fa1b6..a8dbf21b17a6f9 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -896,6 +896,35 @@ void fsmonitor_query_result_release(struct fsmonitor_query_result *result) strbuf_release(&result->paths); } +void fsmonitor_format_worktree_paths( + struct strbuf *paths, const char *path, size_t worktree_len, + int is_file, int is_directory) +{ + const char *relative = path + worktree_len; + + strbuf_reset(paths); + if (!is_file && !is_directory) + return; + + /* The root has no relative pathname; never read past its NUL. */ + if (!*relative || (*relative == '/' && !relative[1])) { + strbuf_addstr(paths, FSMONITOR_PATH_GLOBAL_INVALIDATE); + strbuf_addch(paths, '\0'); + return; + } + + relative++; + if (is_file) { + strbuf_addstr(paths, relative); + strbuf_addch(paths, '\0'); + } + if (is_directory) { + strbuf_addstr(paths, relative); + strbuf_addch(paths, '/'); + strbuf_addch(paths, '\0'); + } +} + static int fsmonitor_valid_worktree_path(const char *path, size_t len) { struct strbuf copy = STRBUF_INIT; diff --git a/fsmonitor.h b/fsmonitor.h index be8136767a9bbe..7ba51d6bd05961 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -36,6 +36,15 @@ struct fsmonitor_query_result { } void fsmonitor_query_result_release(struct fsmonitor_query_result *result); + +/* + * Encode an already classified and alias-resolved worktree event. The caller + * must have verified that worktree_len names the path's worktree prefix. + */ +void fsmonitor_format_worktree_paths( + struct strbuf *paths, const char *path, size_t worktree_len, + int is_file, int is_directory); + enum fsmonitor_query_outcome fsmonitor_parse_builtin_response( const struct strbuf *raw, struct fsmonitor_query_result *result); enum fsmonitor_query_outcome query_builtin_fsmonitor( diff --git a/t/unit-tests/u-fsmonitor-response.c b/t/unit-tests/u-fsmonitor-response.c index 770b8ecea17487..1b024d72f6dea5 100644 --- a/t/unit-tests/u-fsmonitor-response.c +++ b/t/unit-tests/u-fsmonitor-response.c @@ -27,6 +27,29 @@ static void check_malformed(const void *data, size_t len) check_response(data, len, FSMONITOR_QUERY_ERROR, "", NULL, 0); } +static void check_worktree_event( + const char *path, size_t worktree_len, + int is_file, int is_directory, + const void *expected, size_t expected_len) +{ + struct strbuf paths = STRBUF_INIT; + struct strbuf response = STRBUF_INIT; + + fsmonitor_format_worktree_paths( + &paths, path, worktree_len, is_file, is_directory); + cl_assert_equal_i(paths.len, expected_len); + cl_assert(!expected_len || !memcmp(paths.buf, expected, expected_len)); + + strbuf_addstr(&response, "builtin:worktree"); + strbuf_addch(&response, '\0'); + strbuf_addbuf(&response, &paths); + check_response(response.buf, response.len, FSMONITOR_QUERY_DELTA, + "builtin:worktree", expected, expected_len); + + strbuf_release(&response); + strbuf_release(&paths); +} + void test_fsmonitor_response__rejects_malformed_framing(void) { static const char missing_nul[] = "builtin:1"; @@ -74,6 +97,12 @@ void test_fsmonitor_response__accepts_valid_builtin_responses(void) static const char delta[] = "builtin:2\0a\0dir/file\0dir/\0"; static const char global[] = "builtin:3\0//\0"; static const char trivial[] = "builtin:4\0/\0"; + static const char stale_root[] = "/repo\0stale/path"; + static const char global_path[] = "//\0"; + static const char file_path[] = "tracked\0"; + static const char directory_path[] = "nested/\0"; + static const char both_paths[] = "merged\0merged/\0"; + static const char case_path[] = "Tracked\0"; check_response(delta, sizeof(delta) - 1, FSMONITOR_QUERY_DELTA, "builtin:2", delta + sizeof("builtin:2"), @@ -83,6 +112,22 @@ void test_fsmonitor_response__accepts_valid_builtin_responses(void) sizeof(global) - 1 - sizeof("builtin:3")); check_response(trivial, sizeof(trivial) - 1, FSMONITOR_QUERY_TRIVIAL, "builtin:4", NULL, 0); + + check_worktree_event(stale_root, strlen("/repo"), 0, 1, + global_path, sizeof(global_path) - 1); + check_worktree_event("/repo/", strlen("/repo"), 0, 1, + global_path, sizeof(global_path) - 1); + check_worktree_event("/repo", strlen("/repo"), 1, 1, + global_path, sizeof(global_path) - 1); + check_worktree_event("/repo/tracked", strlen("/repo"), 1, 0, + file_path, sizeof(file_path) - 1); + check_worktree_event("/repo/nested", strlen("/repo"), 0, 1, + directory_path, sizeof(directory_path) - 1); + check_worktree_event("/repo/merged", strlen("/repo"), 1, 1, + both_paths, sizeof(both_paths) - 1); + check_worktree_event("/REPO/Tracked", strlen("/repo"), 1, 0, + case_path, sizeof(case_path) - 1); + check_worktree_event("/repo", strlen("/repo"), 0, 0, NULL, 0); } void test_fsmonitor_response__validates_hardlink_inode_markers(void) From ae6012c6352a0caa573141d33a1f0e05996924e0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:39:24 -0500 Subject: [PATCH 362/403] fsmonitor: open watch-limit backoff markers without blocking 60077b92ac (status: preserve semantic history across scoped and index changes, 2026-08-11) records a temporary watch-limit failure in the git directory. The reader checks that this optional marker is a small, private regular file, but opens it before checking its type. A FIFO with no writer therefore blocks every command which reads fsmonitor settings, instead of being rejected as an invalid marker. Open the marker with O_NONBLOCK. Keep the existing no-follow, owner, link-count, mode, size, age, worktree-identity, and watch-limit checks. The marker implementation is already limited to Linux and macOS. Cover a no-writer FIFO with a bounded alarm, and retain oversized and malformed-marker controls. Compare status against an independent strong-stat oracle while the scripted provider reports the real changes. --- fsmonitor-ipc.c | 2 +- t/meson.build | 1 + t/t7536-fsmonitor-watch-limit-backoff.sh | 95 ++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100755 t/t7536-fsmonitor-watch-limit-backoff.sh diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 867a0c975f3dc3..799db433cf86f6 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -230,7 +230,7 @@ int fsmonitor_ipc__watch_limit_backoff(struct repository *r) if (!watch_limit_backoff_enabled()) return 0; path = repo_git_path(r, FSMONITOR_WATCH_LIMIT_MARKER); - fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); if (fd < 0) goto done; if (fstat(fd, &st) || !S_ISREG(st.st_mode) || diff --git a/t/meson.build b/t/meson.build index d97af43eda3ac1..7b10e2b568475d 100644 --- a/t/meson.build +++ b/t/meson.build @@ -968,6 +968,7 @@ integration_tests = [ 't7533-status-scoped-stash.sh', 't7534-status-scoped-readers.sh', 't7535-fsmonitor-cookie-reset.sh', + 't7536-fsmonitor-watch-limit-backoff.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh new file mode 100755 index 00000000000000..c80a4002809a61 --- /dev/null +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -0,0 +1,95 @@ +#!/bin/sh + +test_description='fsmonitor watch-limit backoff authenticates optional markers' + +. ./test-lib.sh + +if ! test_have_prereq FSMONITOR_DAEMON +then + skip_all='fsmonitor--daemon is not supported on this platform' + test_done +fi + +case "$uname_s" in +Linux | Darwin) + ;; +*) + skip_all='inotify watch-limit markers are not supported on this platform' + test_done + ;; +esac + +sane_unset GIT_TEST_SPLIT_INDEX GIT_TEST_FSMONITOR + +setup_backoff_marker_fixture () { + test_create_repo "$1" && + ( + cd "$1" && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_write_lines changed >tracked && + test_write_lines visible >visible && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >.git/expect && + test_grep "^1 \\.M .* tracked$" .git/expect && + test_grep "^? visible$" .git/expect + ) +} + +check_rejected_backoff_marker () { + ( + cd "$1" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/$2.trace" \ + perl -e "alarm 5; exec @ARGV" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <".git/$2.trace" && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + ".git/$2.trace" + ) +} + +test_expect_success PIPE,PERL_TEST_HELPERS \ + 'a FIFO watch-limit marker never blocks ordinary status' ' + setup_backoff_marker_fixture marker-fifo && + marker=marker-fifo/.git/fsmonitor--daemon.inotify-limit && + mkfifo "$marker" && + test_when_finished "rm -f $marker" && + check_rejected_backoff_marker marker-fifo fifo && + test -p "$marker" +' + +test_expect_success PERL_TEST_HELPERS \ + 'an oversized watch-limit marker cannot disable fsmonitor' ' + setup_backoff_marker_fixture marker-oversized && + marker=marker-oversized/.git/fsmonitor--daemon.inotify-limit && + printf "%0257d\\n" 0 >"$marker" && + chmod 600 "$marker" && + test "$(wc -c <"$marker")" -gt 256 && + check_rejected_backoff_marker marker-oversized oversized && + test_path_is_file "$marker" +' + +test_expect_success PERL_TEST_HELPERS \ + 'a malformed watch-limit marker cannot disable fsmonitor' ' + setup_backoff_marker_fixture marker-malformed && + marker=marker-malformed/.git/fsmonitor--daemon.inotify-limit && + printf "inotify-limit-v1\\ninvalid-identity\\nnot-a-limit\\n" \ + >"$marker" && + chmod 600 "$marker" && + check_rejected_backoff_marker marker-malformed malformed && + test_path_is_file "$marker" +' + +test_done From 0e380d274c8169d4f7696aa076c83c98393b682d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:41:39 -0500 Subject: [PATCH 363/403] fsmonitor: reuse authenticated manifests for wider directory deltas 7c19bf467b (fsmonitor: retain checked manifests across scoped directory deltas, 2026-08-15) bounds the local attribute check to 64 candidate paths. A closing event for a directory with 64 child directories crosses that limit even when no attributes changed, invalidating the proof and building the whole-worktree manifest a second time. Collect each indexed directory once, sort the candidate paths, and walk them together with one authenticated manifest cursor. This removes the fixed cutoff, repeated sorted insertions, and repeated manifest searches. Keep the existing descriptor-anchored source checks, negative lookups, and before/after repository, index, attribute, and provider fences. Require the existing high-fanout case to finish with one manifest scan and reuse its checked subtree. Also race an attribute creation in the last child directory and require the ordinary fail-closed result. --- clean-status-manifest.c | 87 +++++++++++++++++++++++++++---------- t/t7519-status-fsmonitor.sh | 56 +++++++++++++++++++----- 2 files changed, 108 insertions(+), 35 deletions(-) diff --git a/clean-status-manifest.c b/clean-status-manifest.c index fe881e44e38edd..9f1628a6840e30 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -180,18 +180,29 @@ int clean_status_manifest_end_directory_delta(struct index_state *istate) } #if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static int directory_manifest_entry_path_compare( + const struct attr_manifest_entry *entry, const char *name) +{ + size_t len = strlen(name); + size_t common = entry->path_len < len ? entry->path_len : len; + int cmp = memcmp(entry->path, name, common); + + if (cmp) + return cmp; + return entry->path_len < len ? -1 : entry->path_len > len; +} + static int directory_attribute_source_matches( struct index_state *istate, struct semantic_verify_path *path, - const char *name, size_t position) + const char *name, const struct attr_manifest_entry *entry, + size_t position) { - struct clean_status_state *state = istate->clean_status; - struct attr_manifest_entry entry; const struct cache_entry *indexed; const struct git_hash_algo *algo = istate->repo->hash_algo; const char *basename; unsigned char observed[GIT_MAX_RAWSZ]; struct stat st; - int parent_fd, found, present, pos; + int parent_fd, found, pos; if (semantic_verify_resolve_parent(path, name, position, &parent_fd, &basename)) @@ -205,24 +216,22 @@ static int directory_attribute_source_matches( if (worktree_attr_source_read(path, name, position, algo, observed, &found)) return 0; - present = !find_manifest_entry(&state->manifest.current, - name, algo, &entry); if (found) - return present && entry.source == ATTR_MANIFEST_WORKTREE && - !memcmp(entry.hash, observed, algo->rawsz); + return entry && entry->source == ATTR_MANIFEST_WORKTREE && + !memcmp(entry->hash, observed, algo->rawsz); if (!fstatat(parent_fd, basename, &st, AT_SYMLINK_NOFOLLOW) || errno != ENOENT) return 0; pos = index_name_pos(istate, name, strlen(name)); if (pos < 0) - return !present; + return !entry; indexed = istate->cache[pos]; if (!S_ISREG(indexed->ce_mode) || ce_stage(indexed) || ce_skip_worktree(indexed) || ce_intent_to_add(indexed) || (indexed->ce_flags & CE_VALID)) return 0; - return present && entry.source == ATTR_MANIFEST_INDEX && - !memcmp(entry.hash, indexed->oid.hash, algo->rawsz); + return entry && entry->source == ATTR_MANIFEST_INDEX && + !memcmp(entry->hash, indexed->oid.hash, algo->rawsz); } #endif @@ -236,12 +245,15 @@ int clean_status_manifest_directory_unchanged( struct clean_status_index_snapshot snapshot; struct clean_status_config_digest config; struct attr_fingerprint attrs; + struct attr_manifest_cursor manifest_cursor; + struct attr_manifest_entry manifest_entry; struct string_list candidates = STRING_LIST_INIT_DUP; struct strbuf candidate = STRBUF_INIT; const struct git_hash_algo *algo = istate->repo->hash_algo; + const char *previous = NULL; unsigned int first, namespace_unstable = 0; - size_t len; - int pos, pinned = 0, safe = 0; + size_t len, previous_len = 0; + int pos, manifest_ret, pinned = 0, safe = 0; uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | FSMONITOR_CLEAN_PROOF_FULL_INDEX; @@ -302,8 +314,7 @@ int clean_status_manifest_directory_unchanged( strbuf_addstr(&candidate, directory); strbuf_addstr(&candidate, GITATTRIBUTES_FILE); - string_list_insert(&candidates, candidate.buf); - /* Bound attribute-source I/O, not the affected in-memory entries. */ + string_list_append(&candidates, candidate.buf); for (unsigned int i = first; i < istate->cache_nr && starts_with(istate->cache[i]->name, directory); i++) { const struct cache_entry *ce = istate->cache[i]; @@ -314,21 +325,49 @@ int clean_status_manifest_directory_unchanged( S_ISSPARSEDIR(ce->ce_mode)) goto done; while ((slash = strchr(slash, '/')) != NULL) { - strbuf_reset(&candidate); - strbuf_add(&candidate, ce->name, - slash - ce->name + 1); - strbuf_addstr(&candidate, GITATTRIBUTES_FILE); - string_list_insert(&candidates, candidate.buf); - if (candidates.nr > 64) - goto done; + size_t parent_len = slash - ce->name; + + if (!previous || previous_len <= parent_len || + previous[parent_len] != '/' || + memcmp(previous, ce->name, parent_len)) { + strbuf_reset(&candidate); + strbuf_add(&candidate, ce->name, parent_len + 1); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + string_list_append(&candidates, candidate.buf); + } slash++; } + previous = ce->name; + previous_len = ce_namelen(ce); } - for (size_t i = 0; i < candidates.nr; i++) + string_list_sort(&candidates); + string_list_remove_duplicates(&candidates, 0); + if (attr_manifest_cursor_init(&manifest_cursor, + state->manifest.current.buf, + state->manifest.current.len, algo)) + goto done; + manifest_ret = attr_manifest_cursor_next(&manifest_cursor, + &manifest_entry); + for (size_t i = 0; i < candidates.nr; i++) { + const char *name = candidates.items[i].string; + const struct attr_manifest_entry *entry = NULL; + + while (manifest_ret > 0 && + directory_manifest_entry_path_compare( + &manifest_entry, name) < 0) + manifest_ret = attr_manifest_cursor_next( + &manifest_cursor, &manifest_entry); + if (manifest_ret < 0) + goto done; + if (manifest_ret > 0 && + !directory_manifest_entry_path_compare( + &manifest_entry, name)) + entry = &manifest_entry; if (!directory_attribute_source_matches( - istate, path, candidates.items[i].string, + istate, path, name, entry, first + i)) goto done; + } semantic_verify_path_free(path, &namespace_unstable, NULL); path = NULL; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index a253bd28ce6302..7412ef7e55682b 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -4204,21 +4204,28 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'directory closure rejects too many distinct attribute candidates' ' + 'directory closure reuses more than 64 authenticated attribute sources' ' test_when_finished "rm -rf directory-many-attribute-candidates" && test_create_repo directory-many-attribute-candidates && ( cd directory-many-attribute-candidates && sane_unset GIT_TEST_SPLIT_INDEX && - mkdir cached sibling && + mkdir before cached sibling && for descendant in $(test_seq 1 64) do mkdir "cached/child-$descendant" && printf "aaaa\n" \ >"cached/child-$descendant/tracked" || return 1 done && + test_write_lines "# unchanged before" \ + >before/.gitattributes && + test_write_lines "# unchanged inside" \ + >cached/child-32/.gitattributes && + test_write_lines "# unchanged after" \ + >sibling/.gitattributes && + test_write_lines retained >before/tracked && test_write_lines retained >sibling/tracked && - git add cached sibling && + git add before cached sibling && git commit -qm base && git config core.trustctime false && git config core.checkStat minimal && @@ -4247,11 +4254,11 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_cmp .git/expect .git/actual && test_grep "^1 \\.M .* cached/child-1/tracked$" .git/actual && test_grep "^? sibling/visible$" .git/actual && - test_trace2_data fsmonitor semantic/manifest-scan-count 2 \ + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ <.git/status.trace && - ! test_trace2_data fsmonitor semantic/manifest-directory-reused 1 \ + test_trace2_data fsmonitor semantic/manifest-directory-reused 1 \ <.git/status.trace && - ! test_trace2_data status \ + test_trace2_data status \ fsmonitor_token/reused-semantic-subtrees 1 \ <.git/status.trace && test_trace2_data fsmonitor token_closure/accepted 1 \ @@ -4262,8 +4269,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'directory closure rejects raced attributes and rechecks raced excludes' ' - test_when_finished "rm -rf directory-race-attributes directory-race-ignore" && - for mutation in attributes ignore + test_when_finished "rm -rf directory-race-attributes \ + directory-race-nested-attributes directory-race-ignore" && + for mutation in attributes nested-attributes ignore do test_create_repo "directory-race-$mutation" && ( @@ -4281,6 +4289,16 @@ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_write_lines "$descendant" \ >"cached/retained-$descendant" || return 1 done && + if test "$mutation" = nested-attributes + then + for descendant in $(test_seq 1 64) + do + mkdir "cached/child-$descendant" && + test_write_lines "$descendant" \ + >"cached/child-$descendant/tracked" || + return 1 + done + fi && for sibling in $(test_seq 1 8) do mkdir "sibling-$sibling" && @@ -4289,6 +4307,10 @@ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ done && git add .gitignore cached/.gitignore cached/tracked \ cached/retained-* sibling-* && + if test "$mutation" = nested-attributes + then + git add cached/child-* + fi && git commit -qm base && git config core.trustctime false && git config core.checkStat minimal && @@ -4327,6 +4349,10 @@ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ then test_write_lines "tracked text eol=crlf" \ >cached/.gitattributes + elif test "$mutation" = nested-attributes + then + test_write_lines "tracked text eol=crlf" \ + >cached/child-64/.gitattributes else test_write_lines "!junk.ignored" >cached/.gitignore fi && @@ -4343,10 +4369,18 @@ test_expect_success PIPE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_cmp .git/expect .git/actual && test_grep "^1 \\.M .* cached/tracked$" .git/actual && test_grep "^? sibling-1/visible$" .git/actual && - if test "$mutation" = attributes + if test "$mutation" = attributes || + test "$mutation" = nested-attributes then - test_grep "^? cached/\\.gitattributes$" \ - .git/actual && + if test "$mutation" = attributes + then + test_grep "^? cached/\\.gitattributes$" \ + .git/actual + else + test_grep \ + "^? cached/child-64/\\.gitattributes$" \ + .git/actual + fi && test_trace2_data fsmonitor \ semantic/manifest-scan-count 2 \ <.git/status.trace && From d01b4d613053bce5efabaae99a03e0bed976f0a8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:43:57 -0500 Subject: [PATCH 364/403] fsmonitor: bound nested attribute invalidation to its index cone 4d1d52613d (fsmonitor: invalidate conversion state for attribute-file events, 2026-07-24) checks every cache-entry name for each changed .gitattributes file. Only entries below that file's directory are invalidated, but several nested sources still require several complete in-memory index walks. Find the first matching entry with the sparse-aware binary search and stop at the end of the contiguous prefix. Do not expand a sparse index merely to locate the range. Keep the full walk for root attributes, case-insensitive matching, and Windows or Cygwin paths whose separators cannot be searched in the byte-sorted index. Cover middle and final ranges, absent prefixes, a collapsed sparse directory, case-folding collisions, and Windows-style separators. The change bounds name comparisons; it does not weaken attribute or content invalidation. --- fsmonitor.c | 22 ++++- t/unit-tests/u-fsmonitor-attributes.c | 119 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index a8dbf21b17a6f9..a217b42baf27e4 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -492,7 +492,8 @@ int fsmonitor_invalidate_attributes_path(struct index_state *istate, { size_t len = strlen(name), base, attr_len = strlen(GITATTRIBUTES_FILE); size_t invalidated = 0; - unsigned int i; + unsigned int first = 0, i; + int bounded = 0; while (len && is_dir_sep(name[len - 1])) len--; @@ -504,12 +505,27 @@ int fsmonitor_invalidate_attributes_path(struct index_state *istate, return 0; git_attr_invalidate_all(); - for (i = 0; i < istate->cache_nr; i++) { + if (base && !repo_ignore_case(the_repository)) { +#if defined(GIT_WINDOWS_NATIVE) || defined(__CYGWIN__) + bounded = !memchr(name, '\\', base); +#else + bounded = 1; +#endif + } + if (bounded) { + int pos = index_name_pos_sparse(istate, name, base); + + first = pos < 0 ? -pos - 1 : pos; + } + for (i = first; i < istate->cache_nr; i++) { struct cache_entry *ce = istate->cache[i]; if (base && (ce->ce_namelen < base || - fspathncmp(ce->name, name, base))) + fspathncmp(ce->name, name, base))) { + if (bounded) + break; continue; + } fsmonitor_invalidate_cache_entry(ce); invalidated++; } diff --git a/t/unit-tests/u-fsmonitor-attributes.c b/t/unit-tests/u-fsmonitor-attributes.c index 3eedefca7aad0b..a6784273925694 100644 --- a/t/unit-tests/u-fsmonitor-attributes.c +++ b/t/unit-tests/u-fsmonitor-attributes.c @@ -1,3 +1,5 @@ +#define USE_THE_REPOSITORY_VARIABLE + #include "unit-test.h" #include "fsmonitor.h" #include "fsmonitor-ll.h" @@ -73,6 +75,123 @@ void test_fsmonitor_attributes__root_source_invalidates_every_entry(void) release_index(&istate); } +void test_fsmonitor_attributes__bounds_middle_and_final_nested_cones(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 6); + istate.cache_alloc = istate.cache_nr = 6; + add_entry(&istate, 0, "before/tracked"); + add_entry(&istate, 1, "middle/first"); + add_entry(&istate, 2, "middle/nested/tracked"); + add_entry(&istate, 3, "middle/second"); + add_entry(&istate, 4, "middle0-sibling/tracked"); + add_entry(&istate, 5, "zzz/tracked"); + + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, "middle/nested/.gitattributes")); + cl_assert(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(!(istate.cache[2]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[3]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(istate.cache[4]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(istate.cache[5]->ce_flags & CE_FSMONITOR_VALID); + + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, "zzz/.gitattributes")); + cl_assert(!(istate.cache[5]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[4]->ce_flags & CE_FSMONITOR_VALID); + release_index(&istate); +} + +void test_fsmonitor_attributes__missing_cone_preserves_every_entry(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_entry(&istate, 0, "before/tracked"); + add_entry(&istate, 1, "later/tracked"); + + cl_assert(!fsmonitor_invalidate_attributes_path( + &istate, "between/.gitattributes")); + cl_assert(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + release_index(&istate); +} + +void test_fsmonitor_attributes__does_not_expand_sparse_directory(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + istate.sparse_index = INDEX_COLLAPSED; + add_entry(&istate, 0, "cone/"); + istate.cache[0]->ce_mode = S_IFDIR; + add_entry(&istate, 1, "outside/tracked"); + + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, "cone/.gitattributes")); + cl_assert_equal_i(istate.sparse_index, INDEX_COLLAPSED); + cl_assert_equal_i(istate.cache_nr, 2); + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + release_index(&istate); +} + +void test_fsmonitor_attributes__casefolded_cones_keep_full_fallback(void) +{ + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + int previous_ignore_case = + the_repository->config_values_private_.ignore_case; + int previously_initialized = the_repository->initialized; + + CALLOC_ARRAY(istate.cache, 3); + istate.cache_alloc = istate.cache_nr = 3; + add_entry(&istate, 0, "A/first"); + add_entry(&istate, 1, "M/untouched"); + add_entry(&istate, 2, "a/second"); + the_repository->initialized = 1; + the_repository->config_values_private_.ignore_case = 1; + + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, "a/.gitattributes")); + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + cl_assert(!(istate.cache[2]->ce_flags & CE_FSMONITOR_VALID)); + + the_repository->config_values_private_.ignore_case = + previous_ignore_case; + the_repository->initialized = previously_initialized; + release_index(&istate); +} + +void test_fsmonitor_attributes__windows_separator_keeps_full_fallback(void) +{ +#if !defined(GIT_WINDOWS_NATIVE) && !defined(__CYGWIN__) + cl_skip(); +#else + struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; + struct index_state istate = INDEX_STATE_INIT(&repo); + + CALLOC_ARRAY(istate.cache, 2); + istate.cache_alloc = istate.cache_nr = 2; + add_entry(&istate, 0, "alpha/tracked"); + add_entry(&istate, 1, "beta/tracked"); + + cl_assert(fsmonitor_invalidate_attributes_path( + &istate, "alpha\\.gitattributes")); + cl_assert(!(istate.cache[0]->ce_flags & CE_FSMONITOR_VALID)); + cl_assert(istate.cache[1]->ce_flags & CE_FSMONITOR_VALID); + release_index(&istate); +#endif +} + void test_fsmonitor_attributes__disabled_provider_preserves_skipped_stat(void) { struct repository repo = { .hash_algo = &hash_algos[GIT_HASH_SHA1] }; From 53b813d8775806d2895fc27c2dae53f3cc4c7380 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:04:47 -0500 Subject: [PATCH 365/403] status: preserve index proofs during temporary fsmonitor backoff 60077b92ac (status: preserve semantic history across scoped and index changes, 2026-08-11) disables the builtin provider temporarily after an inotify watch-limit failure. Reading the index in that mode correctly discards its fsmonitor-valid bits, but an ordinary status can then write that temporary state back. A short outage consequently removes the durable tracked and untracked proofs and makes later commands rebuild them. Remember why the provider was disabled, without treating it as usable. During authenticated watch-limit backoff, let status perform its normal strong checks but skip optional index, history, and sidecar publication. Explicitly disabling fsmonitor and commands that must write the index retain their existing behavior. Each explicit settings transition clears the temporary reason. Exercise the real marker format in main and linked worktrees. Repeated status calls must agree with a provider-disabled oracle while preserving the physical index and checkpoint byte for byte. Cover recovery after the marker is removed, mandatory writes, and explicit disablement too. --- builtin/commit.c | 13 +- fsmonitor-settings.c | 15 ++ fsmonitor-settings.h | 1 + t/helper/test-fsmonitor-client.c | 37 +++++ t/t7536-fsmonitor-watch-limit-backoff.sh | 202 +++++++++++++++++++++++ 5 files changed, 263 insertions(+), 5 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index e44f0f76ca81f6..363797652eef2f 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1888,6 +1888,7 @@ struct repository *repo UNUSED) int save_history_after_write = 0; int deferred_scoped_history = 0; int guarded_scoped_history_source = 0; + int optional_status_writes; struct clean_status_index_snapshot scoped_history_source = { .fd = -1, }; @@ -1962,6 +1963,8 @@ struct repository *repo UNUSED) builtin_status_usage, 0); finalize_colopts(&s.colopts, -1); finalize_deferred_config(&s); + optional_status_writes = use_optional_locks() && + !fsm_settings__is_watch_limit_backoff(the_repository); handle_untracked_files_arg(&s); handle_ignored_arg(&s); @@ -2013,7 +2016,7 @@ struct repository *repo UNUSED) return 0; } } - if (normal_clean_query && use_optional_locks() && + if (normal_clean_query && optional_status_writes && clean_status_identity_is_durable()) reissue_clean_sidecar = clean_status_sidecar_needs_reissue( @@ -2025,7 +2028,7 @@ struct repository *repo UNUSED) if (isatty(2)) clean_status_enable_progress(the_repository); } - if (use_optional_locks()) + if (optional_status_writes) clean_status_require_external_history_source(the_repository); repo_read_index(the_repository); if (sidecar_provider_reset) { @@ -2046,7 +2049,7 @@ struct repository *repo UNUSED) trace2_data_intmax("status", the_repository, "clean-proof/provider-reset-carried", 1); } - if (use_optional_locks()) { + if (optional_status_writes) { deferred_scoped_history = clean_status_defer_scoped_history_capture( &s, &scoped_history_source); @@ -2062,7 +2065,7 @@ struct repository *repo UNUSED) clean_status_capture_external_history_source( the_repository->index); } - if (normal_clean_query && use_optional_locks() && + if (normal_clean_query && optional_status_writes && clean_status_identity_is_durable() && (reissue_clean_sidecar || clean_status_external_history_was_restored( @@ -2075,7 +2078,7 @@ struct repository *repo UNUSED) s.show_untracked_files != SHOW_NO_UNTRACKED_FILES && !s.show_ignored_mode); - if (use_optional_locks()) + if (optional_status_writes) fd = repo_hold_locked_index(the_repository, &index_lock, 0); else fd = -1; diff --git a/fsmonitor-settings.c b/fsmonitor-settings.c index a0c12533413013..0ae8a8c8da956a 100644 --- a/fsmonitor-settings.c +++ b/fsmonitor-settings.c @@ -15,6 +15,7 @@ struct fsmonitor_settings { enum fsmonitor_mode mode; enum fsmonitor_reason reason; char *hook_path; + unsigned watch_limit_backoff : 1; }; /* @@ -124,6 +125,7 @@ static void lookup_fsmonitor_settings(struct repository *r) trace2_data_intmax("fsm_client", r, "settings/inotify-watch-limit-backoff", 1); fsm_settings__set_disabled(r); + r->settings.fsmonitor->watch_limit_backoff = 1; } else if (bool_value) fsm_settings__set_ipc(r); else @@ -159,6 +161,15 @@ enum fsmonitor_mode fsm_settings__get_mode(struct repository *r) return r->settings.fsmonitor->mode; } +int fsm_settings__is_watch_limit_backoff(struct repository *r) +{ + if (!r->settings.fsmonitor) + lookup_fsmonitor_settings(r); + + return r->settings.fsmonitor->mode == FSMONITOR_MODE_DISABLED && + r->settings.fsmonitor->watch_limit_backoff; +} + const char *fsm_settings__get_hook_path(struct repository *r) { if (!r->settings.fsmonitor) @@ -185,6 +196,7 @@ void fsm_settings__set_ipc(struct repository *r) r->settings.fsmonitor->mode = FSMONITOR_MODE_IPC; r->settings.fsmonitor->reason = reason; + r->settings.fsmonitor->watch_limit_backoff = 0; FREE_AND_NULL(r->settings.fsmonitor->hook_path); } @@ -206,6 +218,7 @@ void fsm_settings__set_hook(struct repository *r, const char *path) r->settings.fsmonitor->mode = FSMONITOR_MODE_HOOK; r->settings.fsmonitor->reason = reason; + r->settings.fsmonitor->watch_limit_backoff = 0; FREE_AND_NULL(r->settings.fsmonitor->hook_path); r->settings.fsmonitor->hook_path = strdup(path); } @@ -217,6 +230,7 @@ void fsm_settings__set_disabled(struct repository *r) r->settings.fsmonitor->mode = FSMONITOR_MODE_DISABLED; r->settings.fsmonitor->reason = FSMONITOR_REASON_OK; + r->settings.fsmonitor->watch_limit_backoff = 0; FREE_AND_NULL(r->settings.fsmonitor->hook_path); } @@ -228,6 +242,7 @@ void fsm_settings__set_incompatible(struct repository *r, r->settings.fsmonitor->mode = FSMONITOR_MODE_INCOMPATIBLE; r->settings.fsmonitor->reason = reason; + r->settings.fsmonitor->watch_limit_backoff = 0; FREE_AND_NULL(r->settings.fsmonitor->hook_path); } diff --git a/fsmonitor-settings.h b/fsmonitor-settings.h index ab02e3995ee8f4..07e6081a5d878a 100644 --- a/fsmonitor-settings.h +++ b/fsmonitor-settings.h @@ -30,6 +30,7 @@ void fsm_settings__set_incompatible(struct repository *r, enum fsmonitor_reason reason); enum fsmonitor_mode fsm_settings__get_mode(struct repository *r); +int fsm_settings__is_watch_limit_backoff(struct repository *r); const char *fsm_settings__get_hook_path(struct repository *r); enum fsmonitor_reason fsm_settings__get_reason(struct repository *r); diff --git a/t/helper/test-fsmonitor-client.c b/t/helper/test-fsmonitor-client.c index b5e428a0730a61..653d09455382bb 100644 --- a/t/helper/test-fsmonitor-client.c +++ b/t/helper/test-fsmonitor-client.c @@ -8,6 +8,7 @@ #include "test-tool.h" #include "parse-options.h" #include "fsmonitor-ipc.h" +#include "path.h" #include "read-cache-ll.h" #include "repository.h" #include "setup.h" @@ -85,6 +86,38 @@ static int do_send_flush(void) return 0; } +static int do_record_watch_limit(void) +{ +#if defined(__linux__) || defined(__APPLE__) + struct strbuf identity = STRBUF_INIT; + struct stat st; + char *path = NULL; + int ret = 1; + + if (fsmonitor_ipc__get_worktree_identity(the_repository, &identity)) { + error("could not identify the fsmonitor worktree"); + goto done; + } + fsmonitor_ipc__record_watch_limit_failure(identity.buf); + path = repo_git_path(the_repository, + "fsmonitor--daemon.inotify-limit"); + if (lstat(path, &st) || !S_ISREG(st.st_mode) || + st.st_uid != geteuid() || st.st_nlink != 1 || + (st.st_mode & 077)) { + error("could not record an owned fsmonitor watch-limit marker"); + goto done; + } + ret = 0; + +done: + free(path); + strbuf_release(&identity); + return ret; +#else + return error("watch-limit markers are not supported on this platform"); +#endif +} + struct hammer_thread_data { pthread_t pthread_id; @@ -189,6 +222,7 @@ int cmd__fsmonitor_client(int argc, const char **argv) const char * const fsmonitor_client_usage[] = { "test-tool fsmonitor-client query []", "test-tool fsmonitor-client flush", + "test-tool fsmonitor-client record-watch-limit", "test-tool fsmonitor-client hammer [] [] []", NULL, }; @@ -218,6 +252,9 @@ int cmd__fsmonitor_client(int argc, const char **argv) if (!strcmp(subcmd, "flush")) return !!do_send_flush(); + if (!strcmp(subcmd, "record-watch-limit")) + return !!do_record_watch_limit(); + if (!strcmp(subcmd, "hammer")) return !!do_hammer(token, nr_threads, nr_requests); diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index c80a4002809a61..0f3eb532cbb5fa 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -3,6 +3,7 @@ test_description='fsmonitor watch-limit backoff authenticates optional markers' . ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh if ! test_have_prereq FSMONITOR_DAEMON then @@ -21,6 +22,11 @@ esac sane_unset GIT_TEST_SPLIT_INDEX GIT_TEST_FSMONITOR +test_lazy_prereq UNTRACKED_CACHE ' + { git update-index --test-untracked-cache; ret=$?; } && + test $ret -ne 1 +' + setup_backoff_marker_fixture () { test_create_repo "$1" && ( @@ -60,6 +66,51 @@ check_rejected_backoff_marker () { ) } +assert_backoff_full_proof () { + perl - "$1" <<-\EOF + binmode STDIN; + open my $input, "<", $ARGV[0] or die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my %tokens; + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + if ($name eq "FSCF") { + my $flags = unpack("N", substr($payload, 8, 4)); + die "unbound FSCF flags $flags\n" if $flags != 15; + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{$name} = substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + die "invalid $name token\n" if $end < 0; + $tokens{$name} = substr($payload, 4, $end - 4); + } + } + die "missing builtin provider token\n" unless + $tokens{"FSMN"} =~ /\Abuiltin:/; + die "mismatched tracked provider token\n" unless + $tokens{"FSMN"} eq $tokens{"FSCF"}; + die "mismatched untracked provider token\n" unless + $tokens{"FSMN"} eq $tokens{"FSUC"}; + EOF +} + +assert_backoff_history_unchanged () { + test_cmp_bin "$1/index.before-backoff" "$1/index" && + test_cmp_bin "$1/checkpoint.before-backoff" "$2" && + if test -f "$1/sidecar.before-backoff" + then + test_cmp_bin "$1/sidecar.before-backoff" "$1/index.csts" + else + test_path_is_missing "$1/index.csts" + fi && + test_path_is_missing "$1/index.lock" +} + test_expect_success PIPE,PERL_TEST_HELPERS \ 'a FIFO watch-limit marker never blocks ordinary status' ' setup_backoff_marker_fixture marker-fifo && @@ -92,4 +143,155 @@ test_expect_success PERL_TEST_HELPERS \ test_path_is_file "$marker" ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'temporary backoff preserves main and linked index and history proofs' ' + test_create_repo watch-backoff-main && + test_when_finished "git -C watch-backoff-main -c core.fsmonitor=false \ + worktree remove --force ../watch-backoff-linked \ + >/dev/null 2>&1 || :" && + ( + cd watch-backoff-main && + test_commit base tracked && + git worktree add --detach ../watch-backoff-linked HEAD && + git config core.autocrlf false && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../watch-backoff-linked" + do + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + test-tool chmtime -120 "$worktree/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + assert_backoff_full_proof "$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ + git -C "$worktree" status --short \ + >"$gitdir/checkpoint.status" && + test_must_be_empty "$gitdir/checkpoint.status" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/checkpoint.trace" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status \ + >"$gitdir/sidecar.status" && + find "$gitdir" -maxdepth 1 -type f \ + -name "index.csh1.*" >"$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/checkpoints" && + checkpoint=$(cat "$gitdir/checkpoints") && + assert_backoff_full_proof "$gitdir/index" && + cp "$gitdir/index" "$gitdir/index.before-backoff" && + cp "$checkpoint" "$gitdir/checkpoint.before-backoff" && + if test -f "$gitdir/index.csts" + then + cp "$gitdir/index.csts" \ + "$gitdir/sidecar.before-backoff" + fi && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool -C "$worktree" fsmonitor-client \ + record-watch-limit && + marker="$gitdir/fsmonitor--daemon.inotify-limit" && + test_path_is_file "$marker" && + test_line_count = 3 "$marker" && + test_grep "^inotify-limit-v1$" "$marker" && + test_write_lines changed >"$worktree/tracked" && + test_write_lines visible >"$worktree/visible" && + git -C "$worktree" -c core.fsmonitor=false \ + -c core.untrackedCache=false --no-optional-locks \ + status --porcelain=v2 >"$gitdir/expected" && + test_grep "^1 \\.M .* tracked$" "$gitdir/expected" && + test_grep "^? visible$" "$gitdir/expected" && + assert_backoff_history_unchanged "$gitdir" "$checkpoint" && + for attempt in first second + do + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$gitdir/$attempt.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$attempt.actual" && + test_cmp "$gitdir/expected" \ + "$gitdir/$attempt.actual" && + test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <"$gitdir/$attempt.trace" && + ! test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/$attempt.trace" && + test_region ! fsmonitor history_logical_digest \ + "$gitdir/$attempt.trace" && + test_region ! index do_write_index \ + "$gitdir/$attempt.trace" && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$gitdir/$attempt.trace" && + test_path_is_file "$marker" && + assert_backoff_history_unchanged \ + "$gitdir" "$checkpoint" || return 1 + done && + rm "$marker" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$gitdir/recovery.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/recovery.actual" && + test_cmp "$gitdir/expected" "$gitdir/recovery.actual" && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <"$gitdir/recovery.trace" && + test_path_is_missing "$marker" && + assert_backoff_full_proof "$gitdir/index" || return 1 + done + ) +' + +test_expect_success PERL_TEST_HELPERS \ + 'mandatory writers still update the index during temporary backoff' ' + setup_backoff_marker_fixture watch-backoff-mandatory && + ( + cd watch-backoff-mandatory && + cp .git/index .git/index.before && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/mandatory.trace" \ + git add tracked && + test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/mandatory.trace && + test_region index do_write_index .git/mandatory.trace && + ! cmp .git/index.before .git/index && + test_grep ! FSMN .git/index && + test_grep ! FSUC .git/index && + git -c core.fsmonitor=false diff --cached --name-only \ + >.git/staged && + test_grep "^tracked$" .git/staged + ) +' + +test_expect_success PERL_TEST_HELPERS \ + 'an explicitly disabled fsmonitor still permits optional index writes' ' + setup_backoff_marker_fixture watch-backoff-explicit-disable && + ( + cd watch-backoff-explicit-disable && + cp .git/index .git/index.before && + GIT_TRACE2_EVENT="$PWD/.git/disabled.trace" \ + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/disabled.actual && + test_cmp .git/expect .git/disabled.actual && + test_region index do_write_index .git/disabled.trace && + ! cmp .git/index.before .git/index && + test_grep ! FSMN .git/index && + ! test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/disabled.trace + ) +' + test_done From a03fa3b0631b7d2c939cd16ccf6fd3ba269cc6f1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:15:47 -0500 Subject: [PATCH 366/403] checkout-index: restore external proofs before updating the index 60077b92ac (status: preserve semantic history across scoped and index changes, 2026-08-11) lets external history recover proofs removed by a legacy index writer. checkout-index computes the configuration digest, but never enables that recovery before reading the index. A subsequent stat refresh can therefore persist an index without the paired proofs and leave the next status to rebuild them. Parse the checkout options before reading the index. Enable external history only for an ordinary canonical-index update with reliable stat data and the builtin provider. Keep temporary files, prefixes, alternate indexes, and non-default stages outside that admission. The existing no-op checkout still skips its index write; restoring a proof alone is not a reason to rewrite the index. Cover main and linked worktrees with an external-only checkpoint. A real dirty checkout must restore the proof, update the index, and leave a clean no-scan follower. A clean checkout must remain byte-identical. Also replay a changed attributes file honestly and verify the resulting CRLF conversion, downgraded proof, and independent status oracle. --- builtin/checkout-index.c | 15 +- t/t7519-status-fsmonitor.sh | 278 ++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+), 5 deletions(-) diff --git a/builtin/checkout-index.c b/builtin/checkout-index.c index ac17acea58233e..f474bf524da0f6 100644 --- a/builtin/checkout-index.c +++ b/builtin/checkout-index.c @@ -12,6 +12,7 @@ #include "clean-status-config.h" #include "config.h" #include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "hook.h" #include "lockfile.h" @@ -277,13 +278,8 @@ int cmd_checkout_index(int argc, prepare_repo_settings(repo); repo->settings.command_requires_full_index = 0; - if (repo_read_index(repo) < 0) { - die("invalid cache"); - } - argc = parse_options(argc, argv, prefix, builtin_checkout_index_options, builtin_checkout_index_usage, 0); - state.istate = repo->index; state.force = force; state.quiet = quiet; state.not_new = not_new; @@ -298,6 +294,15 @@ int cmd_checkout_index(int argc, die(_("options '%s' and '%s' cannot be used together"), "--stage=all", "--no-temp"); + if (index_opt && !state.base_dir_len && !to_tempfile && + !checkout_stage && !getenv(INDEX_ENVIRONMENT) && + fstat_is_reliable() && + fsm_settings__get_mode(repo) == FSMONITOR_MODE_IPC) + clean_status_enable_external_history(repo); + if (repo_read_index(repo) < 0) + die("invalid cache"); + state.istate = repo->index; + /* * when --prefix is specified we do not want to update cache. */ diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 7412ef7e55682b..c56393794a5b28 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1716,6 +1716,284 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'checkout-index updates restore external-only worktree proofs' ' + test_when_finished "rm -rf checkout-external-only checkout-external-linked" && + test_create_repo checkout-external-only && + ( + cd checkout-external-only && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + git worktree add --detach ../checkout-external-linked HEAD && + test-tool chmtime -120 tracked sibling \ + ../checkout-external-linked/tracked \ + ../checkout-external-linked/sibling && + git update-index --refresh && + git -C ../checkout-external-linked update-index --refresh && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + cat >.git/remove-checkout-proofs.pl <<-\EOF && + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + local $/; + my $index = ; + my $rawsz = $ARGV[0] eq "sha256" ? 32 : 20; + for my $name ("FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + substr($index, $offset, 8 + $size, ""); + } + my $payload = substr($index, 0, -$rawsz); + print $payload, $rawsz == 32 ? sha256($payload) : sha1($payload); + EOF + strip_checkout_proofs="$PWD/.git/remove-checkout-proofs.pl" && + for worktree in "$PWD" "$PWD/../checkout-external-linked" + do + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + for mode in noop ordinary temp prefix alternate attributes + do + test-tool chmtime -120 "$worktree/tracked" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git -C "$worktree" update-index --refresh && + rm -f "$worktree/.gitattributes" \ + "$gitdir"/index.csh1.* \ + "$gitdir"/index.cswi.* \ + "$gitdir/index.csts" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$mode.prime" && + test_must_be_empty "$gitdir/$mode.prime" && + test_fsmonitor_full_proof "$gitdir/index" paired && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkpoint.trace" \ + git -C "$worktree" status --short \ + >"$gitdir/$mode.checkpoint" && + test_must_be_empty "$gitdir/$mode.checkpoint" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/$mode.checkpoint.trace" && + find "$gitdir" -maxdepth 1 -type f \ + -name "index.csh1.*" >"$gitdir/$mode.csh" && + test_line_count = 1 "$gitdir/$mode.csh" && + checkpoint=$(cat "$gitdir/$mode.csh") && + cp "$checkpoint" "$gitdir/$mode.checkpoint.before" && + perl "$strip_checkout_proofs" "$(test_oid algo)" \ + <"$gitdir/index" >"$gitdir/index.foreign" && + mv "$gitdir/index.foreign" "$gitdir/index" && + test_grep FSMN "$gitdir/index" && + test_grep UNTR "$gitdir/index" && + test_grep ! FSUC "$gitdir/index" && + test_grep ! FSCF "$gitdir/index" && + cp "$gitdir/index" "$gitdir/$mode.stripped.index" && + + case "$mode" in + noop) + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkout.trace" \ + git -C "$worktree" checkout-index \ + -f -u tracked && + test_trace2_data fsmonitor \ + history/external-restored 1 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data index \ + extension/fsmn/read/token builtin:test:3 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data index \ + extension/fsmn/read/token builtin:test:1 \ + <"$gitdir/$mode.checkout.trace" && + test_region ! index do_write_index \ + "$gitdir/$mode.checkout.trace" && + test_cmp_bin "$gitdir/$mode.stripped.index" \ + "$gitdir/index" + ;; + ordinary) + test_write_lines modified >"$worktree/tracked" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 \ + >"$gitdir/$mode.expected" && + test_line_count = 1 "$gitdir/$mode.expected" && + test_grep "^1 \\.M .* tracked$" \ + "$gitdir/$mode.expected" && + test_cmp_bin "$gitdir/$mode.stripped.index" \ + "$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkout.trace" \ + git -C "$worktree" checkout-index \ + -f -u tracked && + test_trace2_data fsmonitor \ + history/external-restored 1 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data fsmonitor apply_count 1 \ + <"$gitdir/$mode.checkout.trace" && + test_region index do_write_index \ + "$gitdir/$mode.checkout.trace" && + test_fsmonitor_full_proof "$gitdir/index" \ + paired && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$mode.checkout.trace" && + cp "$gitdir/index" \ + "$gitdir/$mode.before-status" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$mode.status" && + test_must_be_empty "$gitdir/$mode.status" && + test_cmp_bin "$gitdir/$mode.before-status" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$mode.status.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$mode.status.trace" + ;; + temp) + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkout.trace" \ + git -C "$worktree" checkout-index \ + -u --temp tracked \ + >"$gitdir/$mode.output" && + temp_path=$(cut -f1 "$gitdir/$mode.output") && + test_path_is_file "$worktree/$temp_path" && + rm "$worktree/$temp_path" && + ! test_trace2_data fsmonitor \ + history/external-restored 1 \ + <"$gitdir/$mode.checkout.trace" && + test_cmp_bin "$gitdir/$mode.stripped.index" \ + "$gitdir/index" + ;; + prefix) + mkdir "$gitdir/checkout-prefix" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkout.trace" \ + git -C "$worktree" checkout-index \ + -f -u \ + --prefix="$gitdir/checkout-prefix/" \ + tracked && + test_path_is_file \ + "$gitdir/checkout-prefix/tracked" && + ! test_trace2_data fsmonitor \ + history/external-restored 1 \ + <"$gitdir/$mode.checkout.trace" && + test_cmp_bin "$gitdir/$mode.stripped.index" \ + "$gitdir/index" + ;; + alternate) + cp "$gitdir/index" "$gitdir/alternate.index" && + GIT_INDEX_FILE="$gitdir/alternate.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkout.trace" \ + git -C "$worktree" checkout-index \ + -f -u tracked && + ! test_trace2_data fsmonitor \ + history/external-restored 1 \ + <"$gitdir/$mode.checkout.trace" && + test_cmp_bin "$gitdir/$mode.stripped.index" \ + "$gitdir/index" + ;; + attributes) + test_write_lines "tracked text eol=crlf" \ + >"$worktree/.gitattributes" && + test_write_lines modified >"$worktree/tracked" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 \ + >"$gitdir/$mode.dirty" && + test_line_count = 2 "$gitdir/$mode.dirty" && + test_grep "^1 \\.M .* tracked$" \ + "$gitdir/$mode.dirty" && + test_grep "^? \\.gitattributes$" \ + "$gitdir/$mode.dirty" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$gitdir/$mode.checkout.trace" \ + git -C "$worktree" checkout-index \ + -f -u tracked && + test_trace2_data fsmonitor \ + history/external-restored 1 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data fsmonitor apply_count 1 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data fsmonitor \ + semantic/attributes-scope 0 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data fsmonitor \ + semantic/manifest-invalidated 1 \ + <"$gitdir/$mode.checkout.trace" && + test_trace2_data fsmonitor \ + untracked/proof-missing 1 \ + <"$gitdir/$mode.checkout.trace" && + test_region index do_write_index \ + "$gitdir/$mode.checkout.trace" && + test_grep ! FSUC "$gitdir/index" && + perl - "$gitdir/index" <<-\EOF && + binmode STDIN; + open my $input, "<", $ARGV[0] or + die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my $offset = index($index, "FSCF"); + die "missing FSCF extension\n" if $offset < 0; + my $flags = unpack("N", substr($index, $offset + 16, 4)); + die "unexpected FSCF flags $flags\n" if $flags != 9; + EOF + printf "base\r\n" >"$gitdir/$mode.converted" && + test_cmp_bin "$gitdir/$mode.converted" \ + "$worktree/tracked" && + cp "$gitdir/index" "$gitdir/$mode.before-status" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" \ + -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status --porcelain=v2 \ + >"$gitdir/$mode.expected" && + test_line_count = 1 "$gitdir/$mode.expected" && + test_grep "^? \\.gitattributes$" \ + "$gitdir/$mode.expected" && + test_cmp_bin "$gitdir/$mode.before-status" \ + "$gitdir/index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/$mode.status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$mode.status" && + test_cmp "$gitdir/$mode.expected" \ + "$gitdir/$mode.status" && + test_cmp_bin "$gitdir/$mode.before-status" \ + "$gitdir/index" + ;; + esac && + test_cmp_bin "$gitdir/$mode.checkpoint.before" \ + "$checkpoint" || return 1 + done || return 1 + done + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'write-tree preserves authenticated primary and linked index proofs' ' test_when_finished "rm -rf write-tree-bound-proof write-tree-linked" && From 968fd3287b2ba578ffb5b838ac210b8edbdc7391 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:31:26 -0500 Subject: [PATCH 367/403] t7530: reject failed cookie synchronization in the prerequisite ef3af6c978 (t7530: probe fsmonitor without requiring an index write, 2026-08-16) accepts a builtin token as evidence that the native provider can support the clean-status sidecar tests. A failed cookie wait also returns a builtin token, together with a trivial response. The fixture can therefore proceed without establishing the proof that its later sidecar assertions require. Keep the daemon trace and require a seen cookie with no timed-out wait during the prerequisite. This follows the existing native fsmonitor test's treatment of an unavailable synchronization service. It does not turn a successful cookie into a stronger filesystem-ordering guarantee. --- t/t7530-status-clean-sidecar.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 4fb103c2dcff72..14c80d19d4d373 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -23,11 +23,14 @@ test_lazy_prereq DURABLE_FSMONITOR ' test-tool chmtime =-120 tracked && git -c core.fsmonitor=false update-index --refresh && git config core.fsmonitor true && - git fsmonitor--daemon start --start-timeout=10 && + GIT_TRACE_FSMONITOR="$PWD/.git/daemon.trace" \ + git fsmonitor--daemon start --start-timeout=10 && git status --porcelain=v2 >/dev/null && test-tool fsmonitor-client query --token 0 >token && nul_to_q token.filtered && - grep "^builtin:" token.filtered + grep "^builtin:" token.filtered && + grep "cookie-seen:" .git/daemon.trace && + ! grep "cookie_wait timed out" .git/daemon.trace result=$? git fsmonitor--daemon stop >/dev/null 2>&1 || : exit $result From 8b9f22cbfd0b1110714a6e48705905f929f17f4f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:58:22 -0500 Subject: [PATCH 368/403] read-cache: treat damaged optional index witnesses as misses 60077b92ac (status: preserve semantic history across scoped and index changes, 2026-08-11) uses a saved index to recover external worktree proofs. The recovery code opens and authenticates that witness, then reopens its pathname with the fatal main-index reader. Pruning or replacing the optional file between those opens can abort a command whose real index is valid. A FIFO can block before the file-type check. Read the entries from the already-pinned descriptor instead. Share a bounds-checked entry decoder with the main reader, but return a miss for malformed or unsupported witnesses. The optional reader verifies nonzero checksums, rejects split/sparse and resolve-undo state, and installs no acceleration extensions. Its caller still authenticates the checkpoint, logical index, provider replay, and named file identity. Read into owned storage so a concurrent truncate cannot cause an mmap fault. Open optional witnesses and snapshot probes without blocking where the platform supports it. Keep ordinary main-index corruption fatal. Cover both hash formats, index versions, malformed entries and extensions, borrowed-descriptor ownership, and recovery with a valid main index. --- clean-status-history.c | 23 +- clean-status-index.c | 8 +- read-cache-ll.h | 11 + read-cache.c | 383 ++++++++++++--- t/helper/test-read-cache.c | 152 ++++++ t/meson.build | 1 + t/t1602-index-witness.sh | 946 +++++++++++++++++++++++++++++++++++++ 7 files changed, 1463 insertions(+), 61 deletions(-) create mode 100755 t/t1602-index-witness.sh diff --git a/clean-status-history.c b/clean-status-history.c index 8dcd0760909065..cd2aa992e91b60 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1198,6 +1198,19 @@ static void restore_external_untracked_history( } #endif +#if defined(__APPLE__) || SEMANTIC_VERIFY_HAS_ANCHORED_OPEN +static int open_external_history_witness(const char *path) +{ +#ifdef O_NONBLOCK + return open_nofollow(path, O_RDONLY | O_CLOEXEC | O_NONBLOCK); +#else + (void)path; + errno = ENOSYS; + return -1; +#endif +} +#endif + static int restore_external_semantic_history( struct index_state *istate, const struct clean_status_history_checkpoint *checkpoint, @@ -1247,14 +1260,15 @@ static int restore_external_semantic_history( path = clean_status_history_store_witness_path( istate->repo->index_file, proof_namespace, istate->repo->hash_algo); - fd = open_nofollow(path, O_RDONLY | O_CLOEXEC); + fd = open_external_history_witness(path); if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || before.st_nlink != 1 || before.st_uid != geteuid() || clean_status_identity_from_stat(&before_identity, &before) || lstat(path, &after) || before.st_dev != after.st_dev || before.st_ino != after.st_ino) goto done; - do_read_index(&witness, path, 1); + if (read_index_entries_from_fd(&witness, fd)) + goto done; if (fstat(fd, &after) || after.st_nlink != 1 || after.st_uid != geteuid() || clean_status_identity_from_stat(&after_identity, &after) || @@ -1430,14 +1444,15 @@ static int restore_external_bootstrap_manifest( path = clean_status_history_store_witness_path( istate->repo->index_file, proof_namespace, istate->repo->hash_algo); - fd = open_nofollow(path, O_RDONLY | O_CLOEXEC); + fd = open_external_history_witness(path); if (fd < 0 || fstat(fd, &before) || !S_ISREG(before.st_mode) || before.st_nlink != 1 || before.st_uid != geteuid() || clean_status_identity_from_stat(&before_identity, &before) || lstat(path, &after) || before.st_dev != after.st_dev || before.st_ino != after.st_ino) goto done; - do_read_index(&witness, path, 1); + if (read_index_entries_from_fd(&witness, fd)) + goto done; if (fstat(fd, &after) || after.st_nlink != 1 || after.st_uid != geteuid() || clean_status_identity_from_stat(&after_identity, &after) || diff --git a/clean-status-index.c b/clean-status-index.c index 34dc23d6c0abfb..50609cf323d693 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -58,13 +58,17 @@ static int snapshot_open( { struct clean_status_identity named; struct stat fd_st, named_st; - int fd; + int fd, flags = O_RDONLY | O_CLOEXEC; memset(snapshot, 0, sizeof(*snapshot)); snapshot->fd = -1; - fd = open_nofollow(path, O_RDONLY); +#ifdef O_NONBLOCK + flags |= O_NONBLOCK; +#endif + fd = open_nofollow(path, flags); if (fd < 0 || fstat(fd, &fd_st) || + !S_ISREG(fd_st.st_mode) || lstat(path, &named_st) || clean_status_identity_from_stat(&snapshot->identity, &fd_st) || clean_status_identity_from_stat(&named, &named_st) || diff --git a/read-cache-ll.h b/read-cache-ll.h index 34405b6d5e18d3..a6899217158916 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -320,6 +320,17 @@ int do_read_index(struct index_state *istate, const char *path, /* Takes ownership of fd, including when the state is already initialized. */ int do_read_index_from_fd(struct index_state *istate, int fd, const char *path); +/* + * Read only the entries of a full index into a fresh index_state. Optional + * extensions are ignored; split/sparse indexes, resolve-undo, malformed data, + * and unknown mandatory extensions are rejected. Nonzero checksums are always + * verified. A zero skipHash trailer requires separate authentication by the + * caller before using these entries as a proof. + * + * The caller owns fd. Its offset is unchanged, and failure leaves istate + * unchanged. Return 0 on success or -1 for a missing/unsupported witness. + */ +int read_index_entries_from_fd(struct index_state *istate, int fd); int read_index_from(struct index_state *, const char *path, const char *gitdir); int is_index_unborn(struct index_state *); diff --git a/read-cache.c b/read-cache.c index bf0866a6e5540f..923e8748bd45ea 100644 --- a/read-cache.c +++ b/read-cache.c @@ -32,6 +32,7 @@ #include "name-hash.h" #include "object-name.h" #include "path.h" +#include "path-namespace.h" #include "preload-index.h" #include "read-cache.h" #include "replace-object.h" @@ -1828,14 +1829,10 @@ struct ondisk_cache_entry { char name[FLEX_ARRAY]; }; -/* These are only used for v3 or lower */ +/* Index v2/v3 entries are padded to a multiple of eight bytes. */ #define align_padding_size(size, len) ((size + (len) + 8) & ~7) - (size + len) -#define align_flex_name(STRUCT,len) ((offsetof(struct STRUCT,data) + (len) + 8) & ~7) -#define ondisk_cache_entry_size(len) align_flex_name(ondisk_cache_entry,len) #define ondisk_data_size(flags, len) (the_hash_algo->rawsz + \ ((flags & CE_EXTENDED) ? 2 : 1) * sizeof(uint16_t) + len) -#define ondisk_data_size_max(len) (ondisk_data_size(CE_EXTENDED, len)) -#define ondisk_ce_size(ce) (ondisk_cache_entry_size(ondisk_data_size((ce)->ce_flags, ce_namelen(ce)))) /* Allow fsck to force verification of the index checksum. */ int verify_index_checksum; @@ -1918,30 +1915,76 @@ static int read_index_extension(struct index_state *istate, return 0; } +enum index_entry_decode_error { + INDEX_ENTRY_DECODE_OK, + INDEX_ENTRY_DECODE_CORRUPT, + INDEX_ENTRY_DECODE_FLAGS, + INDEX_ENTRY_DECODE_NAME, +}; + +enum index_entry_decode_flags { + INDEX_ENTRY_ALLOW_NAME_RESTART = 1 << 0, + INDEX_ENTRY_VERIFY_FORMAT = 1 << 1, +}; + +struct decoded_index_entry { + struct cache_entry *ce; + size_t size; + unsigned int bad_flags; +}; + +static int decode_index_entry_varint(const unsigned char **cursor, + const unsigned char *end, + uint64_t *result) +{ + const unsigned char *p = *cursor; + unsigned char c; + uint64_t value; + + if (p == end) + return -1; + c = *p++; + value = c & 127; + while (c & 128) { + value++; + if (!value || MSB(value, 7) || p == end) + return -1; + c = *p++; + value = (value << 7) + (c & 127); + } + *cursor = p; + *result = value; + return 0; +} + /* - * Parses the contents of the cache entry contained within the 'ondisk' buffer - * into a new incore 'cache_entry'. + * Decode one entry without reading beyond available bytes or reporting a + * fatal error. The main-index reader supplies its usual fatal wrapper below; + * optional index witnesses use the same decoder and treat errors as misses. * - * Note that 'char *ondisk' may not be aligned to a 4-byte address interval in - * index v4, so we cannot cast it to 'struct ondisk_cache_entry *' and access - * its members. Instead, we use the byte offsets of members within the struct to - * identify where 'get_be16()', 'get_be32()', and 'oidread()' (which can all - * read from an unaligned memory buffer) should read from the 'ondisk' buffer - * into the corresponding incore 'cache_entry' members. + * A v4 IEOT block starts with a complete name, but its strip count still + * describes the preceding block's last name. Preserve the main reader's + * treatment of a missing previous_ce as a name restart. The optional reader + * starts at the first entry and also requests the stricter format checks. + * + * V4 entries need not be aligned. Load fixed fields by their byte offsets, + * using get_be16(), get_be32(), and oidread() rather than a struct cast. */ -static struct cache_entry *create_from_disk(struct mem_pool *ce_mem_pool, - unsigned int version, - const char *ondisk, - unsigned long *ent_size, - const struct cache_entry *previous_ce) +static enum index_entry_decode_error decode_index_entry( + struct mem_pool *ce_mem_pool, const struct git_hash_algo *algo, + unsigned int version, const char *ondisk, size_t available, + const struct cache_entry *previous_ce, unsigned int options, + struct decoded_index_entry *decoded) { struct cache_entry *ce; - size_t len; - const char *name; - const unsigned hashsz = the_hash_algo->rawsz; - const char *flagsp = ondisk + offsetof(struct ondisk_cache_entry, data) + hashsz; + size_t len, suffix_len, consumed; + size_t fixed_size = offsetof(struct ondisk_cache_entry, data) + + algo->rawsz + sizeof(uint16_t); + const char *name, *end = ondisk + available; + const char *flagsp; unsigned int flags; size_t copy_len = 0; + int verify_format = options & INDEX_ENTRY_VERIFY_FORMAT; /* * Adjacent cache entries tend to share the leading paths, so it makes * sense to only store the differences in later entries. In the v4 @@ -1951,42 +1994,85 @@ static struct cache_entry *create_from_disk(struct mem_pool *ce_mem_pool, */ int expand_name_field = version == 4; + memset(decoded, 0, sizeof(*decoded)); + if (available < fixed_size) + return INDEX_ENTRY_DECODE_CORRUPT; + flagsp = ondisk + fixed_size - sizeof(uint16_t); + /* On-disk flags are just 16 bits */ flags = get_be16(flagsp); len = flags & CE_NAMEMASK; if (flags & CE_EXTENDED) { - int extended_flags; - extended_flags = get_be16(flagsp + sizeof(uint16_t)) << 16; + unsigned int extended_flags; + + if (available - fixed_size < sizeof(uint16_t)) + return INDEX_ENTRY_DECODE_CORRUPT; + extended_flags = + (unsigned int)get_be16(flagsp + sizeof(uint16_t)) << 16; /* We do not yet understand any bit out of CE_EXTENDED_FLAGS */ - if (extended_flags & ~CE_EXTENDED_FLAGS) - die(_("unknown index entry format 0x%08x"), extended_flags); + if (extended_flags & ~CE_EXTENDED_FLAGS) { + decoded->bad_flags = extended_flags; + return INDEX_ENTRY_DECODE_FLAGS; + } flags |= extended_flags; - name = (const char *)(flagsp + 2 * sizeof(uint16_t)); + fixed_size += sizeof(uint16_t); } - else - name = (const char *)(flagsp + sizeof(uint16_t)); + name = ondisk + fixed_size; if (expand_name_field) { const unsigned char *cp = (const unsigned char *)name; - uint64_t strip_len, previous_len; + uint64_t strip_len; - /* If we're at the beginning of a block, ignore the previous name */ - strip_len = decode_varint(&cp); + if (decode_index_entry_varint( + &cp, (const unsigned char *)end, &strip_len)) + return INDEX_ENTRY_DECODE_CORRUPT; if (previous_ce) { - previous_len = previous_ce->ce_namelen; - if (previous_len < strip_len) - die(_("malformed name field in the index, near path '%s'"), - previous_ce->name); - copy_len = previous_len - strip_len; - } + if (previous_ce->ce_namelen < strip_len) + return INDEX_ENTRY_DECODE_NAME; + copy_len = previous_ce->ce_namelen - strip_len; + } else if (strip_len && + !(options & INDEX_ENTRY_ALLOW_NAME_RESTART)) + return INDEX_ENTRY_DECODE_NAME; name = (const char *)cp; } if (len == CE_NAMEMASK) { - len = strlen(name); - if (expand_name_field) - len += copy_len; + const char *nul = memchr(name, '\0', end - name); + + if (!nul || copy_len > INT_MAX || + (size_t)(nul - name) > INT_MAX - copy_len) + return INDEX_ENTRY_DECODE_CORRUPT; + suffix_len = nul - name; + len = copy_len + suffix_len; + if (verify_format && len < CE_NAMEMASK) + return INDEX_ENTRY_DECODE_CORRUPT; + } else { + if (len < copy_len) + return INDEX_ENTRY_DECODE_CORRUPT; + suffix_len = len - copy_len; + if (suffix_len >= (size_t)(end - name) || name[suffix_len] || + (verify_format && memchr(name, '\0', suffix_len))) + return INDEX_ENTRY_DECODE_CORRUPT; + } + if (len > INT_MAX || + len > SIZE_MAX - offsetof(struct cache_entry, name) - 1) + return INDEX_ENTRY_DECODE_CORRUPT; + + consumed = (name - ondisk) + suffix_len + 1; + if (!expand_name_field) { + size_t padded; + + if (consumed > SIZE_MAX - 7) + return INDEX_ENTRY_DECODE_CORRUPT; + padded = (consumed + 7) & ~(size_t)7; + if (padded > available) + return INDEX_ENTRY_DECODE_CORRUPT; + if (verify_format) + for (size_t i = consumed; i < padded; i++) + if (ondisk[i]) + return INDEX_ENTRY_DECODE_CORRUPT; + consumed = padded; } ce = mem_pool__ce_alloc(ce_mem_pool, len); @@ -2017,18 +2103,189 @@ static struct cache_entry *create_from_disk(struct mem_pool *ce_mem_pool, ce->ce_namelen = len; ce->index = 0; oidread(&ce->oid, (const unsigned char *)ondisk + offsetof(struct ondisk_cache_entry, data), - the_repository->hash_algo); + algo); + + if (copy_len) + memcpy(ce->name, previous_ce->name, copy_len); + memcpy(ce->name + copy_len, name, suffix_len + 1); + decoded->ce = ce; + decoded->size = consumed; + return INDEX_ENTRY_DECODE_OK; +} + +static struct cache_entry *create_from_disk( + struct mem_pool *ce_mem_pool, unsigned int version, + const char *ondisk, size_t available, unsigned long *ent_size, + const struct cache_entry *previous_ce) +{ + struct decoded_index_entry decoded; + enum index_entry_decode_error err = decode_index_entry( + ce_mem_pool, the_hash_algo, version, ondisk, available, + previous_ce, INDEX_ENTRY_ALLOW_NAME_RESTART, &decoded); + + if (err == INDEX_ENTRY_DECODE_FLAGS) + die(_("unknown index entry format 0x%08x"), decoded.bad_flags); + if (err == INDEX_ENTRY_DECODE_NAME && previous_ce) + die(_("malformed name field in the index, near path '%s'"), + previous_ce->name); + if (err || decoded.size > ULONG_MAX) + die(_("index file corrupt")); + *ent_size = decoded.size; + return decoded.ce; +} - if (expand_name_field) { - if (copy_len) - memcpy(ce->name, previous_ce->name, copy_len); - memcpy(ce->name + copy_len, name, len + 1 - copy_len); - *ent_size = (name - ((char *)ondisk)) + len + 1 - copy_len; - } else { - memcpy(ce->name, name, len + 1); - *ent_size = ondisk_ce_size(ce); +/* Format-level checks only: a witness must not consult worktree config. */ +static int index_witness_entry_is_valid( + const struct cache_entry *ce, unsigned int version, + const struct cache_entry *previous) +{ + const char *component = ce->name; + + switch (ce->ce_mode) { + case 0100644: + case 0100755: + case 0120000: + case 0160000: + break; + default: + return 0; } - return ce; + if (!ce_namelen(ce) || + (version == 2 && (ce->ce_flags & CE_EXTENDED))) + return 0; + for (;;) { + const char *slash = strchr(component, '/'); + size_t len = slash ? (size_t)(slash - component) : + strlen(component); + + if (!len || (len == 1 && component[0] == '.') || + (len == 2 && !memcmp(component, "..", 2)) || + (len == 4 && component[0] == '.' && + (component[1] == 'g' || component[1] == 'G') && + (component[2] == 'i' || component[2] == 'I') && + (component[3] == 't' || component[3] == 'T'))) + return 0; + if (!slash) + break; + component = slash + 1; + } + if (previous) { + int cmp = strcmp(previous->name, ce->name); + + if (cmp > 0 || + (!cmp && (!ce_stage(previous) || + ce_stage(previous) >= ce_stage(ce)))) + return 0; + } + return 1; +} + +int read_index_entries_from_fd(struct index_state *istate, int fd) +{ + struct index_state parsed = INDEX_STATE_INIT(istate->repo); + const struct git_hash_algo *algo; + struct stat before, after; + unsigned char header[sizeof(struct cache_header)]; + char *data = NULL; + size_t size, end, offset, minimum_entry_size; + uint32_t nr; + int ret = -1; + + if (!istate->repo || !istate->repo->hash_algo || fd < 0 || + istate->initialized || istate->cache || istate->cache_nr || + istate->ce_mem_pool) + return -1; + algo = istate->repo->hash_algo; + trace2_region_enter("index", "read_index_entries", istate->repo); + if (fstat(fd, &before) || !S_ISREG(before.st_mode) || + before.st_size < 0 || + (uintmax_t)before.st_size > SIZE_MAX || + (uintmax_t)before.st_size > + (uintmax_t)maximum_signed_value_of_type(ssize_t)) + goto done; + size = (size_t)before.st_size; + if (size < sizeof(header) + algo->rawsz || + (size_t)pread_in_full(fd, header, sizeof(header), 0) != + sizeof(header) || memcmp(header, "DIRC", 4)) + goto done; + parsed.version = get_be32(header + 4); + if (parsed.version < INDEX_FORMAT_LB || + parsed.version > INDEX_FORMAT_UB) + goto done; + end = size - algo->rawsz; + nr = get_be32(header + 8); + offset = sizeof(header); + minimum_entry_size = offsetof(struct ondisk_cache_entry, data) + + algo->rawsz + sizeof(uint16_t) + 1 + (parsed.version == 4); + if (nr > INT_MAX || nr > (end - offset) / minimum_entry_size || + unsigned_mult_overflows((size_t)nr, sizeof(*parsed.cache))) + goto done; + /* A concurrent truncate must be a short read, not an mmap SIGBUS. */ + data = malloc(size); + if (!data || (size_t)pread_in_full(fd, data, size, 0) != size || + memcmp(data, header, sizeof(header))) + goto done; + oidread(&parsed.oid, (const unsigned char *)data + end, algo); + if (!is_null_oid(&parsed.oid) && + !hashfile_checksum_valid(algo, (const unsigned char *)data, size)) + goto done; + if (nr) { + parsed.cache = calloc(nr, sizeof(*parsed.cache)); + if (!parsed.cache) + goto done; + parsed.ce_mem_pool = malloc(sizeof(*parsed.ce_mem_pool)); + if (!parsed.ce_mem_pool) + goto done; + mem_pool_init(parsed.ce_mem_pool, 0); + } + parsed.cache_alloc = nr; + parsed.initialized = 1; + parsed.timestamp.sec = before.st_mtime; + parsed.timestamp.nsec = ST_MTIME_NSEC(before); + while (parsed.cache_nr < nr) { + struct decoded_index_entry decoded; + const struct cache_entry *previous = parsed.cache_nr ? + parsed.cache[parsed.cache_nr - 1] : NULL; + + if (decode_index_entry(parsed.ce_mem_pool, algo, parsed.version, + data + offset, end - offset, previous, + INDEX_ENTRY_VERIFY_FORMAT, &decoded) || + !index_witness_entry_is_valid(decoded.ce, parsed.version, + previous)) + goto done; + parsed.cache[parsed.cache_nr++] = decoded.ce; + offset += decoded.size; + } + while (offset < end) { + const char *ext = data + offset; + uint32_t ext_size; + + if (end - offset < 8) + goto done; + ext_size = get_be32(ext + 4); + if (ext_size > end - offset - 8 || + ext[0] < 'A' || ext[0] > 'Z' || + !memcmp(ext, "REUC", 4)) + goto done; + /* Optional acceleration extensions are deliberately not installed. */ + offset += 8; + offset += ext_size; + } + if (fstat(fd, &after) || !path_namespace_stat_equal(&before, &after)) + goto done; + + trace2_data_intmax("index", istate->repo, "read/entries-only", + parsed.cache_nr); + release_index(istate); + *istate = parsed; + index_state_init(&parsed, istate->repo); + ret = 0; + +done: + free(data); + release_index(&parsed); + trace2_region_leave("index", "read_index_entries", istate->repo); + return ret; } static void check_ce_order(struct index_state *istate) @@ -2302,20 +2559,32 @@ static void *load_index_extensions(void *_data) */ static unsigned long load_cache_entry_block(struct index_state *istate, struct mem_pool *ce_mem_pool, int offset, int nr, const char *mmap, - unsigned long start_offset, const struct cache_entry *previous_ce) + size_t mmap_size, unsigned long start_offset, + const struct cache_entry *previous_ce) { int i; unsigned long src_offset = start_offset; + size_t end; + + if (mmap_size < the_hash_algo->rawsz || offset < 0 || nr < 0 || + nr > INT_MAX - offset || (unsigned int)offset > istate->cache_nr || + (unsigned int)nr > istate->cache_nr - offset) + die(_("index file corrupt")); + end = mmap_size - the_hash_algo->rawsz; for (i = offset; i < offset + nr; i++) { struct cache_entry *ce; unsigned long consumed; + if (src_offset > end) + die(_("index file corrupt")); ce = create_from_disk(ce_mem_pool, istate->version, - mmap + src_offset, + mmap + src_offset, end - src_offset, &consumed, previous_ce); set_index_entry(istate, i, ce); + if (consumed > ULONG_MAX - src_offset) + die(_("index file corrupt")); src_offset += consumed; previous_ce = ce; } @@ -2337,7 +2606,8 @@ static unsigned long load_all_cache_entries(struct index_state *istate, } consumed = load_cache_entry_block(istate, istate->ce_mem_pool, - 0, istate->cache_nr, mmap, src_offset, NULL); + 0, istate->cache_nr, mmap, mmap_size, + src_offset, NULL); return consumed; } @@ -2357,6 +2627,7 @@ struct load_cache_entries_thread_data struct mem_pool *ce_mem_pool; int offset; const char *mmap; + size_t mmap_size; struct index_entry_offset_table *ieot; int ieot_start; /* starting index into the ieot array */ int ieot_blocks; /* count of ieot entries to process */ @@ -2375,7 +2646,8 @@ static void *load_cache_entries_thread(void *_data) /* iterate across all ieot blocks assigned to this thread */ for (i = p->ieot_start; i < p->ieot_start + p->ieot_blocks; i++) { p->consumed += load_cache_entry_block(p->istate, p->ce_mem_pool, - p->offset, p->ieot->entries[i].nr, p->mmap, p->ieot->entries[i].offset, NULL); + p->offset, p->ieot->entries[i].nr, p->mmap, + p->mmap_size, p->ieot->entries[i].offset, NULL); p->offset += p->ieot->entries[i].nr; } return NULL; @@ -2412,6 +2684,7 @@ static unsigned long load_cache_entries_threaded(struct index_state *istate, con p->istate = istate; p->offset = offset; p->mmap = mmap; + p->mmap_size = mmap_size; p->ieot = ieot; p->ieot_start = ieot_start; p->ieot_blocks = ieot_blocks; diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c index 2f8ce51bf39a2f..1a759163d12dfc 100644 --- a/t/helper/test-read-cache.c +++ b/t/helper/test-read-cache.c @@ -5,6 +5,7 @@ #include "attr-fingerprint.h" #include "attr-manifest.h" #include "clean-status.h" +#include "clean-status-index.h" #include "clean-status-internal.h" #include "config.h" #include "dir.h" @@ -20,6 +21,147 @@ #include "setup.h" #include "strbuf.h" +static int witness_has_only_entries(const struct index_state *istate) +{ + const unsigned int disk_flags = + CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; + + if (!istate->initialized || istate->cache_changed || + istate->name_hash_initialized || istate->cache_tree || + istate->resolve_undo || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || istate->untracked || + istate->clean_status || istate->fsmonitor_dirty || + istate->fsmonitor_last_update || + istate->fsmonitor_last_update_pending || + istate->fsmonitor_untracked_token || + istate->fsmonitor_token_valid || istate->fsmonitor_extension_seen || + istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_valid) + return 0; + for (size_t i = 0; i < istate->cache_nr; i++) + if (istate->cache[i]->ce_flags & ~disk_flags) + return 0; + return 1; +} + +static int compare_witness_entries(const struct index_state *witness, + const struct index_state *full) +{ + const unsigned int disk_flags = + CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; + + if (witness->version != full->version || + witness->cache_nr != full->cache_nr || + !oideq(&witness->oid, &full->oid) || + witness->timestamp.sec != full->timestamp.sec || + witness->timestamp.nsec != full->timestamp.nsec) + return error("witness index header differs from the full reader"); + for (size_t i = 0; i < witness->cache_nr; i++) { + const struct cache_entry *a = witness->cache[i]; + const struct cache_entry *b = full->cache[i]; + + if (memcmp(&a->ce_stat_data, &b->ce_stat_data, + sizeof(a->ce_stat_data)) || + a->ce_mode != b->ce_mode || + ((a->ce_flags ^ b->ce_flags) & disk_flags) || + !oideq(&a->oid, &b->oid) || + ce_namelen(a) != ce_namelen(b) || + memcmp(a->name, b->name, ce_namelen(a) + 1)) + return error("witness entry %"PRIuMAX" differs from the full reader", + (uintmax_t)i); + } + return 0; +} + +static int test_read_index_witness(const char *path, int compare, + int unlink_after_open, int expect_miss) +{ + struct index_state witness = INDEX_STATE_INIT(the_repository); + struct index_state full = INDEX_STATE_INIT(the_repository); + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + struct stat st; + int flags = O_RDONLY | O_CLOEXEC; + int fd = -1, ret = 1, read_result; + + setup_git_directory(the_repository); + repo_config(the_repository, git_default_config, NULL); +#ifdef O_NONBLOCK + flags |= O_NONBLOCK; +#else + /* The parser is still useful for regular-file fixtures on this platform. */ + if (lstat(path, &st) || !S_ISREG(st.st_mode)) { + ret = !expect_miss; + goto done; + } +#endif + fd = open_nofollow(path, flags); + if (fd < 0 || fstat(fd, &st) || !S_ISREG(st.st_mode)) { + ret = !expect_miss; + goto done; + } + if (lseek(fd, 1, SEEK_SET) != 1) + goto done; + if (unlink_after_open && + (clean_status_index_snapshot_open_allow_null_checksum( + &snapshot, path, the_repository->hash_algo) || + unlink(path))) + goto done; + read_result = read_index_entries_from_fd(&witness, fd); + if (fstat(fd, &st) || lseek(fd, 0, SEEK_CUR) != 1) { + error("witness reader consumed its borrowed descriptor"); + goto done; + } + if (read_result) { + if (witness.initialized || witness.cache || witness.cache_nr || + witness.ce_mem_pool) { + error("failed witness read published partial state"); + goto done; + } + ret = !expect_miss; + goto done; + } + if (expect_miss) { + error("invalid witness was accepted"); + goto done; + } + if (!witness_has_only_entries(&witness)) { + error("witness reader installed non-entry state"); + goto done; + } + if (unlink_after_open && + clean_status_index_snapshot_still_matches_path( + &snapshot, path, the_repository->hash_algo)) { + error("unlinked witness retained its named snapshot"); + goto done; + } + if (compare) { + do_read_index(&full, path, 1); + if (compare_witness_entries(&witness, &full)) + goto done; + } + ret = 0; + +done: + if (fd >= 0) + close(fd); + clean_status_index_snapshot_release(&snapshot); + release_index(&full); + release_index(&witness); + return ret; +} + +static int test_index_witness_snapshot(const char *path) +{ + struct clean_status_index_snapshot snapshot = { .fd = -1 }; + int ret; + + setup_git_directory(the_repository); + ret = clean_status_index_snapshot_open_allow_null_checksum( + &snapshot, path, the_repository->hash_algo); + clean_status_index_snapshot_release(&snapshot); + return !!ret; +} + static int test_fsmonitor_content_recovery(const char *path) { struct index_state *istate; @@ -429,6 +571,16 @@ int cmd__read_cache(int argc, const char **argv) int i, cnt = 1; const char *name = NULL; + if (argc == 3 && !strcmp(argv[1], "--read-index-witness")) + return test_read_index_witness(argv[2], 0, 0, 0); + if (argc == 3 && !strcmp(argv[1], "--expect-index-witness-miss")) + return test_read_index_witness(argv[2], 0, 0, 1); + if (argc == 3 && !strcmp(argv[1], "--compare-index-witness")) + return test_read_index_witness(argv[2], 1, 0, 0); + if (argc == 3 && !strcmp(argv[1], "--read-index-witness-unlink")) + return test_read_index_witness(argv[2], 0, 1, 0); + if (argc == 3 && !strcmp(argv[1], "--index-witness-snapshot")) + return test_index_witness_snapshot(argv[2]); if (argc == 3 && !strcmp(argv[1], "--test-fsmonitor-content-recovery")) return test_fsmonitor_content_recovery(argv[2]); diff --git a/t/meson.build b/t/meson.build index 7b10e2b568475d..a0e542915d4d32 100644 --- a/t/meson.build +++ b/t/meson.build @@ -266,6 +266,7 @@ integration_tests = [ 't1517-outside-repo.sh', 't1600-index.sh', 't1601-index-bogus.sh', + 't1602-index-witness.sh', 't1700-split-index.sh', 't1701-racy-split-index.sh', 't1800-hook.sh', diff --git a/t/t1602-index-witness.sh b/t/t1602-index-witness.sh new file mode 100755 index 00000000000000..37b20663ff928b --- /dev/null +++ b/t/t1602-index-witness.sh @@ -0,0 +1,946 @@ +#!/bin/sh + +test_description='gentle entries-only reads of optional index witnesses' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-semantic-verify.sh + +test_lazy_prereq UNTRACKED_CACHE ' + { git update-index --test-untracked-cache; ret=$?; } && + test $ret -ne 1 +' + +sane_unset GIT_TEST_SPLIT_INDEX GIT_TEST_INDEX_VERSION \ + GIT_TEST_INDEX_THREADS GIT_TEST_FSMONITOR + +test_expect_success PERL_TEST_HELPERS 'write index fixture generator' ' + cat >make-index.pl <<-\EOF + use strict; + use warnings; + use Digest::SHA qw(sha1 sha256); + binmode STDIN; + binmode STDOUT; + my ($algo, $case) = @ARGV; + my $rawsz = $algo eq "sha256" ? 32 : 20; + my $fixed = 40 + $rawsz + 2; + sub digest { return $rawsz == 32 ? sha256($_[0]) : sha1($_[0]); } + sub varint { + my ($n) = @_; + my @bytes = ($n & 127); + while ($n >>= 7) { unshift @bytes, 128 | (--$n & 127); } + return pack("C*", @bytes); + } + sub entry { + my %o = @_; + my $name = $o{name} // "alpha"; + my $version = $o{version} // 2; + my $len = length($name); + my $flags = ($o{flags} // 0) | + ($o{namelen} // ($len < 0xfff ? $len : 0xfff)); + my $data = pack("N10", 11, 12, 13, 14, 15, 16, + $o{mode} // 0100644, 17, 18, 19) . + ("\x11" x $rawsz) . pack("n", $flags); + $data .= pack("n", $o{extended} // 0) if $flags & 0x4000; + if ($version == 4) { + $data .= varint($o{strip} // 0) . + ($o{suffix} // $name) . "\0"; + } else { + $data .= $name . "\0"; + $data .= "\0" x ((8 - length($data) % 8) % 8); + } + return $data; + } + if ($case eq "strip-proofs" || $case eq "unbind-proof") { + local $/; + my $data = ; + my ($version, $nr) = unpack("NN", substr($data, 4, 8)); + die "expected an uncompressed index\n" if $version < 2 || $version > 3; + my $offset = 12; + for (1 .. $nr) { + my $flags = unpack("n", substr($data, $offset + 40 + $rawsz, 2)); + my $header = $fixed + (($flags & 0x4000) ? 2 : 0); + my $len = $flags & 0xfff; + $len = index($data, "\0", $offset + $header) - $offset - $header + if $len == 0xfff; + die "invalid name\n" if $len < 0; + $offset += ($header + $len + 8) & ~7; + } + my $out = substr($data, 0, $offset); + my $end = length($data) - $rawsz; + my $proof_seen = 0; + while ($offset < $end) { + die "short extension\n" if $end - $offset < 8; + my ($name, $size) = unpack("a4N", substr($data, $offset, 8)); + die "long extension\n" if $size > $end - $offset - 8; + my $body = substr($data, $offset + 8, $size); + $offset += 8 + $size; + next if $name eq "FSUC"; + next if $case eq "strip-proofs" && $name eq "FSCF"; + next if $case eq "unbind-proof" && $name eq "FSMN"; + if ($case eq "unbind-proof" && $name eq "FSCF") { + $proof_seen++; + my $flags = unpack("N", substr($body, 8, 4)); + substr($body, 8, 4, pack("N", $flags & ~6)); + $body = substr($body, 0, -$rawsz); + $body .= digest($body); + } + $out .= pack("a4N", $name, length($body)) . $body; + } + die "missing FSCF extension\n" if $case eq "unbind-proof" && !$proof_seen; + print $out, digest($out); + exit; + } + my $version = 2; + my @entries = (entry()); + my $extra = ""; + my $signature = "DIRC"; + my $count; + if ($case eq "empty") { @entries = (); } + elsif ($case eq "stages") { + @entries = map { entry(flags => $_ << 12) } 1 .. 3; + } + elsif ($case eq "extended") { + $version = 3; + @entries = (entry(version => 3, flags => 0xc000, extended => 0x6000)); + } + elsif ($case eq "compressed") { + $version = 4; + @entries = (entry(version => 4), + entry(version => 4, name => "alphabet", suffix => "bet"), + entry(version => 4, name => "beta", strip => 8)); + } + elsif ($case eq "long-compressed") { + $version = 4; + my $prefix = "long/" . ("a/" x 2100); + @entries = (entry(version => 4, name => $prefix . "one"), + entry(version => 4, name => $prefix . "two", strip => 3, + suffix => "two")); + } + elsif ($case eq "optional-extensions") { + $extra .= pack("a4N", $_, 4) . "junk" + for qw(TREE UNTR FSMN FSCF FSUC IEOT EOIE ZZZZ); + } + elsif ($case eq "bad-signature") { $signature = "NOPE"; } + elsif ($case eq "bad-version") { $version = 5; } + elsif ($case eq "bad-count") { $count = 0xffffffff; } + elsif ($case eq "truncated-header") { + my $data = "DIRC"; + print $data, digest($data); + exit; + } + elsif ($case eq "truncated-fixed") { $entries[0] = substr($entries[0], 0, $fixed - 1); } + elsif ($case eq "truncated-flags") { + $version = 3; + @entries = (substr(entry(version => 3, flags => 0x4000, + extended => 0x4000), 0, $fixed + 1)); + } + elsif ($case eq "unknown-flags") { + $version = 3; + @entries = (entry(version => 3, flags => 0x4000, extended => 1)); + } + elsif ($case eq "v2-extended") { + @entries = (entry(flags => 0x4000, extended => 0x4000)); + } + elsif ($case eq "missing-nul") { + $version = 4; + @entries = (substr(entry(version => 4), 0, -1)); + } + elsif ($case eq "embedded-nul") { @entries = (entry(name => "al\0ha")); } + elsif ($case eq "bad-padding") { + @entries = (entry(name => "ab")); + substr($entries[0], -1, 1, "\1"); + } + elsif ($case eq "truncated-varint" || $case eq "overflow-varint") { + $version = 4; + @entries = (substr(entry(version => 4), 0, $fixed) . + ($case eq "truncated-varint" ? "\x80\x80" : ("\x80" x 10) . "\0")); + } + elsif ($case eq "first-strip") { + $version = 4; + @entries = (entry(version => 4, strip => 1)); + } + elsif ($case eq "excessive-strip") { + $version = 4; + @entries = (entry(version => 4), + entry(version => 4, name => "beta", strip => 6)); + } + elsif ($case eq "short-name") { + $version = 4; + @entries = (entry(version => 4), + entry(version => 4, name => "b", suffix => "b")); + } + elsif ($case eq "short-long-name") { + $version = 4; + @entries = (entry(version => 4, namelen => 0xfff)); + } + elsif ($case eq "unordered") { @entries = (entry(name => "beta"), entry()); } + elsif ($case eq "duplicate-stage") { @entries = (entry(flags => 0x1000)) x 2; } + elsif ($case eq "mixed-stages") { @entries = (entry(), entry(flags => 0x1000)); } + elsif ($case eq "bad-mode") { @entries = (entry(mode => 0100664)); } + elsif ($case eq "empty-name") { @entries = (entry(name => "")); } + elsif ($case eq "absolute-name") { @entries = (entry(name => "/alpha")); } + elsif ($case eq "dotdot-name") { @entries = (entry(name => "a/../b")); } + elsif ($case eq "dotgit-name") { @entries = (entry(name => "a/.GiT/b")); } + elsif ($case eq "sparse-entry") { @entries = (entry(name => "dir/", mode => 0040000)); } + elsif ($case eq "resolve-undo" || $case eq "split-index" || + $case eq "sparse-index" || $case eq "mandatory-extension") { + my %names = ("resolve-undo" => "REUC", "split-index" => "link", + "sparse-index" => "sdir", "mandatory-extension" => "zzzz"); + $extra = pack("a4N", $names{$case}, 0); + } + elsif ($case eq "truncated-extension") { $extra = "FSMN"; } + elsif ($case eq "oversized-extension") { $extra = pack("a4N", "FSMN", 10) . "x"; } + elsif ($case ne "valid" && $case ne "skiphash" && + $case ne "bad-checksum" && $case ne "truncated-trailer") { + die "unknown fixture $case\n"; + } + my $data = $signature . pack("NN", $version, $count // scalar(@entries)) . + join("", @entries) . $extra; + my $checksum = $case eq "skiphash" ? "\0" x $rawsz : digest($data); + substr($checksum, 0, 1, chr(ord(substr($checksum, 0, 1)) ^ 1)) + if $case eq "bad-checksum"; + $checksum = substr($checksum, 0, -1) if $case eq "truncated-trailer"; + print $data, $checksum; + EOF +' + +for algo in sha1 sha256 +do + test_expect_success "$algo writer-produced v2/v3/v4 and skipHash witnesses" ' + git init --object-format="$algo" "$algo" && + git -C "$algo" config core.fsmonitor false && + git -C "$algo" config core.untrackedCache false && + git -C "$algo" config index.threads 1 && + mkdir "$algo/dir" && + test_write_lines alpha >"$algo/dir/alpha" && + test_write_lines alphabet >"$algo/dir/alphabet" && + test_write_lines beta >"$algo/dir/beta" && + git -C "$algo" add dir && + for version in 2 3 4 + do + if test "$version" = 2 + then + git -C "$algo" update-index --no-skip-worktree dir/alpha + else + git -C "$algo" update-index --skip-worktree dir/alpha + fi && + for skip in false true + do + git -C "$algo" -c index.skipHash="$skip" update-index \ + --index-version="$version" --force-write-index && + test "$version" = "$(git -C "$algo" update-index --show-index-version)" && + cp "$algo/.git/index" "$algo/.git/witness" && + test-tool -C "$algo" read-cache \ + --compare-index-witness .git/witness || return 1 + done || return 1 + done + ' + + test_expect_success PTHREADS "$algo v4 IEOT block restarts use the shared decoder" ' + git -C "$algo" config index.threads 3 && + git -C "$algo" -c index.skipHash=false update-index \ + --index-version=4 --force-write-index && + cp "$algo/.git/index" "$algo/.git/witness" && + test_grep IEOT "$algo/.git/witness" && + test_grep EOIE "$algo/.git/witness" && + test-tool -C "$algo" read-cache --compare-index-witness .git/witness && + git -C "$algo" config index.threads 1 + ' + + test_expect_success PERL_TEST_HELPERS "$algo exact entry fields and long compressed names" ' + for kind in valid empty stages extended compressed long-compressed skiphash + do + perl make-index.pl "$algo" "$kind" >"$algo/.git/witness" && + test-tool -C "$algo" read-cache \ + --compare-index-witness .git/witness || return 1 + done + ' + + test_expect_success PERL_TEST_HELPERS "$algo optional extensions are not decoded or installed" ' + perl make-index.pl "$algo" optional-extensions >"$algo/.git/witness" && + test-tool -C "$algo" read-cache --read-index-witness .git/witness + ' + + test_expect_success PERL_TEST_HELPERS "$algo malformed and unsupported witnesses are clean misses" ' + for kind in bad-signature bad-version bad-count bad-checksum \ + truncated-header truncated-fixed truncated-flags unknown-flags \ + v2-extended missing-nul embedded-nul bad-padding \ + truncated-varint overflow-varint first-strip excessive-strip \ + short-name short-long-name unordered duplicate-stage mixed-stages \ + bad-mode empty-name absolute-name dotdot-name dotgit-name \ + sparse-entry resolve-undo split-index sparse-index \ + mandatory-extension truncated-extension oversized-extension \ + truncated-trailer + do + perl make-index.pl "$algo" "$kind" >"$algo/.git/witness" && + test-tool -C "$algo" read-cache \ + --expect-index-witness-miss .git/witness || return 1 + done + ' + + test_expect_success PERL_TEST_HELPERS "$algo real-index corruption remains fatal" ' + for kind in truncated-header unknown-flags excessive-strip + do + perl make-index.pl "$algo" "$kind" >"$algo/.git/witness" && + test_must_fail env GIT_INDEX_FILE="$PWD/$algo/.git/witness" \ + git -C "$algo" ls-files >out 2>err && + test_grep "^fatal:" err || return 1 + done + ' + + test_expect_success PERL_TEST_HELPERS "$algo pinned reader never reopens a pruned pathname" ' + perl make-index.pl "$algo" valid >"$algo/.git/witness" && + test-tool -C "$algo" read-cache \ + --read-index-witness-unlink .git/witness && + test_path_is_missing "$algo/.git/witness" && + test-tool -C "$algo" read-cache \ + --expect-index-witness-miss .git/witness + ' + + test_expect_success PIPE "$algo witness and installer snapshot reject a FIFO" ' + rm -f "$algo/.git/witness" && + mkfifo "$algo/.git/witness" && + test_when_finished "rm -f $algo/.git/witness" && + test-tool -C "$algo" read-cache \ + --expect-index-witness-miss .git/witness && + test_must_fail test-tool -C "$algo" read-cache \ + --index-witness-snapshot .git/witness + ' +done + +test_lazy_prereq INDEX_WITNESS_APFS ' + test_have_prereq MACOS && + /bin/df -t apfs "$TRASH_DIRECTORY" >/dev/null +' + +# Inspect the framed extensions, not an incidental "FSCF" string in the index. +# These fixtures deliberately write v2 indexes with real checksums. Without +# an explicit expected token, require the real Darwin provider used below. +test_index_witness_full_proof () { + perl - "$1" "$(git rev-parse --show-object-format)" "${2-}" <<-\EOF + use strict; + use warnings; + use Digest::SHA qw(sha1 sha256); + my ($path, $algo, $expected_token) = @ARGV; + my $rawsz = $algo eq "sha256" ? 32 : 20; + sub digest { return $rawsz == 32 ? sha256($_[0]) : sha1($_[0]); } + open my $input, "<", $path or die "cannot read $path: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my $end = length($index) - $rawsz; + die "bad index checksum in $path\n" if $end < 12 || + digest(substr($index, 0, $end)) ne substr($index, $end); + my ($signature, $version, $nr) = unpack("a4NN", substr($index, 0, 12)); + die "expected an uncompressed index in $path\n" + if $signature ne "DIRC" || $version < 2 || $version > 3; + my $offset = 12; + for (1 .. $nr) { + my $fixed = 40 + $rawsz + 2; + die "short index entry in $path\n" if $end - $offset < $fixed; + my $flags = unpack("n", substr($index, $offset + $fixed - 2, 2)); + my $header = $fixed + (($flags & 0x4000) ? 2 : 0); + die "short entry flags in $path\n" if $end - $offset < $header; + my $nul = index($index, "\0", $offset + $header); + my $len = $flags & 0xfff; + die "bad index name in $path\n" if $nul < 0 || $nul >= $end || + ($len != 0xfff && $nul != $offset + $header + $len); + $len = $nul - $offset - $header; + $offset += ($header + $len + 8) & ~7; + die "short index padding in $path\n" if $offset > $end; + } + my %ext; + while ($offset < $end) { + die "short extension in $path\n" if $end - $offset < 8; + my ($name, $size) = unpack("a4N", substr($index, $offset, 8)); + die "bad extension $name in $path\n" + if $size > $end - $offset - 8 || exists $ext{$name}; + $ext{$name} = substr($index, $offset + 8, $size); + $offset += 8 + $size; + } + my $proof = $ext{FSCF} // die "missing FSCF in $path\n"; + die "short FSCF in $path\n" if length($proof) < 20; + my ($pv, $magic, $flags, $token_len, $manifest_len) = + unpack("N5", substr($proof, 0, 20)); + die "incomplete FSCF in $path (version $pv, flags $flags)\n" + if ($pv != 1 && $pv != 2) || $magic != 0x46534331 || + $flags != 15 || !$token_len || + length($proof) != 20 + $token_len + $manifest_len + + ($pv == 2 ? 5 : 4) * $rawsz || + digest(substr($proof, 0, -$rawsz)) ne substr($proof, -$rawsz); + my $token = substr($proof, 20, $token_len); + if (length($expected_token)) { + die "unexpected provider token in $path\n" + if $token ne $expected_token; + } else { + die "not a real builtin token in $path\n" + if $token !~ /^builtin:dirmeta-v1\.inode-v1\./; + } + for my $name (qw(FSMN FSUC)) { + my $body = $ext{$name} // die "missing $name in $path\n"; + my $want_version = $name eq "FSMN" ? 2 : 1; + my $nul = index($body, "\0", 4); + die "unbound $name in $path\n" + if length($body) < 5 || unpack("N", substr($body, 0, 4)) != + $want_version || $nul < 4 || + substr($body, 4, $nul - 4) ne $token; + } + die "missing UNTR in $path\n" if !exists $ext{UNTR}; + print "FSCF version $pv flags $flags token $token\n"; + EOF +} + +test_index_witness_cookie_health () ( + witness_cookie_label=$1 && + GIT_TRACE2_EVENT="$PWD/.git/$witness_cookie_label.cookie-initial.trace" \ + test-tool fsmonitor-client query --token 0 \ + >".git/$witness_cookie_label.cookie-initial" && + nul_to_q <".git/$witness_cookie_label.cookie-initial" \ + >".git/$witness_cookie_label.cookie-initial.q" && + test_grep "^builtin:.*Q/Q$" \ + ".git/$witness_cookie_label.cookie-initial.q" && + witness_cookie_token=$(sed "s/Q.*//" \ + ".git/$witness_cookie_label.cookie-initial.q") && + # A failed startup cookie may already have retired an older epoch. + wc -c <.git/witness-daemon.trace \ + >".git/$witness_cookie_label.cookie-daemon.offset" && + witness_cookie_log_offset=$(cat \ + ".git/$witness_cookie_label.cookie-daemon.offset") && + GIT_TRACE2_EVENT="$PWD/.git/$witness_cookie_label.cookie-healthy.trace" \ + test-tool fsmonitor-client query --token "$witness_cookie_token" \ + >".git/$witness_cookie_label.cookie-healthy" && + tail -c "+$((witness_cookie_log_offset + 1))" .git/witness-daemon.trace \ + >".git/$witness_cookie_label.cookie-daemon.trace" && + nul_to_q <".git/$witness_cookie_label.cookie-healthy" \ + >".git/$witness_cookie_label.cookie-healthy.q" && + test_grep "^builtin:.*Q" \ + ".git/$witness_cookie_label.cookie-healthy.q" && + test_grep ! "Q/Q$" ".git/$witness_cookie_label.cookie-healthy.q" && + test_grep "cookie-seen:" ".git/$witness_cookie_label.cookie-daemon.trace" && + test_grep ! "cookie_wait timed out" \ + ".git/$witness_cookie_label.cookie-daemon.trace" +) + +test_index_witness_physical_prime () ( + witness_prime_label=$1 && + test_index_witness_cookie_health "$witness_prime_label" && + # The physical index must carry the full source proof before CSH issuance. + GIT_OPTIONAL_LOCKS=1 GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TRACE2_EVENT="$PWD/.git/$witness_prime_label.prime.trace" \ + git status --porcelain=v2 >".git/$witness_prime_label.prime" && + test_index_witness_full_proof .git/index \ + >".git/$witness_prime_label.proof" +) + +test_index_witness_native_baseline () { + sane_unset GIT_INDEX_FILE GIT_INDEX_VERSION \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_AT \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_READY \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_RESUME GIT_TEST_FSMONITOR_TOKEN && + git config index.version 2 && + git config index.skipHash false && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.untrackedCache true && + git config core.fsmonitor false && + test-tool chmtime -120 "$@" && + git update-index --refresh && + git update-index --index-version=2 --force-write-index && + git config core.fsmonitor true && + GIT_TRACE_FSMONITOR="$PWD/.git/witness-daemon.trace" \ + GIT_TRACE2_EVENT="$PWD/.git/witness-daemon.trace2" \ + git fsmonitor--daemon start --start-timeout=10 && + GIT_TRACE2_EVENT="$PWD/.git/baseline.enable.trace" \ + git update-index --fsmonitor && + test_index_witness_physical_prime baseline && + test_must_be_empty .git/baseline.prime +} + +test_index_witness_issue_history () { + witness_issue_label=$1 && + witness_issue_expected_token=${2-} && + GIT_OPTIONAL_LOCKS=1 \ + GIT_TRACE2_EVENT="$PWD/.git/$witness_issue_label.issue.trace" \ + git status --short >".git/$witness_issue_label.issue" && + test_trace2_data fsmonitor history/external-stored 1 \ + <".git/$witness_issue_label.issue.trace" && + find .git -maxdepth 1 -type f -name "index.csh1.*" >.git/checkpoints && + find .git -maxdepth 1 -type f -name "index.cswi.*" >.git/witnesses && + test_line_count = 1 .git/checkpoints && + test_line_count = 1 .git/witnesses && + checkpoint=$(cat .git/checkpoints) && + witness=$(cat .git/witnesses) && + test_index_witness_full_proof "$witness" "$witness_issue_expected_token" \ + >".git/$witness_issue_label.witness-proof" && + cp "$checkpoint" .git/checkpoint.good && + cp "$witness" .git/witness.good +} + +test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'corrupt external semantic witnesses fall back with a valid main index' ' + test_when_finished "git -C recovery fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo recovery && + ( + cd recovery && + test_commit base tracked && + test_index_witness_native_baseline tracked && + test_index_witness_issue_history recovery && + test_must_be_empty .git/recovery.issue && + test_write_lines changed >tracked && + git update-index --add tracked && + perl ../make-index.pl "$(git rev-parse --show-object-format)" \ + strip-proofs <.git/index >.git/index.foreign && + cp .git/index.foreign .git/index && + cp .git/checkpoint.good "$checkpoint" && + cp .git/witness.good "$witness" && + git -c core.fsmonitor=false --no-optional-locks \ + status --porcelain=v2 >.git/expect && + GIT_TRACE2_EVENT="$PWD/.git/valid.trace" \ + git --no-optional-locks status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/valid.trace && + for kind in truncated-header unknown-flags excessive-strip + do + cp .git/index.foreign .git/index && + cp .git/checkpoint.good "$checkpoint" && + perl ../make-index.pl "$(git rev-parse --show-object-format)" \ + "$kind" >"$witness" && + GIT_TRACE2_EVENT="$PWD/.git/$kind.trace" \ + git --no-optional-locks status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_cmp_bin .git/index.foreign .git/index && + ! test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <".git/$kind.trace" || return 1 + done && + if test_have_prereq PIPE + then + cp .git/index.foreign .git/index && + cp .git/checkpoint.good "$checkpoint" && + rm -f "$witness" && + mkfifo "$witness" && + GIT_TRACE2_EVENT="$PWD/.git/fifo.trace" \ + git --no-optional-locks status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_cmp_bin .git/index.foreign .git/index && + ! test_trace2_data fsmonitor history/external-semantic-restored 1 \ + <.git/fifo.trace && + rm -f "$witness" + fi + ) +' + +test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'bootstrap manifest recovery treats a damaged witness as a miss' ' + test_when_finished "git -C bootstrap fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo bootstrap && + ( + cd bootstrap && + test_commit base tracked && + test_write_lines "tracked diff=old" >.gitattributes && + git add .gitattributes && + git commit -qm attributes && + test_index_witness_native_baseline tracked .gitattributes && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse :.gitattributes >.git/attributes.old-oid && + test_write_lines "tracked diff=new" >.gitattributes && + test-tool chmtime -120 .gitattributes && + test_index_witness_physical_prime bootstrap && + test_grep "^1 \\.M .* .gitattributes$" .git/bootstrap.prime && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse :.gitattributes >.git/attributes.still-staged && + test_cmp .git/attributes.old-oid .git/attributes.still-staged && + test_index_witness_issue_history bootstrap && + test_write_lines " M .gitattributes" >.git/bootstrap.expect && + test_cmp .git/bootstrap.expect .git/bootstrap.issue && + GIT_INDEX_FILE="$PWD/.git/witness.good" \ + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse :.gitattributes >.git/attributes.witness-oid && + test_cmp .git/attributes.old-oid .git/attributes.witness-oid && + git -c core.fsmonitor=false update-index --add .gitattributes && + perl ../make-index.pl "$(git rev-parse --show-object-format)" \ + unbind-proof <.git/index >.git/index.foreign && + cp .git/index.foreign .git/index && + cp .git/checkpoint.good "$checkpoint" && + cp .git/witness.good "$witness" && + git -c core.fsmonitor=false --no-optional-locks \ + status --porcelain=v2 >.git/expect && + GIT_TRACE2_EVENT="$PWD/.git/valid.trace" \ + git --no-optional-locks status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_trace2_data fsmonitor history/external-bootstrap-manifest 1 \ + <.git/valid.trace && + cp .git/index.foreign .git/index && + cp .git/checkpoint.good "$checkpoint" && + perl ../make-index.pl "$(git rev-parse --show-object-format)" \ + truncated-header >"$witness" && + GIT_TRACE2_EVENT="$PWD/.git/corrupt.trace" \ + git --no-optional-locks status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_cmp_bin .git/index.foreign .git/index && + ! test_trace2_data fsmonitor history/external-bootstrap-manifest 1 \ + <.git/corrupt.trace + ) +' + +test_lazy_prereq INDEX_WITNESS_SCRIPTED_IPC ' + test-tool simple-ipc SUPPORTS_SIMPLE_IPC +' + +# This provider exists in the pre-fix runtime too. Its one stable token is +# truthful only while the worktree is unchanged: make every worktree edit +# before starting it, and keep all later fixture writes inside .git. +index_witness_scripted_token=builtin:test-capable:0 + +test_index_witness_scripted_prepare () { + sane_unset GIT_INDEX_FILE GIT_INDEX_VERSION \ + GIT_TEST_FSMONITOR GIT_TEST_FSMONITOR_QUERY_SEQUENCE \ + GIT_TEST_FSMONITOR_QUERY_PATH GIT_TEST_FSMONITOR_QUERY_BARRIER_AT \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_READY \ + GIT_TEST_FSMONITOR_QUERY_BARRIER_RESUME GIT_TEST_FSMONITOR_TOKEN && + git config index.version 2 && + git config index.skipHash false && + git config core.autocrlf false && + git config core.trustctime true && + git config core.checkStat default && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor false && + test-tool chmtime -120 "$@" && + git update-index --refresh && + git update-index --index-version=2 --force-write-index && + git config core.fsmonitor true +} + +test_index_witness_scripted_start () { + witness_ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + GIT_TRACE2_EVENT="$PWD/.git/scripted-provider.trace" \ + test-tool simple-ipc start-daemon --name="$witness_ipc_path" \ + --threads=1 --fsmonitor-capability-superset && + printf "%s\000/\000" "$index_witness_scripted_token" \ + >.git/scripted-initial.expect && + printf "%s\000" "$index_witness_scripted_token" \ + >.git/scripted-clean.expect && + GIT_TRACE2_EVENT="$PWD/.git/scripted-initial.trace" \ + test-tool fsmonitor-client query --token 0 \ + >.git/scripted-initial.actual && + test_cmp_bin .git/scripted-initial.expect .git/scripted-initial.actual && + for witness_query in first repeated + do + GIT_TRACE2_EVENT="$PWD/.git/scripted-$witness_query.trace" \ + test-tool fsmonitor-client query \ + --token "$index_witness_scripted_token" \ + >".git/scripted-$witness_query.actual" && + test_cmp_bin .git/scripted-clean.expect \ + ".git/scripted-$witness_query.actual" || return 1 + done && + GIT_TRACE2_EVENT="$PWD/.git/scripted-enable.trace" \ + git update-index --fsmonitor +} + +test_index_witness_scripted_prime () { + witness_prime_label=$1 && + GIT_OPTIONAL_LOCKS=1 GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TRACE2_EVENT="$PWD/.git/$witness_prime_label.prime.trace" \ + git status --porcelain=v2 >".git/$witness_prime_label.prime" && + test_index_witness_full_proof .git/index \ + "$index_witness_scripted_token" \ + >".git/$witness_prime_label.proof" +} + +# Check the real, issued CSHS v2 source alias. Perl exposes the ordinary stat +# fields; Darwin stat adds the durable birth time and inode generation. The +# nanosecond fields remain in the authenticated record and are range-checked. +test_index_witness_scripted_source () { + perl - "$checkpoint" "$witness" \ + "$(git rev-parse --show-object-format)" \ + "$index_witness_scripted_token" <<-\EOF + use strict; + use warnings; + use Digest::SHA qw(sha1 sha256); + my ($checkpoint, $witness, $algo, $token) = @ARGV; + my $rawsz = $algo eq "sha256" ? 32 : 20; + sub digest { return $rawsz == 32 ? sha256($_[0]) : sha1($_[0]); } + sub read_file { + open my $fh, "<", $_[0] or die "cannot read $_[0]: $!\n"; + binmode $fh; + local $/; + return <$fh>; + } + my $source = read_file(".git/scripted-source.index"); + my $record = read_file($checkpoint); + my $end = length($record) - $rawsz; + my $offset = 12 + 2 * $rawsz; + die "bad source checksum\n" if length($source) < 12 + $rawsz || + digest(substr($source, 0, -$rawsz)) ne substr($source, -$rawsz); + die "bad CSHS checksum\n" if $end < $offset + 112 + 8 + $rawsz + 16 || + digest(substr($record, 0, $end)) ne substr($record, $end); + my ($magic, $version, $flags) = unpack("a4NN", substr($record, 0, 12)); + die "missing complete CSHS v2 source alias\n" + if $magic ne "CSHS" || $version != 2 || $flags != 15; + my $namespace = unpack("H*", substr($record, 12, $rawsz)); + die "checkpoint and witness namespaces differ\n" + if $checkpoint !~ /\.csh1\.\Q$namespace\E\z/ || + $witness !~ /\.cswi\.\Q$namespace\E\z/; + my @identity = unpack("Q>*", substr($record, $offset, 112)); + $offset += 112; + my @stat = split(/\s+/, read_file(".git/scripted-source.stat")); + my @fields = (0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13); + die "incomplete source stat\n" if @stat != @fields; + for my $i (0 .. $#fields) { + die "source identity field $fields[$i] differs\n" + if $identity[$fields[$i]] != $stat[$i]; + } + die "source is not an owned regular single-link index\n" + if ($identity[2] & 0170000) != 0100000 || + $identity[3] != 1 || $identity[4] != $>; + for my $i (8, 10, 12) { + die "invalid source nanoseconds\n" if $identity[$i] >= 1000000000; + } + my ($source_version, $source_nr) = unpack("NN", substr($record, $offset, 8)); + $offset += 8; + die "source header differs\n" + if substr($source, 0, 4) ne "DIRC" || + substr($source, 4, 8) ne pack("NN", $source_version, $source_nr); + die "source trailer differs\n" + if substr($record, $offset, $rawsz) ne substr($source, -$rawsz); + $offset += $rawsz; + my @lengths = unpack("N4", substr($record, $offset, 16)); + $offset += 16; + my %ext; + for my $name (qw(FSMN UNTR FSCF FSUC)) { + my $len = shift @lengths; + die "short checkpoint $name\n" if !$len || $len > $end - $offset; + $ext{$name} = substr($record, $offset, $len); + $offset += $len; + } + die "trailing checkpoint bytes\n" if $offset != $end; + my $proof = $ext{FSCF}; + die "short checkpoint FSCF\n" if length($proof) < 20; + my ($pv, $pmagic, $pf, $token_len, $manifest_len) = + unpack("N5", substr($proof, 0, 20)); + die "checkpoint does not carry FULL15\n" + if ($pv != 1 && $pv != 2) || $pmagic != 0x46534331 || $pf != 15 || + $token_len != length($token) || substr($proof, 20, $token_len) ne $token || + length($proof) != 20 + $token_len + $manifest_len + + ($pv == 2 ? 5 : 4) * $rawsz || + digest(substr($proof, 0, -$rawsz)) ne substr($proof, -$rawsz); + for my $name (qw(FSMN FSUC)) { + my $body = $ext{$name}; + my $want_version = $name eq "FSMN" ? 2 : 1; + die "checkpoint has an unbound $name\n" + if substr($body, 0, 4) ne pack("N", $want_version) || + substr($body, 4, length($token) + 1) ne "$token\0"; + } + print "CSHS v2 source $identity[0]:$identity[1] ", + "birth $identity[11] generation $identity[13] ", + "index $source_version entries $source_nr checksum ", + unpack("H*", substr($source, -$rawsz)), "\n"; + EOF +} + +test_index_witness_scripted_issue_history () { + perl -e ' + use strict; + use warnings; + my @st = lstat($ARGV[0]); + die "cannot stat source index: $!\n" if !@st; + print join(" ", @st[0, 1, 2, 3, 4, 5, 7, 9, 10]), "\n"; + ' .git/index >.git/scripted-source.stat && + /usr/bin/stat -f "%DB %Uv" .git/index >>.git/scripted-source.stat && + cp .git/index .git/scripted-source.index && + test_index_witness_issue_history "$1" "$index_witness_scripted_token" && + test_cmp_bin .git/scripted-source.index .git/witness.good && + test_index_witness_scripted_source >.git/scripted-source.proof +} + +# A FIFO regression must fail instead of hanging the whole test suite. Keep +# the child's actual exit status, and reserve 124 for a killed timeout. +test_index_witness_watchdog () { + perl -e ' + use strict; + use warnings; + use Errno qw(EINTR); + my $seconds = shift @ARGV; + my $pid = fork(); + die "cannot fork watchdog: $!\n" if !defined($pid); + if (!$pid) { + exec @ARGV or die "cannot exec $ARGV[0]: $!\n"; + } + my $timed_out = 0; + $SIG{ALRM} = sub { $timed_out = 1; kill "KILL", $pid; }; + alarm $seconds; + my $waited; + do { $waited = waitpid($pid, 0); } while $waited < 0 && $! == EINTR; + my $status = $?; + alarm 0; + die "cannot reap watchdog child: $!\n" if $waited != $pid; + if ($timed_out) { + warn "index witness command timed out after $seconds seconds\n"; + exit 124; + } + exit(($status & 127) ? 128 + ($status & 127) : $status >> 8); + ' "$@" +} + +test_index_witness_scripted_restore () { + cp .git/index.foreign .git/index && + cp .git/checkpoint.good "$checkpoint" && + rm -f "$witness" && + cp .git/witness.good "$witness" +} + +test_index_witness_scripted_status () ( + witness_status_label=$1 && + witness_status_key=$2 && + witness_status_expected=$3 && + if GIT_TRACE2_EVENT="$PWD/.git/$witness_status_label.trace" \ + test_index_witness_watchdog 20 git --no-optional-locks \ + status --porcelain=v2 >".git/$witness_status_label.actual" \ + 2>".git/$witness_status_label.err" + then + echo 0 >".git/$witness_status_label.exit" + else + witness_status_ret=$? && + echo "$witness_status_ret" >".git/$witness_status_label.exit" && + cat ".git/$witness_status_label.err" >&2 + return 1 + fi && + test_cmp .git/expect ".git/$witness_status_label.actual" && + test_cmp_bin .git/index.foreign .git/index && + test_cmp_bin .git/checkpoint.good "$checkpoint" && + test_grep ! '"key":"query/incompatible-daemon"' \ + ".git/$witness_status_label.trace" && + test_grep ! '"argv":.*"fsmonitor--daemon","run","--detach"' \ + ".git/$witness_status_label.trace" && + if test "$witness_status_expected" = restored + then + test_trace2_data fsmonitor "$witness_status_key" 1 \ + <".git/$witness_status_label.trace" + else + ! test_trace2_data fsmonitor "$witness_status_key" 1 \ + <".git/$witness_status_label.trace" + fi +) + +test_index_witness_scripted_recovery () ( + witness_recovery_key=$1 && + test_index_witness_scripted_restore && + test_index_witness_scripted_status valid-before \ + "$witness_recovery_key" restored || return 1 + witness_recovery_failed=0 + for witness_kind in truncated-header unknown-flags excessive-strip + do + if test_index_witness_scripted_restore && + perl ../make-index.pl "$(git rev-parse --show-object-format)" \ + "$witness_kind" >"$witness" && + test_index_witness_scripted_status "$witness_kind" \ + "$witness_recovery_key" miss + then + : + else + witness_recovery_failed=1 + fi + done + if test_index_witness_scripted_restore && + rm -f "$witness" && + mkfifo "$witness" && + test_index_witness_scripted_status fifo \ + "$witness_recovery_key" miss && + test -p "$witness" + then + : + else + witness_recovery_failed=1 + fi + # Even a pre-fix failure must reach the FIFO and closing positive control. + if test_index_witness_scripted_restore && + test_index_witness_scripted_status valid-after \ + "$witness_recovery_key" restored + then + : + else + witness_recovery_failed=1 + fi + test "$witness_recovery_failed" = 0 +) + +test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,INDEX_WITNESS_SCRIPTED_IPC,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS,PIPE \ + 'scripted-provider semantic recovery ignores damaged optional witnesses' ' + test_when_finished "test-tool -C scripted-recovery simple-ipc stop-daemon --name=.git/fsmonitor--daemon.ipc --max-wait=5 2>/dev/null || :" && + test_create_repo scripted-recovery && + ( + cd scripted-recovery && + test_commit base tracked && + test_write_lines stable >stable && + git add stable && + git commit -qm stable && + test_write_lines changed >.git/replacement && + git hash-object -w --stdin <.git/replacement >.git/replacement.oid && + test_index_witness_scripted_prepare tracked stable && + test_index_witness_scripted_start && + test_index_witness_scripted_prime semantic && + test_must_be_empty .git/semantic.prime && + test_index_witness_scripted_issue_history semantic && + test_must_be_empty .git/semantic.issue && + # Only the index changes; the provider can truthfully stay at its token. + git update-index --cacheinfo \ + "100644,$(cat .git/replacement.oid),tracked" && + perl ../make-index.pl "$(git rev-parse --show-object-format)" \ + strip-proofs <.git/index >.git/index.foreign && + GIT_INDEX_FILE="$PWD/.git/index.foreign" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >.git/expect && + test_line_count = 1 .git/expect && + test_grep "^1 MM .* tracked$" .git/expect && + test_index_witness_scripted_recovery \ + history/external-semantic-restored + ) +' + +test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,INDEX_WITNESS_SCRIPTED_IPC,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS,PIPE \ + 'scripted-provider bootstrap recovery ignores damaged optional witnesses' ' + test_when_finished "test-tool -C scripted-bootstrap simple-ipc stop-daemon --name=.git/fsmonitor--daemon.ipc --max-wait=5 2>/dev/null || :" && + test_create_repo scripted-bootstrap && + ( + cd scripted-bootstrap && + test_commit base tracked && + test_write_lines "tracked diff=old" >.gitattributes && + git add .gitattributes && + git commit -qm attributes && + test_index_witness_scripted_prepare tracked .gitattributes && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse :.gitattributes >.git/attributes.old-oid && + # This edit predates the synthetic provider; no later query may omit it. + test_write_lines "tracked diff=new" >.gitattributes && + test-tool chmtime -120 .gitattributes && + test_index_witness_scripted_start && + test_index_witness_scripted_prime bootstrap && + test_grep "^1 \\.M .* .gitattributes$" .git/bootstrap.prime && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse :.gitattributes >.git/attributes.still-staged && + test_cmp .git/attributes.old-oid .git/attributes.still-staged && + test_index_witness_scripted_issue_history bootstrap && + test_write_lines " M .gitattributes" >.git/bootstrap.expect && + test_cmp .git/bootstrap.expect .git/bootstrap.issue && + GIT_INDEX_FILE="$PWD/.git/witness.good" \ + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse :.gitattributes >.git/attributes.witness-oid && + test_cmp .git/attributes.old-oid .git/attributes.witness-oid && + git -c core.fsmonitor=false update-index --add .gitattributes && + perl ../make-index.pl "$(git rev-parse --show-object-format)" \ + unbind-proof <.git/index >.git/index.foreign && + GIT_INDEX_FILE="$PWD/.git/index.foreign" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >.git/expect && + test_line_count = 1 .git/expect && + test_grep "^1 M\\. .* .gitattributes$" .git/expect && + test_index_witness_scripted_recovery \ + history/external-bootstrap-manifest + ) +' + +test_done From 176a9b2792bcb7ce3fde529b5b0d17fc5f8ceeb7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:10:04 -0500 Subject: [PATCH 369/403] t1602: require a healthy native cookie before recovery tests A native fsmonitor daemon can start and return a builtin token while cookie synchronization is failing. The two native witness-recovery tests then fail while establishing their source proof, before they can exercise the optional index reader. Probe a fresh repository for a real, nontrivial cookie response before running those tests. Require an observed cookie and reject a daemon trace containing a timeout. Keep the assertions inside each recovery test, and leave the portable parser tests and deterministic IPC recovery controls unconditional. --- t/t1602-index-witness.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/t/t1602-index-witness.sh b/t/t1602-index-witness.sh index 37b20663ff928b..50943332f8eb4a 100755 --- a/t/t1602-index-witness.sh +++ b/t/t1602-index-witness.sh @@ -478,7 +478,21 @@ test_index_witness_issue_history () { cp "$witness" .git/witness.good } -test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ +test_lazy_prereq INDEX_WITNESS_HEALTHY_NATIVE_COOKIE ' + test_have_prereq INDEX_WITNESS_APFS,FSMONITOR_DAEMON && + test_create_repo index-witness-native-cookie-prerequisite && + ( + cd index-witness-native-cookie-prerequisite && + trap "git fsmonitor--daemon stop >/dev/null 2>&1 || :" 0 && + git config core.fsmonitor true && + GIT_TRACE_FSMONITOR="$PWD/.git/witness-daemon.trace" \ + git fsmonitor--daemon start --start-timeout=10 && + test_index_witness_cookie_health native-prerequisite && + test_grep ! "cookie_wait timed out" .git/witness-daemon.trace + ) +' + +test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS,INDEX_WITNESS_HEALTHY_NATIVE_COOKIE \ 'corrupt external semantic witnesses fall back with a valid main index' ' test_when_finished "git -C recovery fsmonitor--daemon stop 2>/dev/null || :" && test_create_repo recovery && @@ -532,7 +546,7 @@ test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC ) ' -test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ +test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS,INDEX_WITNESS_HEALTHY_NATIVE_COOKIE \ 'bootstrap manifest recovery treats a damaged witness as a miss' ' test_when_finished "git -C bootstrap fsmonitor--daemon stop 2>/dev/null || :" && test_create_repo bootstrap && From 20d54cc2f4dde32c304a431ab96eb612f22a4aa4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:23:44 -0500 Subject: [PATCH 370/403] t: use test_env for shell-function invocations The new witness and sidecar regressions put environment assignments directly before shell functions. Some shells do not export those assignments to commands called by the function, and test-lint rejects the nonportable form. Use test_env so the watchdog and bulk-status helpers pass the intended variables to Git. Keep the command output, failure handling, and proof assertions unchanged. --- t/t1602-index-witness.sh | 2 +- t/t7530-status-clean-sidecar.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/t/t1602-index-witness.sh b/t/t1602-index-witness.sh index 50943332f8eb4a..efbc04db0b0a03 100755 --- a/t/t1602-index-witness.sh +++ b/t/t1602-index-witness.sh @@ -811,7 +811,7 @@ test_index_witness_scripted_status () ( witness_status_label=$1 && witness_status_key=$2 && witness_status_expected=$3 && - if GIT_TRACE2_EVENT="$PWD/.git/$witness_status_label.trace" \ + if test_env GIT_TRACE2_EVENT="$PWD/.git/$witness_status_label.trace" \ test_index_witness_watchdog 20 git --no-optional-locks \ status --porcelain=v2 >".git/$witness_status_label.actual" \ 2>".git/$witness_status_label.err" diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 14c80d19d4d373..a60fbc54cfa5dd 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2632,11 +2632,11 @@ test_expect_success PERL_TEST_HELPERS \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ git update-index --fsmonitor && - GIT_INDEX_FILE="$PWD/.git/index" \ + test_env GIT_INDEX_FILE="$PWD/.git/index" \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ bulk_status status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ bulk_status status --porcelain=v2 >.git/issue && test_must_be_empty .git/issue && test_path_is_file .git/index.csts && From c5a07e68b5e892140a6d47a3e9ca28a2389b9789 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:29:52 -0500 Subject: [PATCH 371/403] t7519: expect the index-only cached diff fallback 88f1686b27 (fsmonitor: recognize equivalent index-only reader requests, 2026-08-17) lets cached diff-index avoid restoring external worktree history. Its output depends only on the indexed objects, so rebuilding that history or hashing an old checkpoint is unnecessary. The earlier plumbing-diff regression still requires every command to restore external history. Require the conservative scoped-reader fallback for its cached case instead, together with no restoration, logical digest, manifest scan, or physical index write. Keep the restoration requirements for commands that inspect the worktree. --- t/t7519-status-fsmonitor.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index c56393794a5b28..5aebea5b4ed65b 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -4729,8 +4729,20 @@ test_expect_success MACOS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty ".git/$diff_case.actual" fi && test_cmp_bin .git/index.before .git/index && - test_trace2_data fsmonitor history/external-restored 1 \ - <".git/$diff_case.trace" && + if test "$diff_case" = cached + then + test_trace2_data fsmonitor \ + semantic/scoped-reader-stat-fallback 1 \ + <".git/$diff_case.trace" && + ! test_trace2_data fsmonitor \ + history/external-restored 1 \ + <".git/$diff_case.trace" && + test_region ! fsmonitor history_logical_digest \ + ".git/$diff_case.trace" + else + test_trace2_data fsmonitor history/external-restored 1 \ + <".git/$diff_case.trace" + fi && ! test_trace2_data fsmonitor semantic/manifest-scan-count \ <".git/$diff_case.trace" && test_grep ! "\"label\":\"do_write_index\"" \ From 19fc55206f31ee6a6a426eba9d3243116e251c1c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:29:55 -0500 Subject: [PATCH 372/403] t7535: wait for the listener before removing its cookie directory The background start command can report a listening socket before the filesystem listener has started serving requests. The test currently removes its cookie directory before proving that the listener is ready, which mixes startup failures with the intended cookie-creation error. Complete the existing flush request before renaming the directory. IPC workers start only after the platform listener is ready, and a flush does not depend on cookie-event delivery. The following query still fails to create its cookie and must retire the old provider token. --- t/t7535-fsmonitor-cookie-reset.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/t/t7535-fsmonitor-cookie-reset.sh b/t/t7535-fsmonitor-cookie-reset.sh index d83013928c80a2..f2861b405ba8f5 100755 --- a/t/t7535-fsmonitor-cookie-reset.sh +++ b/t/t7535-fsmonitor-cookie-reset.sh @@ -15,15 +15,15 @@ test_expect_success 'a failed cookie permanently invalidates the old token' ' test_create_repo cookie-reset && GIT_TRACE2_EVENT="$PWD/cookie-daemon.trace" \ git -C cookie-reset fsmonitor--daemon start --start-timeout=10 && + test-tool -C cookie-reset fsmonitor-client flush >before && + nul_to_q before.q && + test_grep "^builtin:.*:0Q/Q$" before.q && + old_token=$(sed "s/Q.*//" before.q) && mv cookie-reset/.git/fsmonitor--daemon/cookies \ cookie-reset/.git/fsmonitor--daemon/cookies.saved && test_when_finished "test ! -d cookie-reset/.git/fsmonitor--daemon/cookies.saved || mv cookie-reset/.git/fsmonitor--daemon/cookies.saved \ cookie-reset/.git/fsmonitor--daemon/cookies" && - test-tool -C cookie-reset fsmonitor-client flush >before && - nul_to_q before.q && - test_grep "^builtin:.*:0Q/Q$" before.q && - old_token=$(sed "s/Q.*//" before.q) && test-tool -C cookie-reset fsmonitor-client query \ --token "$old_token" >failed && nul_to_q failed.q && From be3eacfd884d229ea7d859e77f4624e5c69f29fa Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:53:51 -0500 Subject: [PATCH 373/403] read-cache: decode extension signatures as unsigned integers CACHE_EXT() shifts bytes loaded through a char pointer. On targets where char is signed, a byte with its high bit set becomes a negative integer, and shifting it has undefined behavior. The EOIE reader probes a possible signature before it knows whether the extension exists. That position can contain arbitrary payload bytes in an otherwise valid index. The new SHA-256 witness tests expose this under UBSan while reading an ordinary index. Use get_be32() to assemble the signature from unsigned bytes. Cover both the speculative EOIE probe and an optional extension with a high-bit byte in its signature, in both object formats. Leave EOIE layout and entry-thread selection unchanged. --- read-cache.c | 2 +- t/t1602-index-witness.sh | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/read-cache.c b/read-cache.c index 923e8748bd45ea..ed9bf5b24276be 100644 --- a/read-cache.c +++ b/read-cache.c @@ -69,7 +69,7 @@ * is outside the range, to cause the reader to abort. */ -#define CACHE_EXT(s) ( (s[0]<<24)|(s[1]<<16)|(s[2]<<8)|(s[3]) ) +#define CACHE_EXT(s) get_be32(s) #define CACHE_EXT_TREE 0x54524545 /* "TREE" */ #define CACHE_EXT_RESOLVE_UNDO 0x52455543 /* "REUC" */ #define CACHE_EXT_LINK 0x6c696e6b /* "link" */ diff --git a/t/t1602-index-witness.sh b/t/t1602-index-witness.sh index efbc04db0b0a03..5b13f0c6657b62 100755 --- a/t/t1602-index-witness.sh +++ b/t/t1602-index-witness.sh @@ -120,6 +120,13 @@ test_expect_success PERL_TEST_HELPERS 'write index fixture generator' ' $extra .= pack("a4N", $_, 4) . "junk" for qw(TREE UNTR FSMN FSCF FSUC IEOT EOIE ZZZZ); } + elsif ($case eq "high-bit-extension") { + my $size = 12 + $rawsz; + $extra = pack("a4N", "ZZZZ", $size) . ("\x95" x $size); + } + elsif ($case eq "high-bit-signature") { + $extra = pack("a4N", "Z\x95ZZ", 0); + } elsif ($case eq "bad-signature") { $signature = "NOPE"; } elsif ($case eq "bad-version") { $version = 5; } elsif ($case eq "bad-count") { $count = 0xffffffff; } @@ -957,4 +964,39 @@ test_expect_success INDEX_WITNESS_APFS,FSMONITOR_DAEMON,INDEX_WITNESS_SCRIPTED_I ) ' +# An EOIE lookup probes backwards from the checksum before it knows whether +# the bytes are an extension signature. Keep both the current SHA-1-sized +# probe and a hash-sized probe inside a fixed high-bit optional payload. +# Also exercise the ordinary reader with a high-bit byte after the uppercase +# first byte of an optional extension signature. +for algo in sha1 sha256 +do + test_expect_success PTHREADS,PERL_TEST_HELPERS \ + "$algo index extension signatures use unsigned bytes" ' + repo=high-bit-extension-$algo && + git init --object-format="$algo" "$repo" && + perl make-index.pl "$algo" high-bit-extension >"$repo/.git/witness" && + GIT_INDEX_FILE="$PWD/$repo/.git/witness" \ + git -C "$repo" -c core.fsmonitor=false \ + -c core.untrackedCache=false -c index.threads=1 \ + --no-optional-locks ls-files --stage >expect 2>serial.err && + test_line_count = 1 expect && + test_grep "^100644 .*alpha$" expect && + test_grep "ignoring ZZZZ extension" serial.err && + GIT_INDEX_FILE="$PWD/$repo/.git/witness" \ + git -C "$repo" -c core.fsmonitor=false \ + -c core.untrackedCache=false -c index.threads=2 \ + --no-optional-locks ls-files --stage >actual 2>threaded.err && + test_cmp expect actual && + test_grep "ignoring ZZZZ extension" threaded.err && + perl make-index.pl "$algo" high-bit-signature >"$repo/.git/witness" && + GIT_INDEX_FILE="$PWD/$repo/.git/witness" \ + git -C "$repo" -c core.fsmonitor=false \ + -c core.untrackedCache=false -c index.threads=1 \ + --no-optional-locks ls-files --stage >actual 2>signature.err && + test_cmp expect actual && + test_grep "ignoring .* extension" signature.err + ' +done + test_done From a56d683649698eae71d3dc0e2cb867af8e6c7dea Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 15:58:48 -0500 Subject: [PATCH 374/403] t7536: export the watch-limit fixture environment The test library wraps perl in a shell function. Under dash, temporary assignments before that function do not reach the Git process started by the watchdog. The status output still matches the independent oracle, but the missing Trace2 file makes all three invalid-marker tests fail in linux32 and linux-TEST-vars. Use test_env to export the existing provider and Trace2 settings. Keep the watchdog, dirty fixture, oracle, and marker assertions unchanged. --- t/t7536-fsmonitor-watch-limit-backoff.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 0f3eb532cbb5fa..911f1a29425dce 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -50,7 +50,7 @@ setup_backoff_marker_fixture () { check_rejected_backoff_marker () { ( cd "$1" && - GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test_env GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCCCCCC \ GIT_TEST_FSMONITOR_QUERY_PATH=// \ GIT_TRACE2_EVENT="$PWD/.git/$2.trace" \ From 7d315041ac43ab97ee5ddb83e69e4f7efa6d7b17 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 15:58:54 -0500 Subject: [PATCH 375/403] unit-tests: use a nonzero mode bit in namespace identity test The directory-identity test changes a synthetic stat buffer to check that a permission change invalidates the identity. S_IXGRP is zero on Windows, so the buffer remains unchanged and the assertion fails. Toggle S_IXUSR instead. It is nonzero on every supported platform and tests the same identity field without changing filesystem permissions. --- t/unit-tests/u-path-namespace.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/unit-tests/u-path-namespace.c b/t/unit-tests/u-path-namespace.c index b3735df2c4a4a1..b705daaf107b5a 100644 --- a/t/unit-tests/u-path-namespace.c +++ b/t/unit-tests/u-path-namespace.c @@ -96,7 +96,7 @@ void test_path_namespace__directory_identity_ignores_unrelated_entries(void) changed.st_ino++; cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); changed = original; - changed.st_mode ^= S_IXGRP; + changed.st_mode ^= S_IXUSR; cl_assert(!path_namespace_directory_stat_equal(&original, &changed)); changed = original; changed.st_uid++; From fa49e0f77a23253131e478164093d65644d1e58c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:04:17 -0500 Subject: [PATCH 376/403] status: skip an unclosable terminal index refresh The provider-token closure loop permits three queries. After each changed or trivial response, it refreshes the entire tracked index before trying again. The refresh after the third query cannot be closed: the loop immediately exits and the conservative fallback invalidates that work and refreshes the index again. Keep the attribute and manifest rechecks, but stop before that final refresh when the query budget is exhausted. The fallback still rejects the pending token, invalidates tracked and untracked proofs, and checks the worktree normally. A scripted repeated-reset test matches an independent stat-based oracle and reduces captured proof epochs from four to three without accepting or publishing the failed boundary. --- t/t7519-status-fsmonitor.sh | 47 +++++++++++++++++++++++++++++++++++++ wt-status.c | 6 +++++ 2 files changed, 53 insertions(+) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 5aebea5b4ed65b..5d01ca89c9d118 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -6662,4 +6662,51 @@ test_expect_success LINUX_SCOPED_HISTORY,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORE ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'exhausted provider resets skip an unclosable final index refresh' ' + test_when_finished "rm -rf builtin-closure-terminal-reset" && + prepare_builtin_closure_repo builtin-closure-terminal-reset untracked && + ( + cd builtin-closure-terminal-reset && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_fsmonitor_full_proof .git/index paired && + test_write_lines modified >tracked && + test_write_lines "tracked -text" >.gitattributes && + test_write_lines visible >visible && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expected && + test_grep "^1 \\.M .* tracked$" .git/expected && + test_grep "^? \\.gitattributes$" .git/expected && + test_grep "^? visible$" .git/expected && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TTTT \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expected .git/actual && + test_trace2_data fsmonitor token_closure/trivial 1 \ + <.git/status.trace >.git/trivial && + test_line_count = 3 .git/trivial && + test_trace2_data fsmonitor semantic/proof-epoch-captured 1 \ + <.git/status.trace >.git/epochs && + test_line_count = 3 .git/epochs && + test_trace2_data status \ + fsmonitor_token/terminal-rescan-skipped 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "^fsmonitor last update builtin:test:2$" \ + .git/fsmonitor && + ! test_fsmonitor_full_proof .git/index paired \ + 2>.git/unbound-proof && + test_grep "^unbound FSCF flags 9$" .git/unbound-proof + ) +' + test_done diff --git a/wt-status.c b/wt-status.c index 356bc948c0711d..e45cf6f9d2c4b1 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2031,6 +2031,12 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); if (wt_status_refresh_invalidated_manifest(s)) break; + if (closure->queries >= FSMONITOR_TOKEN_MAX_QUERIES) { + trace2_data_intmax("status", s->repo, + "fsmonitor_token/terminal-rescan-skipped", + 1); + break; + } if (validate_epoch) { wt_status_refresh_for_token( s, closure->refresh_flags, &scan_epoch, From bd208f35619f7b6e7d9e5d70038a7f2a025a9109 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:04:28 -0500 Subject: [PATCH 377/403] status: retain the identity of an index it rewrites A zero-checksum index cannot be authenticated by its trailer alone. The clean-status sidecar therefore binds it to the durable identity recorded when the index was read. If status repairs the stat cache and commits a new index inode, its postwrite sidecar attempt still compares against the old identity. The repair succeeds, but sidecar issuance fails and the next status remains cold. Let this caller request a receipt for its own canonical index write. Keep a close-on-exec duplicate of the lockfile descriptor, then record the installed file's identity after the rename and before invoking post-index-change. After the hook returns, adopt that identity only if the held descriptor and canonical pathname still match every recorded field, header, entry count, and trailer. A hook's in-place rewrite or atomic replacement therefore cannot be mistaken for our own write. Leave the original source descriptor and logical digest intact, and keep generic zero-checksum pinning strict. Unsupported, private, split, sparse, failed, and skipped writes do not produce receipts. Also schedule sidecar reissuance when its opening provider query loses the boundary, even if the old sidecar's source identity still matches. Cover zero-checksum repair and first-reset publication, self-removing hooks, foreign replacement, no-op writes, and private indexes. The clean controls require the following status to hit the new sidecar. --- builtin/commit.c | 11 +- clean-status-index.c | 155 +++++++++++ clean-status-index.h | 30 +++ read-cache-ll.h | 10 + read-cache.c | 68 ++++- t/t7530-status-clean-sidecar.sh | 442 ++++++++++++++++++++++++++++++++ 6 files changed, 701 insertions(+), 15 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 363797652eef2f..86211af7996fc0 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1892,6 +1892,8 @@ struct repository *repo UNUSED) struct clean_status_index_snapshot scoped_history_source = { .fd = -1, }; + struct clean_status_index_write_receipt written_index = + CLEAN_STATUS_INDEX_WRITE_RECEIPT_INIT; struct object_id oid; static struct option builtin_status_options[] = { OPT__VERBOSE(&verbose, N_("be verbose")), @@ -2020,7 +2022,8 @@ struct repository *repo UNUSED) clean_status_identity_is_durable()) reissue_clean_sidecar = clean_status_sidecar_needs_reissue( - the_repository, repository_inputs_changed); + the_repository, repository_inputs_changed || + sidecar_provider_reset); if (status_format != STATUS_FORMAT_PORCELAIN && status_format != STATUS_FORMAT_PORCELAIN_V2) { @@ -2219,7 +2222,10 @@ struct repository *repo UNUSED) "history/scoped-source-epoch-mismatch", 1); } if (0 <= fd) { - repo_update_index_if_able(the_repository, &index_lock); + repo_update_index_if_able_with_receipt(the_repository, &index_lock, + &written_index); + clean_status_index_adopt_write_receipt(the_repository->index, + &written_index); if (save_history_after_write && !hook_exists(the_repository, "post-index-change") && repo_hold_locked_index(the_repository, &index_lock, 0) >= 0) { @@ -2239,6 +2245,7 @@ struct repository *repo UNUSED) rollback_lock_file(&index_lock); } } + clean_status_index_write_receipt_release(&written_index); clean_status_index_snapshot_release(&scoped_history_source); if (s.relative_paths) diff --git a/clean-status-index.c b/clean-status-index.c index 50609cf323d693..e9eb4a940d79d4 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -3,6 +3,7 @@ #include "clean-status-index.h" #include "clean-status-internal.h" #include "clean-status-sidecar.h" +#include "environment.h" #include "hash-framing.h" #include "object.h" #include "read-cache-ll.h" @@ -228,6 +229,160 @@ void clean_status_index_snapshot_release( snapshot->fd = -1; } +static int write_receipt_owner_matches(const struct stat *st) +{ +#ifdef __APPLE__ + return st->st_uid == geteuid(); +#else + (void)st; + return 0; +#endif +} + +static int write_receipt_is_eligible(const struct index_state *istate) +{ + const struct clean_status_state *state; + + if (!istate || !istate->initialized || !istate->repo || + !istate->repo->initialized) + return 0; + state = istate->clean_status; + return clean_status_identity_is_durable() && fstat_is_reliable() && + state && state->config_enforced && state->current_config_valid && + state->source_identity_valid && + clean_status_external_history_enabled(istate) && + !getenv(INDEX_ENVIRONMENT) && istate == istate->repo->index && + !istate->split_index && istate->sparse_index == INDEX_EXPANDED && + !repo_config_values(istate->repo)->apply_sparse_checkout && + is_null_oid(&istate->oid); +} + +int clean_status_index_prepare_write_receipt( + struct index_state *istate, int lock_fd, + struct clean_status_index_write_receipt *receipt) +{ +#if defined(__APPLE__) && defined(F_DUPFD_CLOEXEC) && \ + defined(F_GETFL) && defined(O_ACCMODE) + struct clean_status_identity initial, held; + struct stat before, after; + int owned, flags; + + if (!receipt || receipt->snapshot.fd >= 0 || receipt->istate || + receipt->recorded || !write_receipt_is_eligible(istate) || + lock_fd < 0) + return -1; + flags = fcntl(lock_fd, F_GETFL); + if (flags < 0 || (flags & O_ACCMODE) != O_RDWR || + fstat(lock_fd, &before) || !write_receipt_owner_matches(&before) || + clean_status_identity_from_stat(&initial, &before)) + return -1; + /* The writer closes its descriptor before committing the lockfile. */ + owned = fcntl(lock_fd, F_DUPFD_CLOEXEC, 0); + if (owned < 0) + return -1; + if (fstat(owned, &after) || + clean_status_identity_from_stat(&held, &after) || + !clean_status_identity_equal(&initial, &held)) { + close(owned); + return -1; + } + receipt->snapshot.fd = owned; + receipt->source_identity = istate->clean_status->source_identity; + receipt->istate = istate; + return 0; +#else + (void)istate; + (void)lock_fd; + (void)receipt; + return -1; +#endif +} + +void clean_status_index_record_write_receipt( + struct index_state *istate, + struct clean_status_index_write_receipt *receipt) +{ + struct clean_status_index_snapshot snapshot; + struct stat st; + + if (!receipt || receipt->snapshot.fd < 0) + return; + if (receipt->recorded || receipt->istate != istate || + !write_receipt_is_eligible(istate) || + !clean_status_identity_equal( + &receipt->source_identity, + &istate->clean_status->source_identity)) + goto fail; + + /* Capture ctime after our rename, but before any hook can change it. */ + snapshot = receipt->snapshot; + if (fstat(snapshot.fd, &st) || !write_receipt_owner_matches(&st) || + clean_status_identity_from_stat(&snapshot.identity, &st) || + snapshot_read(snapshot.fd, &st, istate->repo->hash_algo, + &snapshot.version, &snapshot.cache_nr, + &snapshot.checksum) || + snapshot.version != istate->version || + snapshot.cache_nr != istate->cache_nr || + !oideq(&snapshot.checksum, &istate->oid) || + !clean_status_index_snapshot_still_matches_path( + &snapshot, istate->repo->index_file, + istate->repo->hash_algo)) + goto fail; + receipt->snapshot = snapshot; + receipt->recorded = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "history/own-write-source-recorded", 1); + return; + +fail: + clean_status_index_write_receipt_release(receipt); +} + +int clean_status_index_adopt_write_receipt( + struct index_state *istate, + struct clean_status_index_write_receipt *receipt) +{ + struct clean_status_state *state; + struct stat st; + int adopted = 0; + + if (!receipt || !receipt->recorded || receipt->istate != istate || + !write_receipt_is_eligible(istate)) + goto done; + state = istate->clean_status; + if (!clean_status_identity_equal(&receipt->source_identity, + &state->source_identity) || + receipt->snapshot.version != istate->version || + receipt->snapshot.cache_nr != istate->cache_nr || + !oideq(&receipt->snapshot.checksum, &istate->oid) || + fstat(receipt->snapshot.fd, &st) || + !write_receipt_owner_matches(&st) || + !clean_status_index_snapshot_still_matches_path( + &receipt->snapshot, istate->repo->index_file, + istate->repo->hash_algo)) + goto done; + + /* The original descriptor and logical digest still name the old source. */ + state->source_identity = receipt->snapshot.identity; + trace2_data_intmax("fsmonitor", istate->repo, + "history/own-write-source-adopted", 1); + adopted = 1; + +done: + clean_status_index_write_receipt_release(receipt); + return adopted; +} + +void clean_status_index_write_receipt_release( + struct clean_status_index_write_receipt *receipt) +{ + if (!receipt) + return; + clean_status_index_snapshot_release(&receipt->snapshot); + memset(receipt, 0, sizeof(*receipt)); + receipt->snapshot.fd = -1; +} + int clean_status_index_entries_are_certifiable( const struct index_state *istate) { diff --git a/clean-status-index.h b/clean-status-index.h index 1728b6021ddb3c..370792b66f7e4b 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -14,6 +14,36 @@ struct clean_status_index_snapshot { int fd; }; +/* + * An opt-in receipt for a canonical index write. Only the index writer may + * record it, after committing its lockfile and before running hooks. The + * caller must initialize and release it, even if no write was performed. + */ +struct clean_status_index_write_receipt { + struct clean_status_index_snapshot snapshot; + struct clean_status_identity source_identity; + const struct index_state *istate; + unsigned int recorded : 1; +}; + +#define CLEAN_STATUS_INDEX_WRITE_RECEIPT_INIT \ + { .snapshot = { .fd = -1 } } + +/* Writer-only lifecycle: prepare duplicates lock_fd; record fails closed. */ +int clean_status_index_prepare_write_receipt( + struct index_state *istate, int lock_fd, + struct clean_status_index_write_receipt *receipt); +void clean_status_index_record_write_receipt( + struct index_state *istate, + struct clean_status_index_write_receipt *receipt); + +/* Consumes the receipt and returns whether the written source was adopted. */ +int clean_status_index_adopt_write_receipt( + struct index_state *istate, + struct clean_status_index_write_receipt *receipt); +void clean_status_index_write_receipt_release( + struct clean_status_index_write_receipt *receipt); + int clean_status_index_snapshot_open( struct clean_status_index_snapshot *snapshot, const char *path, const struct git_hash_algo *algo); diff --git a/read-cache-ll.h b/read-cache-ll.h index a6899217158916..05c1db3d1c0e82 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -31,6 +31,7 @@ struct cache_entry { char name[FLEX_ARRAY]; /* more */ }; +struct clean_status_index_write_receipt; struct clean_status_proof_epoch; struct preload_bulk_stat_update; @@ -360,6 +361,15 @@ int is_index_unborn(struct index_state *); */ int write_locked_index(struct index_state *, struct lock_file *lock, unsigned flags); +/* + * Like repo_update_index_if_able(), with an optional receipt for the canonical + * file actually written. The receipt must be initialized by the caller and + * remains empty if the write is skipped, fails, or is not eligible. + */ +void repo_update_index_if_able_with_receipt( + struct repository *repo, struct lock_file *lock, + struct clean_status_index_write_receipt *receipt); + void discard_index(struct index_state *); void move_index_extensions(struct index_state *dst, struct index_state *src); int unmerged_index(const struct index_state *); diff --git a/read-cache.c b/read-cache.c index ed9bf5b24276be..f2ed21be6c169c 100644 --- a/read-cache.c +++ b/read-cache.c @@ -17,6 +17,7 @@ #include "lockfile.h" #include "cache-tree.h" #include "clean-status.h" +#include "clean-status-index.h" #include "refs.h" #include "dir.h" #include "object-file.h" @@ -3343,17 +3344,31 @@ int has_racy_timestamp(struct index_state *istate) return 0; } -void repo_update_index_if_able(struct repository *repo, - struct lock_file *lockfile) +static int write_locked_index_with_receipt( + struct index_state *istate, struct lock_file *lock, + unsigned flags, struct clean_status_index_write_receipt *receipt); + +void repo_update_index_if_able_with_receipt( + struct repository *repo, struct lock_file *lockfile, + struct clean_status_index_write_receipt *receipt) { + if (receipt) + clean_status_index_write_receipt_release(receipt); if ((repo->index->cache_changed || has_racy_timestamp(repo->index)) && repo_verify_index(repo)) - write_locked_index(repo->index, lockfile, COMMIT_LOCK); + write_locked_index_with_receipt(repo->index, lockfile, + COMMIT_LOCK, receipt); else rollback_lock_file(lockfile); } +void repo_update_index_if_able(struct repository *repo, + struct lock_file *lockfile) +{ + repo_update_index_if_able_with_receipt(repo, lockfile, NULL); +} + static int record_eoie(void) { int val; @@ -3795,17 +3810,25 @@ static int commit_locked_index(struct lock_file *lk) return commit_lock_file(lk); } -static int do_write_locked_index(struct index_state *istate, - struct lock_file *lock, - unsigned flags, - enum write_extensions write_extensions) +static int do_write_locked_index( + struct index_state *istate, struct lock_file *lock, unsigned flags, + enum write_extensions write_extensions, + struct clean_status_index_write_receipt *receipt) { int ret; int was_full = istate->sparse_index == INDEX_EXPANDED; + int receipt_prepared = 0; + + if (receipt && (flags & COMMIT_LOCK) && !alternate_index_output && + !(write_extensions & WRITE_SPLIT_INDEX_EXTENSION)) + receipt_prepared = !clean_status_index_prepare_write_receipt( + istate, get_lock_file_fd(lock), receipt); ret = convert_to_sparse(istate, 0); if (ret) { + if (receipt_prepared) + clean_status_index_write_receipt_release(receipt); warning(_("failed to convert to a sparse-index")); return ret; } @@ -3819,12 +3842,21 @@ static int do_write_locked_index(struct index_state *istate, if (was_full) ensure_full_index(istate); - if (ret) + if (ret) { + if (receipt_prepared) + clean_status_index_write_receipt_release(receipt); return ret; + } if (flags & COMMIT_LOCK) ret = commit_locked_index(lock); else ret = close_lock_file_gently(lock); + if (receipt_prepared) { + if (!ret) + clean_status_index_record_write_receipt(istate, receipt); + else + clean_status_index_write_receipt_release(receipt); + } run_hooks_l(the_repository, "post-index-change", istate->updated_workdir ? "1" : "0", @@ -3841,7 +3873,8 @@ static int write_split_index(struct index_state *istate, { int ret; prepare_to_write_split_index(istate); - ret = do_write_locked_index(istate, lock, flags, WRITE_ALL_EXTENSIONS); + ret = do_write_locked_index(istate, lock, flags, WRITE_ALL_EXTENSIONS, + NULL); finish_writing_split_index(istate); return ret; } @@ -3983,8 +4016,9 @@ static int too_many_not_shared_entries(struct index_state *istate) return (int64_t)istate->cache_nr * max_split < (int64_t)not_shared * 100; } -int write_locked_index(struct index_state *istate, struct lock_file *lock, - unsigned flags) +static int write_locked_index_with_receipt( + struct index_state *istate, struct lock_file *lock, + unsigned flags, struct clean_status_index_write_receipt *receipt) { int new_shared_index, ret, test_split_index_env; struct split_index *si = istate->split_index; @@ -4008,7 +4042,8 @@ int write_locked_index(struct index_state *istate, struct lock_file *lock, alternate_index_output || (istate->cache_changed & ~EXTMASK)) { ret = do_write_locked_index(istate, lock, flags, - ~WRITE_SPLIT_INDEX_EXTENSION); + ~WRITE_SPLIT_INDEX_EXTENSION, + receipt); goto out; } @@ -4038,7 +4073,8 @@ int write_locked_index(struct index_state *istate, struct lock_file *lock, free(path); if (!temp) { ret = do_write_locked_index(istate, lock, flags, - ~WRITE_SPLIT_INDEX_EXTENSION); + ~WRITE_SPLIT_INDEX_EXTENSION, + receipt); goto out; } ret = write_shared_index(istate, &temp, flags); @@ -4068,6 +4104,12 @@ int write_locked_index(struct index_state *istate, struct lock_file *lock, return ret; } +int write_locked_index(struct index_state *istate, struct lock_file *lock, + unsigned flags) +{ + return write_locked_index_with_receipt(istate, lock, flags, NULL); +} + /* * Read the index file that is potentially unmerged into given * index_state, dropping any unmerged entries to stage #0 (potentially diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index a60fbc54cfa5dd..937470b0262044 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2734,4 +2734,446 @@ test_expect_success PERL_TEST_HELPERS \ ) ' +test_expect_success PERL_TEST_HELPERS \ + 'a rewritten skipHash index reissues its clean status sidecar' ' + test_when_finished "rm -rf sidecar-skiphash-postwrite" && + test_create_repo sidecar-skiphash-postwrite && + ( + cd sidecar-skiphash-postwrite && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime =-180 tracked && + git -c core.fsmonitor=false update-index --refresh && + git config index.version 4 && + git config index.skipHash true && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + test_env GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + rawsz=$(test_oid rawsz) && + dd if=/dev/zero of=.git/zero-trailer \ + bs="$rawsz" count=1 2>/dev/null && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issue && + test_must_be_empty .git/issue && + test_path_is_file .git/index.csts && + + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status >.git/expected && + cp .git/index .git/index.before-noop && + cp .git/index.csts .git/index.csts.before-noop && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/noop.trace" \ + git status >.git/noop && + test_cmp .git/expected .git/noop && + test_trace2_data status clean-proof/hit 1 \ + <.git/noop.trace && + test_region ! index do_read_index .git/noop.trace && + test_region ! index do_write_index .git/noop.trace && + test_cmp_bin .git/index.before-noop .git/index && + test_cmp_bin .git/index.csts.before-noop .git/index.csts && + + before_inode=$(/usr/bin/stat -f %i .git/index) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --force-write-index && + foreign_inode=$(/usr/bin/stat -f %i .git/index) && + test "$before_inode" != "$foreign_inode" && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + test_cmp_bin .git/index.csts.before-noop .git/index.csts && + cp .git/index .git/index.foreign && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/foreign.trace" \ + git status >.git/foreign && + test_cmp .git/expected .git/foreign && + test_trace2_data status clean-proof/miss \ + fast-index-mismatch <.git/foreign.trace && + ! test_trace2_data status clean-proof/hit 1 \ + <.git/foreign.trace && + test_region ! index do_write_index .git/foreign.trace && + test_cmp_bin .git/index.foreign .git/index && + test_cmp_bin .git/index.csts.before-noop .git/index.csts && + + test-tool chmtime =-90 tracked && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status >.git/expected && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/repair.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/actual && + test_cmp .git/expected .git/actual && + test_trace2_data status clean-proof/miss \ + fast-index-mismatch <.git/repair.trace && + test_trace2_data fsmonitor apply_count 1 \ + <.git/repair.trace && + test_region index do_write_index .git/repair.trace && + repaired_inode=$(/usr/bin/stat -f %i .git/index) && + test "$repaired_inode" != "$foreign_inode" && + ! test_cmp_bin .git/index.foreign .git/index && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + test_trace2_data status clean-proof/sidecar 1 \ + <.git/repair.trace && + test_trace2_data status clean-proof/postwrite-reissued 1 \ + <.git/repair.trace && + ! test_trace2_data status clean-proof/miss \ + issue-pinned-inputs <.git/repair.trace && + + cp .git/index .git/index.before-follower && + cp .git/index.csts .git/index.csts.before-follower && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/follower.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/follower && + test_cmp .git/expected .git/follower && + test_trace2_data status clean-proof/hit 1 \ + <.git/follower.trace && + test_region ! index do_read_index .git/follower.trace && + test_region ! index do_write_index .git/follower.trace && + test_cmp_bin .git/index.before-follower .git/index && + test_cmp_bin .git/index.csts.before-follower .git/index.csts + ) +' + +test_expect_success PERL_TEST_HELPERS \ + 'index write receipts reject in-place and foreign hook mutations' ' + test_when_finished "rm -rf sidecar-receipt-valid sidecar-receipt-same-inode sidecar-receipt-foreign-replace" && + for mode in valid same-inode foreign-replace + do + repo=sidecar-receipt-$mode && + test_create_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime =-180 tracked && + git -c core.fsmonitor=false update-index --refresh && + git config index.version 4 && + git config index.skipHash true && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + test_env GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issued && + test_must_be_empty .git/issued && + test_path_is_file .git/index.csts && + cp .git/index.csts .git/sidecar.before && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --force-write-index && + test_cmp_bin .git/sidecar.before .git/index.csts && + rawsz=$(test_oid rawsz) && + printf "%s\n" "$rawsz" >.git/receipt-rawsz && + dd if=/dev/zero of=.git/zero-trailer \ + bs="$rawsz" count=1 2>/dev/null && + test-tool chmtime =-90 tracked && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status >.git/expected && + + if test "$mode" != valid + then + printf "%s\n" "$mode" >.git/receipt-mode && + cat >.git/receipt-mutate.pl <<-\EOF && + use strict; + use warnings; + my $path = shift; + open(my $index, "+<", $path) or die "open: $!"; + binmode $index; + read($index, my $header, 12) == 12 or die "header"; + substr($header, 0, 4) eq "DIRC" or die "magic"; + unpack("N", substr($header, 8, 4)) or die "entries"; + seek($index, 16, 0) or die "seek"; + read($index, my $ctime, 4) == 4 or die "ctime"; + seek($index, 16, 0) or die "rewind"; + my $next = (unpack("N", $ctime) + 1) % 1000000000; + print {$index} pack("N", $next) or die "write"; + close($index) or die "close"; + EOF + write_script .git/hooks/post-index-change <<-\EOF + mode=$(cat .git/receipt-mode) && + /usr/bin/stat -f %i .git/index >.git/hook-inode-before && + cp .git/index .git/hook-index-before && + rm -f "$0" && + case "$mode" in + same-inode) + perl .git/receipt-mutate.pl .git/index + ;; + foreign-replace) + cp .git/index .git/hook-replacement && + mv .git/hook-replacement .git/index + ;; + *) + exit 1 + ;; + esac && + /usr/bin/stat -f %i .git/index >.git/hook-inode-after && + tail -c "$(cat .git/receipt-rawsz)" .git/index \ + >.git/hook-trailer + EOF + else + : + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/actual && + test_cmp .git/expected .git/actual && + test_region index do_write_index .git/status.trace && + test_trace2_data fsmonitor \ + history/own-write-source-recorded 1 \ + <.git/status.trace && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + + case "$mode" in + valid) + test_trace2_data fsmonitor \ + history/own-write-source-adopted 1 \ + <.git/status.trace && + test_trace2_data status \ + clean-proof/postwrite-reissued 1 \ + <.git/status.trace + ;; + same-inode|foreign-replace) + test_path_is_missing .git/hooks/post-index-change && + test_cmp_bin .git/zero-trailer .git/hook-trailer && + ! test_trace2_data fsmonitor \ + history/own-write-source-adopted 1 \ + <.git/status.trace && + ! test_trace2_data status \ + clean-proof/sidecar 1 \ + <.git/status.trace && + ! test_trace2_data status \ + clean-proof/postwrite-reissued 1 \ + <.git/status.trace && + test_cmp_bin .git/sidecar.before .git/index.csts && + if test "$mode" = same-inode + then + test_cmp .git/hook-inode-before \ + .git/hook-inode-after && + ! test_cmp_bin .git/hook-index-before .git/index + else + ! test_cmp .git/hook-inode-before \ + .git/hook-inode-after && + test_cmp_bin .git/hook-index-before .git/index + fi + ;; + esac && + cp .git/index .git/follower-index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/follower.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/follower && + test_cmp .git/expected .git/follower && + test_cmp_bin .git/follower-index .git/index && + test_region ! index do_write_index .git/follower.trace && + if test "$mode" = valid + then + test_trace2_data status clean-proof/hit 1 \ + <.git/follower.trace + else + test_trace2_data status clean-proof/miss \ + fast-index-mismatch <.git/follower.trace && + ! test_trace2_data status clean-proof/hit 1 \ + <.git/follower.trace + fi + ) || return 1 + done +' + +test_expect_success PERL_TEST_HELPERS \ + 'index write receipts reject unchanged and private indexes' ' + test_when_finished "rm -rf sidecar-receipt-private" && + test_create_repo sidecar-receipt-private && + ( + cd sidecar-receipt-private && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime =-180 tracked && + git -c core.fsmonitor=false update-index --refresh && + git config index.version 4 && + git config index.skipHash true && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + test_env GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issued && + test_must_be_empty .git/issued && + test_path_is_file .git/index.csts && + cp .git/index .git/canonical.before && + cp .git/index.csts .git/sidecar.before && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/noop.trace" \ + git status >.git/noop && + test_trace2_data status clean-proof/hit 1 \ + <.git/noop.trace && + ! test_trace2_data fsmonitor \ + history/own-write-source-recorded 1 <.git/noop.trace && + ! test_trace2_data fsmonitor \ + history/own-write-source-adopted 1 <.git/noop.trace && + test_cmp_bin .git/canonical.before .git/index && + test_cmp_bin .git/sidecar.before .git/index.csts && + + cp .git/index .git/private.index && + test-tool chmtime =-90 tracked && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status >.git/expected && + GIT_INDEX_FILE="$PWD/.git/private.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$PWD/.git/private.trace" \ + git status >.git/private.actual && + test_cmp .git/expected .git/private.actual && + test_region index do_write_index .git/private.trace && + ! test_trace2_data fsmonitor \ + history/own-write-source-recorded 1 <.git/private.trace && + ! test_trace2_data fsmonitor \ + history/own-write-source-adopted 1 <.git/private.trace && + test_cmp_bin .git/canonical.before .git/index && + test_cmp_bin .git/sidecar.before .git/index.csts + ) +' + +test_expect_success PERL_TEST_HELPERS \ + 'a provider reset reissues an otherwise current skipHash sidecar' ' + test_when_finished "rm -rf sidecar-receipt-provider-reset" && + test_create_repo sidecar-receipt-provider-reset && + ( + cd sidecar-receipt-provider-reset && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime =-180 tracked && + git -c core.fsmonitor=false update-index --refresh && + git config index.version 4 && + git config index.skipHash true && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + test_env GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issued && + test_must_be_empty .git/issued && + test_path_is_file .git/index.csts && + rawsz=$(test_oid rawsz) && + dd if=/dev/zero of=.git/zero-trailer \ + bs="$rawsz" count=1 2>/dev/null && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status >.git/expected && + cp .git/index .git/index.before && + cp .git/index.csts .git/sidecar.before && + before_inode=$(/usr/bin/stat -f %i .git/index) && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/hit.trace" \ + git status >.git/hit && + test_cmp .git/expected .git/hit && + test_trace2_data status clean-proof/hit 1 <.git/hit.trace && + test_region ! index do_read_index .git/hit.trace && + test_region ! index do_write_index .git/hit.trace && + test_cmp_bin .git/index.before .git/index && + test_cmp_bin .git/sidecar.before .git/index.csts && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/actual && + test_cmp .git/expected .git/actual && + test_trace2_data status clean-proof/miss \ + fast-provider-changed <.git/reset.trace && + test_trace2_data status clean-proof/provider-reset-carried 1 \ + <.git/reset.trace && + test_region index do_write_index .git/reset.trace && + after_inode=$(/usr/bin/stat -f %i .git/index) && + test "$before_inode" != "$after_inode" && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + test_trace2_data fsmonitor \ + history/own-write-source-recorded 1 <.git/reset.trace && + test_trace2_data fsmonitor \ + history/own-write-source-adopted 1 <.git/reset.trace && + test_trace2_data status clean-proof/sidecar 1 \ + <.git/reset.trace && + test_trace2_data status clean-proof/postwrite-reissued 1 \ + <.git/reset.trace && + ! test_cmp_bin .git/sidecar.before .git/index.csts && + cp .git/index .git/index.after && + cp .git/index.csts .git/sidecar.after && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/follower.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/follower && + test_cmp .git/expected .git/follower && + test_trace2_data status clean-proof/hit 1 \ + <.git/follower.trace && + test_region ! index do_read_index .git/follower.trace && + test_region ! index do_write_index .git/follower.trace && + test_cmp_bin .git/index.after .git/index && + test_cmp_bin .git/sidecar.after .git/index.csts + ) +' + test_done From 880b232f8aeb50a6590dbc25508470e27b8f97f4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:49:28 -0500 Subject: [PATCH 378/403] status: trace provider resets in the clean sidecar probe The clean sidecar reports fast-provider-changed for both a nonempty delta and an unavailable provider boundary. Unlike the ordinary index reader, it does not record when the latter was a TRIVIAL response. A trace stopped before the index refresh therefore cannot distinguish a real worktree change from a provider reset. Emit the existing query/trivial-response event as soon as the sidecar probe receives TRIVIAL. Reuse the parsed response without another IPC request, and keep errors and nonempty deltas out of this counter. The existing reset-propagation test checks all three outcomes, including a second TRIVIAL from the ordinary reader. --- clean-status-fast.c | 3 +++ t/t7530-status-clean-sidecar.sh | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/clean-status-fast.c b/clean-status-fast.c index 53f05ce1df41a8..0ec70fc62f92e2 100644 --- a/clean-status-fast.c +++ b/clean-status-fast.c @@ -284,6 +284,9 @@ int clean_status_try_sidecar( record.sidecar.token, record.sidecar.token_len); if (query_builtin_fsmonitor(query_token, &query) != FSMONITOR_QUERY_DELTA) { + if (query.outcome == FSMONITOR_QUERY_TRIVIAL) + trace2_data_intmax("fsm_client", NULL, + "query/trivial-response", 1); /* A later successful query cannot erase this lost boundary. */ *provider_reset = 1; trace_miss(repo, "fast-provider-changed"); diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 937470b0262044..55a7dd7a3f38e6 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -2681,6 +2681,22 @@ test_expect_success PERL_TEST_HELPERS \ <".git/$outcome.trace" && test_trace2_data status clean-proof/provider-reset-carried 1 \ <".git/$outcome.trace" && + case "$outcome" in + E) + ! test_trace2_data fsm_client query/trivial-response 1 \ + <".git/$outcome.trace" + ;; + *) + test_trace2_data fsm_client query/trivial-response 1 \ + <".git/$outcome.trace" >.git/trivial-responses && + if test "$outcome" = TT + then + test_line_count = 2 .git/trivial-responses + else + test_line_count = 1 .git/trivial-responses + fi + ;; + esac && test_grep ! "\"key\":\"clean-proof/hit\"" \ ".git/$outcome.trace" && test_cmp_bin .git/index.before .git/index && @@ -2718,6 +2734,8 @@ test_expect_success PERL_TEST_HELPERS \ test_cmp .git/expect .git/actual && ! test_trace2_data status clean-proof/provider-reset-carried 1 \ <.git/delta.trace && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/delta.trace && test_cmp_bin .git/index.before .git/index && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/writable.trace" \ From 798fb21ad82bdae76e56b7e72e51d62ae6b432e5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:56:48 -0500 Subject: [PATCH 379/403] fsmonitor: require failed-cookie token retirement 2444e35250 (fsmonitor: retire the provider token after a failed cookie, 2026-08-17) prevents a failed synchronization cookie from leaving an old token usable. Its daemon still advertises the same capabilities and token prefix as earlier versions. A new client can therefore keep using an old daemon which lacks that guarantee. On macOS, the existing token-prefix shortcut also bypasses a separate capability check. Advertise cookie-token-retirement-v1 and mark every daemon token with cookie-v1. Require the marker in the actual query response, including an attested legacy response. Discard unmarked bytes before checking capabilities: a reply from a replacement daemon cannot authenticate a response from the preceding process. Reuse the existing bounded restart or retry path, and add no capability query to marked warm responses. Preserve the existing macOS prefix and raw, query-v1, and query-v2 interfaces so older clients can use the upgraded daemon. Update the deterministic token fixtures too; test tokens have no acceptance bypass. Add backend-only regressions for a fully capable pre-retirement daemon, an advertised capability with an unmarked response, and warm queries which do not repeat capability negotiation. --- builtin/fsmonitor--daemon.c | 12 +- fsmonitor-ipc.c | 49 +++++--- fsmonitor-ipc.h | 3 + t/helper/test-simple-ipc.c | 66 ++++++++-- t/meson.build | 1 + t/t1602-index-witness.sh | 7 +- t/t7527-builtin-fsmonitor.sh | 35 ++++-- t/t7537-fsmonitor-cookie-compat.sh | 189 +++++++++++++++++++++++++++++ 8 files changed, 321 insertions(+), 41 deletions(-) create mode 100755 t/t7537-fsmonitor-cookie-compat.sh diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index b52421f672c930..1c53a5af4dd6df 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -410,15 +410,18 @@ static struct fsmonitor_token_data *fsmonitor_new_token_data(void) if (test_env_value < 0) test_env_value = git_env_bool("GIT_TEST_FSMONITOR_TOKEN", 0); +#ifdef __APPLE__ + strbuf_addstr(&token->token_id, + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX); +#endif + strbuf_addstr(&token->token_id, + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX); + if (!test_env_value) { struct timeval tv; struct tm tm; time_t secs; -#ifdef __APPLE__ - strbuf_addstr(&token->token_id, - FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX); -#endif gettimeofday(&tv, NULL); secs = tv.tv_sec; gmtime_r(&secs, &tm); @@ -754,6 +757,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, if (!strcmp(command, FSMONITOR_IPC_CAPABILITY_COMMAND)) { static const char capabilities[] = FSMONITOR_IPC_QUERY_VERSION "\n" + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY "\n" #ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 799db433cf86f6..5ddd7503ca60f4 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -437,39 +437,42 @@ static int server_supports_bound_queries(void) static int server_supports_required_capabilities(void) { -#ifdef __APPLE__ struct strbuf answer = STRBUF_INIT; int ret; ret = !try_send_command(FSMONITOR_IPC_CAPABILITY_COMMAND, &answer, NULL, 1) && has_capability(&answer, FSMONITOR_IPC_QUERY_VERSION) && + has_capability(&answer, + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY); +#ifdef __APPLE__ + ret = ret && has_capability(&answer, FSMONITOR_IPC_HARDLINK_QUERY_VERSION) && has_capability(&answer, FSMONITOR_IPC_DIR_METADATA_CAPABILITY) && has_capability(&answer, FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY); +#endif strbuf_release(&answer); return ret; -#else - return server_supports_bound_queries(); -#endif } -#ifdef __APPLE__ -static int query_identifies_filtered_daemon(const char *token, - const struct strbuf *answer) +static int response_identifies_cookie_retiring_daemon( + const struct strbuf *answer) { static const char prefix[] = - "builtin:" FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX; + "builtin:" +#ifdef __APPLE__ + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX +#endif + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX; const char *end = memchr(answer->buf, '\0', answer->len); - return starts_with(token, prefix) && end && + return end && (size_t)(end - answer->buf) >= sizeof(prefix) - 1 && !memcmp(answer->buf, prefix, sizeof(prefix) - 1); } -#endif #if defined(__APPLE__) || defined(__linux__) static int legacy_peer_credentials( @@ -940,26 +943,32 @@ int fsmonitor_ipc__send_query(const char *since_token, trace2_data_intmax("fsm_client", NULL, "query/response-length", answer->len); -#ifdef __APPLE__ - if (!ret && !query_identifies_filtered_daemon(tok, answer) && - !server_supports_required_capabilities()) { + if (!ret && + !response_identifies_cookie_retiring_daemon(answer)) { + int compatible = server_supports_required_capabilities(); + + trace2_data_intmax("fsm_client", NULL, + "query/unmarked-response", 1); strbuf_reset(answer); ret = -1; if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS || - restart_incompatible_daemon()) + (!compatible && restart_incompatible_daemon())) goto done; - options.wait_if_not_found = 1; + options.wait_if_not_found = !compatible; goto try_again; } -#endif if (!ret && is_trivial_response(answer) && !server_supports_bound_queries()) { if (!try_send_attested_legacy_query( tok, &identity, answer)) { - if (legacy_worktree_authenticated) - *legacy_worktree_authenticated = 1; - ret = 0; - goto done; + if (response_identifies_cookie_retiring_daemon(answer)) { + if (legacy_worktree_authenticated) + *legacy_worktree_authenticated = 1; + ret = 0; + goto done; + } + trace2_data_intmax("fsm_client", NULL, + "query/unmarked-response", 1); } /* * A daemon predating bound queries treats query-v1 as diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index 39d8d9cdb6c5f8..ba1c05eea5c065 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -16,6 +16,9 @@ struct repository; #define FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "hardlink-inode-v1" #define FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX \ FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX "inode-v1." +#define FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY \ + "cookie-token-retirement-v1" +#define FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX "cookie-v1." #define FSMONITOR_IPC_WORKTREE_ID_HEX 64 /* Hash the canonical worktree root and its stable filesystem identity. */ diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index d1f2149740fbcc..a7e4750fc9be2a 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -3,6 +3,7 @@ */ #include "test-tool.h" +#include "fsmonitor-ipc.h" #include "gettext.h" #include "simple-ipc.h" #include "parse-options.h" @@ -162,6 +163,8 @@ static int my_app_data = 42; static int fsmonitor_legacy; static int fsmonitor_capability_superset; static int fsmonitor_pre_dir_metadata; +static int fsmonitor_pre_cookie_retirement; +static int fsmonitor_unmarked_response; static int fsmonitor_disconnect_first; static ipc_server_application_cb test_app_cb; @@ -172,35 +175,69 @@ static int app__fsmonitor_capability_superset( struct ipc_server_reply_data *reply_data) { static const char capability_command[] = "get-capabilities"; - static const char capabilities[] = "query-v1\nquery-v2\n" + static const char capabilities[] = + FSMONITOR_IPC_QUERY_VERSION "\n" + FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY "\n" #ifdef __APPLE__ - "dir-metadata-filter-v1\n" - "hardlink-inode-v1\n" + FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" + FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n" #endif ; - static const char pre_dir_metadata_capabilities[] = "query-v1\n"; - static const char token[] = "builtin:test-capable:0"; + static const char pre_cookie_capabilities[] = + FSMONITOR_IPC_QUERY_VERSION "\n" +#ifdef __APPLE__ + FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" + FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" + FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n" +#endif + ; + static const char pre_dir_metadata_capabilities[] = + FSMONITOR_IPC_QUERY_VERSION "\n"; + static const char current_token[] = + "builtin:" +#ifdef __APPLE__ + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX +#endif + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX "test-capable:0"; + static const char old_token[] = + "builtin:" +#ifdef __APPLE__ + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX +#endif + "test-pre-cookie:0"; + const char *token; const char *query; - size_t query_len; + size_t token_len, query_len; int ret; + trace2_data_string("fsmonitor", NULL, "request", command); + if (command_len == sizeof(capability_command) - 1 && !memcmp(command, capability_command, command_len)) { if (fsmonitor_pre_dir_metadata) return reply_cb(reply_data, pre_dir_metadata_capabilities, sizeof(pre_dir_metadata_capabilities) - 1); + if (fsmonitor_pre_cookie_retirement) + return reply_cb(reply_data, + pre_cookie_capabilities, + sizeof(pre_cookie_capabilities) - 1); return reply_cb(reply_data, capabilities, sizeof(capabilities) - 1); } + token = fsmonitor_pre_dir_metadata || + fsmonitor_pre_cookie_retirement || fsmonitor_unmarked_response ? + old_token : current_token; + token_len = strlen(token); query = memchr(command, '\n', command_len); query_len = query ? command_len - (query + 1 - command) : 0; - ret = reply_cb(reply_data, token, sizeof(token)); + ret = reply_cb(reply_data, token, token_len + 1); if (!ret && ((!starts_with(command, "query-v1 ") && !starts_with(command, "query-v2 ")) || - query_len != sizeof(token) - 1 || + query_len != token_len || memcmp(query + 1, token, query_len))) ret = reply_cb(reply_data, "/", 2); return ret; @@ -251,7 +288,8 @@ static int test_app_cb(void *application_data, return SIMPLE_IPC_QUIT; } - if (fsmonitor_capability_superset || fsmonitor_pre_dir_metadata) + if (fsmonitor_capability_superset || fsmonitor_pre_dir_metadata || + fsmonitor_pre_cookie_retirement || fsmonitor_unmarked_response) return app__fsmonitor_capability_superset( command, command_len, reply_cb, reply_data); @@ -380,6 +418,10 @@ static int daemon__start_server(void) strvec_push(&cp.args, "--fsmonitor-capability-superset"); if (fsmonitor_pre_dir_metadata) strvec_push(&cp.args, "--fsmonitor-pre-dir-metadata"); + if (fsmonitor_pre_cookie_retirement) + strvec_push(&cp.args, "--fsmonitor-pre-cookie-retirement"); + if (fsmonitor_unmarked_response) + strvec_push(&cp.args, "--fsmonitor-unmarked-response"); if (fsmonitor_disconnect_first) strvec_push(&cp.args, "--fsmonitor-disconnect-first"); @@ -682,6 +724,12 @@ int cmd__simple_ipc(int argc, const char **argv) OPT_BOOL(0, "fsmonitor-pre-dir-metadata", &fsmonitor_pre_dir_metadata, N_("emulate a daemon without directory metadata filtering")), + OPT_BOOL(0, "fsmonitor-pre-cookie-retirement", + &fsmonitor_pre_cookie_retirement, + N_("emulate a daemon without failed-cookie token retirement")), + OPT_BOOL(0, "fsmonitor-unmarked-response", + &fsmonitor_unmarked_response, + N_("advertise token retirement but return an unmarked token")), OPT_BOOL(0, "fsmonitor-disconnect-first", &fsmonitor_disconnect_first, N_("disconnect while handling the first fsmonitor query")), diff --git a/t/meson.build b/t/meson.build index a0e542915d4d32..b5aa03844cbff8 100644 --- a/t/meson.build +++ b/t/meson.build @@ -970,6 +970,7 @@ integration_tests = [ 't7534-status-scoped-readers.sh', 't7535-fsmonitor-cookie-reset.sh', 't7536-fsmonitor-watch-limit-backoff.sh', + 't7537-fsmonitor-cookie-compat.sh', 't7600-merge.sh', 't7601-merge-pull-config.sh', 't7602-merge-octopus-many.sh', diff --git a/t/t1602-index-witness.sh b/t/t1602-index-witness.sh index 5b13f0c6657b62..88c8793bcf035f 100755 --- a/t/t1602-index-witness.sh +++ b/t/t1602-index-witness.sh @@ -613,7 +613,12 @@ test_lazy_prereq INDEX_WITNESS_SCRIPTED_IPC ' # This provider exists in the pre-fix runtime too. Its one stable token is # truthful only while the worktree is unchanged: make every worktree edit # before starting it, and keep all later fixture writes inside .git. -index_witness_scripted_token=builtin:test-capable:0 +if test_have_prereq MACOS +then + index_witness_scripted_token=builtin:dirmeta-v1.inode-v1.cookie-v1.test-capable:0 +else + index_witness_scripted_token=builtin:cookie-v1.test-capable:0 +fi test_index_witness_scripted_prepare () { sane_unset GIT_INDEX_FILE GIT_INDEX_VERSION \ diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index c10f1635ff5a98..04c188ac50132a 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -74,6 +74,14 @@ then test_done fi +if test_have_prereq MACOS +then + fsmonitor_pre_cookie_token_prefix=dirmeta-v1.inode-v1. +else + fsmonitor_pre_cookie_token_prefix= +fi +fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. + stop_daemon_delete_repo () { r=$1 && { maybe_timeout 30 git -C $r fsmonitor--daemon stop 2>/dev/null || :; } && @@ -630,13 +638,17 @@ test_expect_success 'flush cached data' ' # then a few (probably platform-specific number of) events in _1. # These should both have the same . - test-tool -C test_flush fsmonitor-client query --token "builtin:test_00000001:0" >actual_0 && + test-tool -C test_flush fsmonitor-client query \ + --token "builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" \ + >actual_0 && nul_to_q actual_q0 && >test_flush/file_1 && >test_flush/file_2 && - test-tool -C test_flush fsmonitor-client query --token "builtin:test_00000001:0" >actual_1 && + test-tool -C test_flush fsmonitor-client query \ + --token "builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" \ + >actual_1 && nul_to_q actual_q1 && test_grep "file_1" actual_q1 && @@ -647,16 +659,24 @@ test_expect_success 'flush cached data' ' test-tool -C test_flush fsmonitor-client flush >flush_0 && nul_to_q flush_q0 && - test_grep "^builtin:test_00000002:0Q/Q$" flush_q0 && + test_grep \ + "^builtin:${fsmonitor_cookie_token_prefix}test_00000002:0Q/Q$" \ + flush_q0 && - test-tool -C test_flush fsmonitor-client query --token "builtin:test_00000002:0" >actual_2 && + test-tool -C test_flush fsmonitor-client query \ + --token "builtin:${fsmonitor_cookie_token_prefix}test_00000002:0" \ + >actual_2 && nul_to_q actual_q2 && - test_grep "^builtin:test_00000002:0Q$" actual_q2 && + test_grep \ + "^builtin:${fsmonitor_cookie_token_prefix}test_00000002:0Q$" \ + actual_q2 && >test_flush/file_3 && - test-tool -C test_flush fsmonitor-client query --token "builtin:test_00000002:0" >actual_3 && + test-tool -C test_flush fsmonitor-client query \ + --token "builtin:${fsmonitor_cookie_token_prefix}test_00000002:0" \ + >actual_3 && nul_to_q actual_q3 && test_grep "file_3" actual_q3 @@ -2306,7 +2326,8 @@ test_expect_success 'bound query accepts a capability superset' ' GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ git status >.git/status.out && test_trace2_data fsm_client query/command \ - "builtin:test-capable:0" <.git/status.trace && + "builtin:${fsmonitor_cookie_token_prefix}test-capable:0" \ + <.git/status.trace && test_grep ! \ "\"key\":\"query/incompatible-daemon\"" \ .git/status.trace && diff --git a/t/t7537-fsmonitor-cookie-compat.sh b/t/t7537-fsmonitor-cookie-compat.sh new file mode 100755 index 00000000000000..29d1eca87ec641 --- /dev/null +++ b/t/t7537-fsmonitor-cookie-compat.sh @@ -0,0 +1,189 @@ +#!/bin/sh + +test_description='fsmonitor cookie-retirement daemon compatibility' + +. ./test-lib.sh + +if ! test_have_prereq FSMONITOR_DAEMON +then + skip_all='fsmonitor--daemon is not supported on this platform' + test_done +fi + +if test_have_prereq MACOS +then + fsmonitor_pre_cookie_token_prefix=dirmeta-v1.inode-v1. +else + fsmonitor_pre_cookie_token_prefix= +fi +fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. + +stop_cookie_compat_daemon () { + cookie_compat_repo=$1 && + test -d "$cookie_compat_repo/.git" || return 0 + cookie_compat_ipc=$( + git -C "$cookie_compat_repo" \ + rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc 2>/dev/null + ) || return 0 + test-tool simple-ipc stop-daemon \ + --name="$cookie_compat_ipc" --max-wait=5 \ + >/dev/null 2>&1 || : +} + +have_t2_data_event () { + grep -e '"event":"data".*"category":"'"$1"'".*"key":"'"$2"'"' +} + +# Unlike test_when_finished, these still stop our private daemons under -i. +test_atexit 'stop_cookie_compat_daemon cookie-retirement-upgrade' +test_atexit 'stop_cookie_compat_daemon cookie-retirement-unmarked' + +test_expect_success \ + 'a marked provider boundary replaces pre-retirement daemons once' ' + test_when_finished \ + "stop_cookie_compat_daemon cookie-retirement-upgrade" && + test_create_repo cookie-retirement-upgrade && + ( + cd cookie-retirement-upgrade && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.preloadIndex false && + git config core.untrackedCache true && + test_write_lines modified >tracked && + test_write_lines visible >visible && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expected && + test_grep "^1 \\.M .* tracked$" .git/expected && + test_grep "^? visible$" .git/expected && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 --max-wait=10 \ + --fsmonitor-pre-cookie-retirement && + test-tool simple-ipc send --name="$ipc_path" \ + --token=get-capabilities >.git/old-capabilities && + test_grep "^query-v1$" .git/old-capabilities && + test_grep ! "^cookie-token-retirement-v1$" \ + .git/old-capabilities && + old_token="builtin:${fsmonitor_pre_cookie_token_prefix}test-pre-cookie:0" && + GIT_TRACE2_EVENT="$PWD/.git/upgrade.trace" \ + test-tool fsmonitor-client query \ + --token "$old_token" >.git/upgrade.raw && + nul_to_q <.git/upgrade.raw >.git/upgrade.response && + test_grep "^builtin:${fsmonitor_cookie_token_prefix}" \ + .git/upgrade.response && + test_trace2_data fsm_client query/command \ + "$old_token" <.git/upgrade.trace && + test_trace2_data fsm_client query/unmarked-response 1 \ + <.git/upgrade.trace && + test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/upgrade.trace >.git/restarts && + test_line_count = 1 .git/restarts && + test_grep \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/upgrade.trace && + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_cmp .git/expected .git/prime && + current_token=$(sed -n "1s/Q.*//p" .git/upgrade.response) && + worktree_identity=$( + sed -n \ + "s/.*\"category\":\"fsmonitor\",\"key\":\"request\",\"value\":\"query-v[12] \\([0-9a-f]*\\)\\\\n.*/\\1/p" \ + .git/upgrade.trace | + sed -n 1p + ) && + test ${#worktree_identity} = 64 && + for legacy_protocol in raw query-v1 query-v2 + do + if test "$legacy_protocol" = raw + then + legacy_command=$current_token + else + legacy_command=$(printf "%s %s\\n%s" \ + "$legacy_protocol" "$worktree_identity" \ + "$current_token") + fi && + test-tool simple-ipc send --name="$ipc_path" \ + --token="$legacy_command" \ + >".git/legacy-$legacy_protocol.response" && + test_grep "^builtin:${fsmonitor_cookie_token_prefix}" \ + ".git/legacy-$legacy_protocol.response" || return 1 + done && + test-tool simple-ipc stop-daemon \ + --name="$ipc_path" --max-wait=5 && + GIT_TRACE2_EVENT="$PWD/.git/marked-provider.trace" \ + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 --max-wait=10 \ + --fsmonitor-capability-superset && + test-tool simple-ipc send --name="$ipc_path" \ + --token=get-capabilities >.git/marked-capabilities && + test_grep "^cookie-token-retirement-v1$" \ + .git/marked-capabilities && + test_trace2_data fsmonitor request get-capabilities \ + <.git/marked-provider.trace \ + >.git/capabilities.before && + test_line_count = 1 .git/capabilities.before && + marked_token="builtin:${fsmonitor_cookie_token_prefix}test-capable:0" && + printf "%s\\000" "$marked_token" >.git/warm.expected && + for warm_query in first repeated + do + GIT_TRACE2_EVENT="$PWD/.git/warm-$warm_query.trace" \ + test-tool fsmonitor-client query \ + --token "$marked_token" \ + >".git/warm-$warm_query.actual" && + test_cmp_bin .git/warm.expected \ + ".git/warm-$warm_query.actual" && + have_t2_data_event fsm_client query/response-length \ + <".git/warm-$warm_query.trace" && + ! test_trace2_data fsm_client query/unmarked-response 1 \ + <".git/warm-$warm_query.trace" && + ! test_trace2_data fsm_client query/incompatible-daemon 1 \ + <".git/warm-$warm_query.trace" || return 1 + done && + test_trace2_data fsmonitor request get-capabilities \ + <.git/marked-provider.trace \ + >.git/capabilities.after && + test_cmp .git/capabilities.before .git/capabilities.after + ) +' + +test_expect_success \ + 'an advertised capability never authenticates an unmarked response' ' + test_when_finished \ + "stop_cookie_compat_daemon cookie-retirement-unmarked" && + test_create_repo cookie-retirement-unmarked && + ( + cd cookie-retirement-unmarked && + test_commit base tracked && + git config core.fsmonitor true && + ipc_path=$(git rev-parse --path-format=absolute \ + --git-path fsmonitor--daemon.ipc) && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 --max-wait=10 \ + --fsmonitor-unmarked-response && + test-tool simple-ipc send --name="$ipc_path" \ + --token=get-capabilities >.git/capabilities && + test_grep "^cookie-token-retirement-v1$" \ + .git/capabilities && + old_token="builtin:${fsmonitor_pre_cookie_token_prefix}test-pre-cookie:0" && + test_must_fail env \ + GIT_TRACE2_EVENT="$PWD/.git/unmarked.trace" \ + test-tool fsmonitor-client query \ + --token "$old_token" \ + >.git/unmarked.raw \ + 2>.git/unmarked.err && + test_must_be_empty .git/unmarked.raw && + test_trace2_data fsm_client query/unmarked-response 1 \ + <.git/unmarked.trace >.git/rejected-responses && + test_line_count = 4 .git/rejected-responses && + ! test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/unmarked.trace && + test-tool simple-ipc is-active --name="$ipc_path" + ) +' + +test_done From 68abca224f29ab5a5fd9863bea21d1fbc50b6fd8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 17:26:48 -0500 Subject: [PATCH 380/403] status: stop retrying consecutive provider resets A TRIVIAL closing query discards the current proof and starts another full refresh. The terminal retry cap prevents an unclosable last scan, but an unavailable provider still makes status repeat expensive tracked and untracked work before falling back to an ordinary refresh. Stop closing the token after two consecutive TRIVIAL responses. Share the counter between semantic and ordinary closure, and clear it when a query returns another result. A single transient reset can still be rescanned and accepted, and changed-path replies retain their existing three-query limit. Discard staged results and recheck attributes and the manifest before entering the existing conservative fallback. The regression compares dirty tracked, attributes, and untracked output with an independent fsmonitor-disabled status, and verifies that no FULL proof is issued. --- t/t7519-status-fsmonitor.sh | 8 ++++---- wt-status.c | 34 ++++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 5d01ca89c9d118..9454c11695077f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -6663,7 +6663,7 @@ test_expect_success LINUX_SCOPED_HISTORY,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORE ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'exhausted provider resets skip an unclosable final index refresh' ' + 'repeated provider resets fall back before an unclosable rescan' ' test_when_finished "rm -rf builtin-closure-terminal-reset" && prepare_builtin_closure_repo builtin-closure-terminal-reset untracked && ( @@ -6689,12 +6689,12 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_cmp .git/expected .git/actual && test_trace2_data fsmonitor token_closure/trivial 1 \ <.git/status.trace >.git/trivial && - test_line_count = 3 .git/trivial && + test_line_count = 2 .git/trivial && test_trace2_data fsmonitor semantic/proof-epoch-captured 1 \ <.git/status.trace >.git/epochs && - test_line_count = 3 .git/epochs && + test_line_count = 2 .git/epochs && test_trace2_data status \ - fsmonitor_token/terminal-rescan-skipped 1 \ + fsmonitor_token/repeated-trivial-fallback 1 \ <.git/status.trace && test_trace2_data fsmonitor token_closure/rejected 1 \ <.git/status.trace && diff --git a/wt-status.c b/wt-status.c index e45cf6f9d2c4b1..061c75d51f1c67 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1746,6 +1746,7 @@ struct wt_status_token_closure { int staged_output_matches_status; int refresh_result; int queries; + int consecutive_trivial; }; static void wt_status_discard_staged_untracked( @@ -1846,6 +1847,19 @@ static int fsmonitor_token_requires_rescan(enum fsmonitor_token_result result) result == FSMONITOR_TOKEN_TRIVIAL; } +static enum fsmonitor_token_result wt_status_query_pending_token( + struct wt_status_token_closure *closure, int untracked_ready) +{ + enum fsmonitor_token_result result = fsmonitor_query_pending_token( + closure->status->repo->index, untracked_ready); + + if (result == FSMONITOR_TOKEN_TRIVIAL) + closure->consecutive_trivial++; + else + closure->consecutive_trivial = 0; + return result; +} + static void wt_status_release_attr_snapshot(struct wt_status *s); static int wt_status_attr_snapshot_matches(struct wt_status *s) @@ -1984,9 +1998,8 @@ static int wt_status_close_ordinary_fsmonitor_token( istate, scan_epoch)) break; closure->queries++; - result = fsmonitor_query_pending_token( - istate, - wt_status_untracked_cache_valid(closure)); + result = wt_status_query_pending_token( + closure, wt_status_untracked_cache_valid(closure)); if (result == FSMONITOR_TOKEN_CLEAN) { if (validate_epoch && !clean_status_proof_epoch_matches( @@ -2031,6 +2044,12 @@ static int wt_status_close_ordinary_fsmonitor_token( wt_status_reset_attr_snapshot_if_changed(s); if (wt_status_refresh_invalidated_manifest(s)) break; + if (closure->consecutive_trivial >= 2) { + trace2_data_intmax("status", s->repo, + "fsmonitor_token/repeated-trivial-fallback", + 1); + break; + } if (closure->queries >= FSMONITOR_TOKEN_MAX_QUERIES) { trace2_data_intmax("status", s->repo, "fsmonitor_token/terminal-rescan-skipped", @@ -2089,8 +2108,8 @@ wt_status_close_semantic_fsmonitor_token( /* The first query closes the tracked scan and its semantic proof. */ closure->queries++; - result = fsmonitor_query_pending_token( - istate, defer_untracked ? 0 : + result = wt_status_query_pending_token( + closure, defer_untracked ? 0 : wt_status_untracked_cache_valid(closure)); if (result != FSMONITOR_TOKEN_CLEAN) { wt_status_discard_semantic_verify( @@ -2136,9 +2155,8 @@ wt_status_close_semantic_fsmonitor_token( /* A second query closes the subsequent untracked scan. */ closure->queries++; clean_status_manifest_begin_directory_delta(istate, *proof); - result = fsmonitor_query_pending_token( - istate, - wt_status_untracked_cache_valid(closure)); + result = wt_status_query_pending_token( + closure, wt_status_untracked_cache_valid(closure)); directory_delta_reused = clean_status_manifest_end_directory_delta(istate); if (result != FSMONITOR_TOKEN_CLEAN) { From a8d82e3790880fdaa1842d1d2093eab6cc44754e Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Tue, 21 Jul 2026 17:04:56 -0400 Subject: [PATCH 381/403] fsmonitor: flush pending FSEvents before cookie wait 56cef9cb1a (fsmonitor: use pthread_cond_timedwait for cookie wait, 2026-04-15) limits the cookie wait to one second so that a filesystem which never delivers events cannot hang fsmonitor clients. A client that times out receives a trivial response and scans the entire index. FSEvents can defer delivery while it batches notifications and does not guarantee that its queue is drained in one latency interval. A loaded macOS system can therefore time out even though the event stream is working. On an Apple M4 Max (16 cores, 128 GiB RAM) running macOS 26.5.2, two worktrees with a 1,001,178-entry index timed out 484 of 545 and 297 of 365 fsmonitor requests. One status call performed 934,519 lstat() calls during a 47-second preload and took 52 seconds overall. Ask FSEvents to flush pending notifications after creating the cookie and before starting the timed wait. Use the asynchronous form because the client handler holds main_lock, which the listener callback also acquires. Keep the timeout and the behavior of the other backends unchanged. Signed-off-by: Tamir Duberstein Signed-off-by: Junio C Hamano (cherry picked from commit 08b12d90cde7a833221e132b1b2b7a9c724af234) --- builtin/fsmonitor--daemon.c | 3 +++ compat/fsmonitor/fsm-darwin-gcc.h | 1 + compat/fsmonitor/fsm-listen-darwin.c | 5 +++++ compat/fsmonitor/fsm-listen-linux.c | 4 ++++ compat/fsmonitor/fsm-listen-win32.c | 4 ++++ compat/fsmonitor/fsm-listen.h | 6 ++++++ 6 files changed, 23 insertions(+) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 1c53a5af4dd6df..cb4fdb7f64aae2 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -217,6 +217,9 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( close(fd); unlink(cookie_pathname.buf); + /* The listener callback takes main_lock, so this must not block. */ + fsm_listen__flush_async(state); + /* * Wait for the listener thread to observe the cookie file. * Time out after a short interval so that the client diff --git a/compat/fsmonitor/fsm-darwin-gcc.h b/compat/fsmonitor/fsm-darwin-gcc.h index 959bc88f8f765a..b749012c959ca8 100644 --- a/compat/fsmonitor/fsm-darwin-gcc.h +++ b/compat/fsmonitor/fsm-darwin-gcc.h @@ -97,6 +97,7 @@ CFRunLoopRef CFRunLoopGetCurrent(void); extern CFStringRef kCFRunLoopDefaultMode; void FSEventStreamSetDispatchQueue(FSEventStreamRef stream, dispatch_queue_t q); unsigned char FSEventStreamStart(FSEventStreamRef stream); +FSEventStreamEventId FSEventStreamFlushAsync(FSEventStreamRef stream); void FSEventStreamStop(FSEventStreamRef stream); void FSEventStreamInvalidate(FSEventStreamRef stream); void FSEventStreamRelease(FSEventStreamRef stream); diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index f25d7cdd907af9..b7d091453c2730 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -586,6 +586,11 @@ void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) pthread_mutex_unlock(&data->dq_lock); } +void fsm_listen__flush_async(struct fsmonitor_daemon_state *state) +{ + FSEventStreamFlushAsync(state->listen_data->stream); +} + void fsm_listen__loop(struct fsmonitor_daemon_state *state) { struct fsm_listen_data *data; diff --git a/compat/fsmonitor/fsm-listen-linux.c b/compat/fsmonitor/fsm-listen-linux.c index 6181dcba51472d..ec46ef721c0031 100644 --- a/compat/fsmonitor/fsm-listen-linux.c +++ b/compat/fsmonitor/fsm-listen-linux.c @@ -481,6 +481,10 @@ void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) state->listen_data->shutdown = SHUTDOWN_STOP; } +void fsm_listen__flush_async(struct fsmonitor_daemon_state *state UNUSED) +{ +} + /* * Process a single inotify event and queue for publication. */ diff --git a/compat/fsmonitor/fsm-listen-win32.c b/compat/fsmonitor/fsm-listen-win32.c index 9a6efc9bea340b..039d7970004d46 100644 --- a/compat/fsmonitor/fsm-listen-win32.c +++ b/compat/fsmonitor/fsm-listen-win32.c @@ -290,6 +290,10 @@ void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) SetEvent(state->listen_data->hListener[LISTENER_SHUTDOWN]); } +void fsm_listen__flush_async(struct fsmonitor_daemon_state *state UNUSED) +{ +} + static struct one_watch *create_watch(const char *path) { struct one_watch *watch = NULL; diff --git a/compat/fsmonitor/fsm-listen.h b/compat/fsmonitor/fsm-listen.h index 41650bf8972217..cfeca1f4b63204 100644 --- a/compat/fsmonitor/fsm-listen.h +++ b/compat/fsmonitor/fsm-listen.h @@ -38,6 +38,12 @@ void fsm_listen__dtor(struct fsmonitor_daemon_state *state); */ void fsm_listen__loop(struct fsmonitor_daemon_state *state); +/* + * Prompt the listener to deliver queued filesystem events, if supported. + * This does not wait for the events to be processed. + */ +void fsm_listen__flush_async(struct fsmonitor_daemon_state *state); + /* * Gently request that the fsmonitor listener thread shutdown. * It does not wait for it to stop. The caller should do a JOIN From f7be5b1d18f97c2b66256370b608f684641c80e1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 18:07:24 -0500 Subject: [PATCH 382/403] Revert "fsmonitor: flush pending FSEvents before cookie wait" a8d82e3790 (fsmonitor: flush pending FSEvents before cookie wait, 2026-07-21) backported a topic that upstream has since retracted. It also does not repair the workload motivating this backport: a matched 48-query test observes 12 timeouts in each 24-query arm, and status in a 1,160,465-entry macOS worktree still loses its closing cookie after one second. Remove the unqualified delivery prompt. Keep failed-cookie token retirement, response-bound capability checks, and the bounded status fallback unchanged. This reverts commit a8d82e3790880fdaa1842d1d2093eab6cc44754e. Link: https://github.com/git/git/blob/eb17606c718c93a69a64a8c3fd764899a68ba9f2/whats-cooking.txt#L1208-L1217 --- builtin/fsmonitor--daemon.c | 3 --- compat/fsmonitor/fsm-darwin-gcc.h | 1 - compat/fsmonitor/fsm-listen-darwin.c | 5 ----- compat/fsmonitor/fsm-listen-linux.c | 4 ---- compat/fsmonitor/fsm-listen-win32.c | 4 ---- compat/fsmonitor/fsm-listen.h | 6 ------ 6 files changed, 23 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index cb4fdb7f64aae2..1c53a5af4dd6df 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -217,9 +217,6 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( close(fd); unlink(cookie_pathname.buf); - /* The listener callback takes main_lock, so this must not block. */ - fsm_listen__flush_async(state); - /* * Wait for the listener thread to observe the cookie file. * Time out after a short interval so that the client diff --git a/compat/fsmonitor/fsm-darwin-gcc.h b/compat/fsmonitor/fsm-darwin-gcc.h index b749012c959ca8..959bc88f8f765a 100644 --- a/compat/fsmonitor/fsm-darwin-gcc.h +++ b/compat/fsmonitor/fsm-darwin-gcc.h @@ -97,7 +97,6 @@ CFRunLoopRef CFRunLoopGetCurrent(void); extern CFStringRef kCFRunLoopDefaultMode; void FSEventStreamSetDispatchQueue(FSEventStreamRef stream, dispatch_queue_t q); unsigned char FSEventStreamStart(FSEventStreamRef stream); -FSEventStreamEventId FSEventStreamFlushAsync(FSEventStreamRef stream); void FSEventStreamStop(FSEventStreamRef stream); void FSEventStreamInvalidate(FSEventStreamRef stream); void FSEventStreamRelease(FSEventStreamRef stream); diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index b7d091453c2730..f25d7cdd907af9 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -586,11 +586,6 @@ void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) pthread_mutex_unlock(&data->dq_lock); } -void fsm_listen__flush_async(struct fsmonitor_daemon_state *state) -{ - FSEventStreamFlushAsync(state->listen_data->stream); -} - void fsm_listen__loop(struct fsmonitor_daemon_state *state) { struct fsm_listen_data *data; diff --git a/compat/fsmonitor/fsm-listen-linux.c b/compat/fsmonitor/fsm-listen-linux.c index ec46ef721c0031..6181dcba51472d 100644 --- a/compat/fsmonitor/fsm-listen-linux.c +++ b/compat/fsmonitor/fsm-listen-linux.c @@ -481,10 +481,6 @@ void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) state->listen_data->shutdown = SHUTDOWN_STOP; } -void fsm_listen__flush_async(struct fsmonitor_daemon_state *state UNUSED) -{ -} - /* * Process a single inotify event and queue for publication. */ diff --git a/compat/fsmonitor/fsm-listen-win32.c b/compat/fsmonitor/fsm-listen-win32.c index 039d7970004d46..9a6efc9bea340b 100644 --- a/compat/fsmonitor/fsm-listen-win32.c +++ b/compat/fsmonitor/fsm-listen-win32.c @@ -290,10 +290,6 @@ void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) SetEvent(state->listen_data->hListener[LISTENER_SHUTDOWN]); } -void fsm_listen__flush_async(struct fsmonitor_daemon_state *state UNUSED) -{ -} - static struct one_watch *create_watch(const char *path) { struct one_watch *watch = NULL; diff --git a/compat/fsmonitor/fsm-listen.h b/compat/fsmonitor/fsm-listen.h index cfeca1f4b63204..41650bf8972217 100644 --- a/compat/fsmonitor/fsm-listen.h +++ b/compat/fsmonitor/fsm-listen.h @@ -38,12 +38,6 @@ void fsm_listen__dtor(struct fsmonitor_daemon_state *state); */ void fsm_listen__loop(struct fsmonitor_daemon_state *state); -/* - * Prompt the listener to deliver queued filesystem events, if supported. - * This does not wait for the events to be processed. - */ -void fsm_listen__flush_async(struct fsmonitor_daemon_state *state); - /* * Gently request that the fsmonitor listener thread shutdown. * It does not wait for it to stop. The caller should do a JOIN From 4e82cbf14855d8dd7f4f21471aa2a7626b99f7a3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 19:50:22 -0500 Subject: [PATCH 383/403] fsmonitor: retain suspended history across backoff writers 53b813d877 (status: preserve index proofs during temporary fsmonitor backoff, 2026-08-17) keeps status from rewriting the index while an authenticated watch-limit marker disables the provider. Other writers still remove FSMN and FSUC when reading that index. Even a clean add --refresh, a dirty stash create, or diff's optional stat refresh can discard the history needed to recover after the marker expires. Suspend an authenticated on-index proof instead of treating temporary backoff as an explicit disable. Retain its token only as historical state, clear every live tracked and untracked validity hint, and make the public current-proof predicate reject the suspended epoch. A real index write may preserve an all-dirty FSMN bitmap, an unbound FSCF manifest, and pending untracked candidates. It cannot publish a clean proof until a fresh provider boundary and ordinary revalidation agree. Restrict that preservation to authenticated canonical full indexes and same-path regular-file replacements. Recheck root and ancestor attributes against the historical manifest, force a content check for changed object IDs, and revoke suspension on unsafe mutations. Repeated writers must authenticate the same pending boundary; invalidation cannot revive it. Explicitly disabling fsmonitor retains its existing behavior. Avoid optional canonical-index writes in diff, the shared refresh helper, and cache-tree publication during backoff. Stash still writes its private indexes and trees, and actual staging still publishes the new contents. Cover clean refresh, dirty stash with an invalid cache tree, repeated staging, hostile attribute changes, and recovery through both DELTA and TRIVIAL replies against independent status and tree oracles. --- builtin/diff.c | 3 +- cache-tree.c | 27 +- clean-status-history.c | 2 + clean-status-internal.h | 2 + clean-status-manifest.c | 93 +++++ clean-status-manifest.h | 3 + clean-status.c | 128 +++++- clean-status.h | 3 + dir.c | 3 +- fsmonitor.c | 24 +- fsmonitor.h | 6 +- read-cache-ll.h | 1 + read-cache.c | 49 ++- t/t7536-fsmonitor-watch-limit-backoff.sh | 482 ++++++++++++++++++++++- 14 files changed, 809 insertions(+), 17 deletions(-) diff --git a/builtin/diff.c b/builtin/diff.c index 4d983985e00bd6..e1c5d6f1f493a9 100644 --- a/builtin/diff.c +++ b/builtin/diff.c @@ -330,7 +330,8 @@ static void refresh_index_quietly(void) int fd; int refreshed; - if (!use_optional_locks()) + if (!use_optional_locks() || + fsm_settings__is_watch_limit_backoff(the_repository)) return; can_close_token = can_close_diff_fsmonitor_token(istate); diff --git a/cache-tree.c b/cache-tree.c index c811e23b14705b..3ab5219f84b16b 100644 --- a/cache-tree.c +++ b/cache-tree.c @@ -2,6 +2,10 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "git-compat-util.h" +#include "abspath.h" +#include "dir.h" +#include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "hex.h" #include "lockfile.h" @@ -808,6 +812,26 @@ struct tree *write_in_core_index_as_tree(struct repository *repo, } +static int skip_backoff_cache_tree_write(struct index_state *istate, + const char *index_path) +{ + struct repository *repo = istate->repo; + char *main_index, *named, *canonical; + int skip; + + if (getenv(INDEX_ENVIRONMENT) || get_alternate_index_output() || + !fsm_settings__is_watch_limit_backoff(repo)) + return 0; + main_index = xstrfmt("%s/index", repo_get_git_dir(repo)); + named = real_pathdup(index_path, 0); + canonical = real_pathdup(main_index, 0); + skip = named && canonical && !fspathcmp(named, canonical); + free(main_index); + free(named); + free(canonical); + return skip; +} + int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix) { int entries, was_valid; @@ -829,7 +853,8 @@ int write_index_as_tree(struct object_id *oid, struct index_state *index_state, ret = write_index_as_tree_internal(oid, index_state, was_valid, flags, prefix); - if (!ret && !was_valid) { + if (!ret && !was_valid && + !skip_backoff_cache_tree_write(index_state, index_path)) { write_locked_index(index_state, &lock_file, COMMIT_LOCK); /* Not being able to write is fine -- we are only interested * in updating the cache-tree part, and if the next caller diff --git a/clean-status-history.c b/clean-status-history.c index cd2aa992e91b60..5ce8afbb3e821e 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -1911,6 +1911,8 @@ void clean_status_copy_fsmonitor_history(struct index_state *dst, src_state->disk_config_invalid || !src_state->disk_config_raw.len) return; dst_state = clean_status_get_state(dst); + dst_state->backoff_suspended = 0; + FREE_AND_NULL(dst_state->backoff_token); FREE_AND_NULL(dst_state->disk_config_token); strbuf_reset(&dst_state->disk_config_raw); dst_state->disk_config_token = diff --git a/clean-status-internal.h b/clean-status-internal.h index 1bc3613920c9f1..aeab1730b84ea3 100644 --- a/clean-status-internal.h +++ b/clean-status-internal.h @@ -14,6 +14,7 @@ struct clean_status_state { struct strbuf authenticated_new_directories; char *disk_config_token; char *config_revalidated_token; + char *backoff_token; char *authenticated_new_directories_token; int source_index_fd; unsigned char current_config_hash[GIT_MAX_RAWSZ]; @@ -55,6 +56,7 @@ struct clean_status_state { unsigned disk_config_seen : 1; unsigned disk_config_invalid : 1; unsigned semantic_baseline_pending : 1; + unsigned backoff_suspended : 1; }; struct clean_status_state *clean_status_get_state(struct index_state *istate); diff --git a/clean-status-manifest.c b/clean-status-manifest.c index 9f1628a6840e30..8c7e79e0b481ed 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -235,6 +235,99 @@ static int directory_attribute_source_matches( } #endif +int clean_status_manifest_path_attributes_unchanged( + const struct index_state *istate, const char *name) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + const struct clean_status_state *state = + istate ? istate->clean_status : NULL; + const struct git_hash_algo *algo; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + struct strbuf candidate = STRBUF_INIT; + const char *slash = name; + unsigned int namespace_unstable = 0; + size_t position = 0; + uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + int safe = 0; + + if (!istate || !istate->repo || !name || !*name || + !verify_path(name, 0) || + !clean_status_fsmonitor_backoff_suspended(istate) || + istate->split_index || istate->sparse_index != INDEX_EXPANDED || + istate->cache_nr > INT_MAX || !state || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.current_invalidated || + state->manifest.global_fallback || + (state->manifest.current_flags & required) != required) + return 0; + algo = istate->repo->hash_algo; + if (!algo || !attr_manifest_valid(state->manifest.current.buf, + state->manifest.current.len, algo) || + semantic_verify_root_init(istate->repo, &root)) + goto done; + path = semantic_verify_path_new(root); + if (!path) + goto done; + + /* The root source applies even to a path without any slash. */ + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + for (;;) { + struct attr_manifest_entry entry; + const struct attr_manifest_entry *historical = NULL; + int pos; + + if (candidate.len > INT_MAX) + goto done; + if (!find_manifest_entry(&state->manifest.current, + candidate.buf, algo, &entry)) + historical = &entry; + /* + * Attribute fallback can read stage #2 of an unmerged path. + * The shared matcher accepts only stage #0, so do not mistake + * an unmerged source for historical absence. The expanded-index + * guard above makes this lookup non-mutating. + */ + pos = index_name_pos((struct index_state *)istate, + candidate.buf, candidate.len); + if (pos < 0) { + unsigned int first = -(pos + 1); + + if (first < istate->cache_nr && + !strcmp(istate->cache[first]->name, candidate.buf)) + goto done; + } + if (!directory_attribute_source_matches( + (struct index_state *)istate, path, candidate.buf, + historical, position++)) + goto done; + slash = strchr(slash, '/'); + if (!slash) + break; + strbuf_reset(&candidate); + strbuf_add(&candidate, name, slash - name + 1); + strbuf_addstr(&candidate, GITATTRIBUTES_FILE); + slash++; + } + + semantic_verify_path_free(path, &namespace_unstable, NULL); + path = NULL; + safe = !namespace_unstable && semantic_verify_root_stable(root); + +done: + if (path) + semantic_verify_path_free(path, NULL, NULL); + semantic_verify_root_clear(root); + strbuf_release(&candidate); + return safe; +#else + (void)istate; + (void)name; + return 0; +#endif +} + int clean_status_manifest_directory_unchanged( struct index_state *istate, const char *directory) { diff --git a/clean-status-manifest.h b/clean-status-manifest.h index 81cad1124e6af9..8bc4f1ac28ad28 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -35,6 +35,9 @@ int clean_status_manifest_refresh(struct index_state *istate, void clean_status_manifest_begin_directory_delta( struct index_state *istate, const struct semantic_verify_proof *proof); int clean_status_manifest_end_directory_delta(struct index_state *istate); +/* Recheck one path's attribute ancestry for suspended backoff history. */ +int clean_status_manifest_path_attributes_unchanged( + const struct index_state *istate, const char *path); int clean_status_manifest_directory_unchanged( struct index_state *istate, const char *directory); int clean_status_manifest_reconcile_deleted_attribute( diff --git a/clean-status.c b/clean-status.c index 7409daa8dc78b0..3a3d7b9beac9b7 100644 --- a/clean-status.c +++ b/clean-status.c @@ -2,12 +2,16 @@ #include "attr-fingerprint.h" #include "attr-manifest.h" #include "clean-status.h" +#include "clean-status-index.h" #include "clean-status-internal.h" #include "convert.h" #include "dir.h" +#include "environment.h" #include "fsmonitor-clean-proof.h" +#include "fsmonitor-settings.h" #include "progress.h" #include "read-cache-ll.h" +#include "replace-object.h" #include "repository.h" #include "semantic-verify-internal.h" #include "worktree-attr-source.h" @@ -172,7 +176,7 @@ int clean_status_revalidated_token_matches(const struct index_state *istate) { const struct clean_status_state *state = istate->clean_status; - return state && state->config_revalidated && + return state && !state->backoff_token && state->config_revalidated && state->config_revalidated_token && istate->fsmonitor_last_update && !strcmp(state->config_revalidated_token, @@ -188,6 +192,115 @@ void clean_status_invalidate_current_proof(struct index_state *istate) istate->clean_status->initial_coherent = 0; istate->clean_status->filter_scope_valid = 0; istate->clean_status->semantic_baseline_pending = 0; + istate->clean_status->backoff_suspended = 0; +} + +int clean_status_fsmonitor_backoff_suspended( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->backoff_suspended && state->backoff_token && + istate->fsmonitor_token_valid && istate->fsmonitor_last_update && + !strcmp(state->backoff_token, istate->fsmonitor_last_update) && + fsm_settings__is_watch_limit_backoff(istate->repo); +} + +int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate) +{ + struct clean_status_state *state = istate->clean_status; + struct clean_status_index_snapshot source = { .fd = -1 }; + const struct git_hash_algo *algo; + const struct untracked_cache *uc = istate->untracked; + const char *suffix, *pending; + const uint32_t historical = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + int paired, suspended = 0; + + /* Never revive an epoch which an earlier mutation has invalidated. */ + if (state && state->backoff_token) + return clean_status_fsmonitor_backoff_suspended(istate); + if (!state || !istate->repo || !istate->repo->worktree || + !fsm_settings__is_watch_limit_backoff(istate->repo) || + !fstat_is_reliable() || istate != istate->repo->index || + getenv(INDEX_ENVIRONMENT) || getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || getenv(DB_ENVIRONMENT) || + getenv(ALTERNATE_DB_ENVIRONMENT) || istate->split_index || + istate->sparse_index != INDEX_EXPANDED || + repo_config_values(istate->repo)->apply_sparse_checkout || + !istate->repo->config_values_private_.trust_ctime || + !istate->repo->config_values_private_.check_stat || + repo_has_replace_refs_uncached(istate->repo) || + !state->config_enforced || !state->current_config_valid || + !state->current_semantic_valid || !state->current_attr_valid || + !state->current_tracked_policy_valid || state->filter_configured || + state->external_history_restored || !state->disk_config_valid || + state->disk_config_invalid || !state->disk_config_raw.len || + !state->disk_semantic_valid || !state->disk_attr_valid || + !state->disk_tracked_policy_valid || !state->manifest.disk_valid || + !istate->fsmonitor_extension_seen || !istate->fsmonitor_token_valid || + !istate->fsmonitor_last_update || + !skip_prefix(istate->fsmonitor_last_update, "builtin:", &suffix) || + !*suffix || !strcmp(suffix, "fake") || + !state->disk_config_token || + strcmp(state->disk_config_token, istate->fsmonitor_last_update) || + istate->fsmonitor_last_update_pending || + istate->fsmonitor_pending_token_from_provider || + istate->fsmonitor_legacy_untracked_fallback || + !uc || !uc->root || !uc->root->valid || + uc->fsmonitor_dirty_paths.len || + !istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + !istate->fsmonitor_untracked_token) + return 0; + algo = istate->repo->hash_algo; + if (memcmp(state->disk_config_hash, state->current_config_hash, + algo->rawsz) || + memcmp(state->disk_semantic_hash, state->current_semantic_hash, + algo->rawsz) || + memcmp(state->disk_attr_hash, state->current_attr_hash, algo->rawsz) || + memcmp(state->disk_tracked_policy_hash, + state->current_tracked_policy_hash, algo->rawsz)) + return 0; + + paired = state->manifest.disk_flags == FSMONITOR_CLEAN_PROOF_ALL && + clean_status_has_current_full_fsmonitor_proof(istate) && + !memcmp(state->manifest.disk_hash, state->manifest.current_hash, + algo->rawsz) && + istate->fsmonitor_untracked_valid && uc->root->valid_recursive && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token); + if (!paired && + !(state->manifest.disk_flags == historical && + !istate->fsmonitor_untracked_valid && uc->fsmonitor_revalidation && + skip_prefix(istate->fsmonitor_untracked_token, "pending:", &pending) && + !strcmp(suffix, pending))) + return 0; + if (clean_status_index_snapshot_pin_proof_epoch(&source, istate)) + return 0; + if (!untracked_cache_preserve_for_revalidation(istate) || + !clean_status_index_snapshot_still_matches_proof_epoch(&source, istate)) + goto done; + + /* Only historical path semantics survive. Nothing is currently clean. */ + clean_status_manifest_adopt_disk(&state->manifest); + state->manifest.current_flags = historical; + clean_status_clear_authenticated_new_directories(istate); + state->authenticated_bootstrap_manifest = 0; + state->config_revalidated = 0; + state->initial_coherent = 0; + state->config_mismatch = 1; + state->filter_scope_valid = 0; + FREE_AND_NULL(state->config_revalidated_token); + state->backoff_token = xstrdup(istate->fsmonitor_last_update); + state->backoff_suspended = 1; + state->semantic_baseline_pending = 1; + suspended = 1; + trace2_data_intmax("fsmonitor", istate->repo, + "history/watch-limit-suspended", 1); +done: + clean_status_index_snapshot_release(&source); + return suspended; } static int path_has_no_new_attribute_sources( @@ -316,9 +429,10 @@ int clean_status_index_entry_is_semantically_safe( const struct cache_entry *entry = old ? old : new_entry; struct conv_attrs attrs; const char *base; + int suspended = clean_status_fsmonitor_backoff_suspended(istate); - if (!state || !state->config_revalidated || - !clean_status_revalidated_token_matches(istate) || + if (!state || + (!suspended && !clean_status_revalidated_token_matches(istate)) || (state->filter_configured && !state->filter_scope_valid) || istate->split_index || istate->sparse_index || !entry) @@ -339,6 +453,11 @@ int clean_status_index_entry_is_semantically_safe( if (!fspathcmp(base, ".gitattributes") || !fspathcmp(base, ".gitignore")) return 0; + if (suspended && + (!old || !new_entry || !S_ISREG(old->ce_mode) || + !S_ISREG(new_entry->ce_mode) || + !clean_status_manifest_path_attributes_unchanged(istate, entry->name))) + return 0; if (state->filter_configured) { convert_attrs((struct index_state *)istate, &attrs, entry->name); if (convert_attrs_has_clean_filter(&attrs)) @@ -679,6 +798,8 @@ void clean_status_mark_fsmonitor_config_valid(struct index_state *istate, state->config_mismatch = 0; state->strong_mismatch = 0; state->semantic_baseline_pending = 0; + state->backoff_suspended = 0; + FREE_AND_NULL(state->backoff_token); state->manifest.current_flags = FSMONITOR_CLEAN_PROOF_ALL; state->config_revalidated = state->current_semantic_valid && state->current_attr_valid && state->manifest.current_valid; @@ -701,6 +822,7 @@ void clean_status_release(struct index_state *istate) strbuf_release(&istate->clean_status->authenticated_new_directories); free(istate->clean_status->disk_config_token); free(istate->clean_status->config_revalidated_token); + free(istate->clean_status->backoff_token); free(istate->clean_status->authenticated_new_directories_token); FREE_AND_NULL(istate->clean_status); } diff --git a/clean-status.h b/clean-status.h index 24f07807d2ee99..17741df3de6621 100644 --- a/clean-status.h +++ b/clean-status.h @@ -64,6 +64,9 @@ int clean_status_try_preserve_tracked_config_epoch( struct index_state *istate); int clean_status_revalidated_token_matches( const struct index_state *istate); +int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate); +int clean_status_fsmonitor_backoff_suspended( + const struct index_state *istate); int clean_status_has_persistent_fsmonitor_semantic_history( const struct index_state *istate); diff --git a/dir.c b/dir.c index 5c9a4a4f6b96d5..b0e8aea8c85b22 100644 --- a/dir.c +++ b/dir.c @@ -2242,7 +2242,8 @@ int untracked_cache_preserve_for_revalidation(struct index_state *istate) istate == istate->repo->index && !istate->split_index && istate->sparse_index == INDEX_EXPANDED && - fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + (fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC || + fsm_settings__is_watch_limit_backoff(istate->repo)) && (!getenv(GIT_WORK_TREE_ENVIRONMENT) || ident_in_untracked(uc)) && skip_prefix(istate->fsmonitor_last_update, diff --git a/fsmonitor.c b/fsmonitor.c index a217b42baf27e4..4b8a52939ee227 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1930,7 +1930,8 @@ void fsmonitor_reject_pending_token(struct index_state *istate) void fsmonitor_mark_untracked_cache_valid(struct index_state *istate) { - if (istate->fsmonitor_last_update_pending || + if (fsm_settings__is_watch_limit_backoff(istate->repo) || + istate->fsmonitor_last_update_pending || !istate->fsmonitor_token_valid || !istate->fsmonitor_last_update || !istate->untracked || istate->fsmonitor_untracked_valid) @@ -2021,6 +2022,27 @@ void tweak_fsmonitor(struct index_state *istate) int fsmonitor_enabled = (fsm_settings__get_mode(istate->repo) > FSMONITOR_MODE_DISABLED); + if (fsm_settings__is_watch_limit_backoff(istate->repo)) { + int suspended = clean_status_suspend_fsmonitor_for_backoff(istate); + + /* Historical tokens never make a live entry clean while disabled. */ + for (i = 0; i < istate->cache_nr; i++) + istate->cache[i]->ce_flags &= + ~(CE_FSMONITOR_VALID | CE_UPTODATE); + ewah_free(istate->fsmonitor_dirty); + istate->fsmonitor_dirty = NULL; + if (!suspended) { + remove_fsmonitor(istate); + return; + } + istate->fsmonitor_untracked_valid = 0; + istate->fsmonitor_untracked_revalidation_authenticated = 0; + FREE_AND_NULL(istate->fsmonitor_last_update_pending); + istate->fsmonitor_pending_token_from_provider = 0; + istate->untracked->use_fsmonitor = 0; + return; + } + if (istate->fsmonitor_dirty) { if (fsmonitor_enabled) { /* Mark all entries valid */ diff --git a/fsmonitor.h b/fsmonitor.h index 7ba51d6bd05961..6d5f3d3bc34c29 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -2,6 +2,7 @@ #define FSMONITOR_H #include "fsmonitor-ll.h" +#include "clean-status.h" #include "dir.h" #include "fsmonitor-settings.h" #include "object.h" @@ -122,8 +123,11 @@ static inline void mark_fsmonitor_valid(struct index_state *istate, struct cache static inline void mark_fsmonitor_invalid(struct index_state *istate, struct cache_entry *ce) { enum fsmonitor_mode fsm_mode = fsm_settings__get_mode(istate->repo); + int backoff = fsm_settings__is_watch_limit_backoff(istate->repo); - if (fsm_mode > FSMONITOR_MODE_DISABLED) { + if (fsm_mode > FSMONITOR_MODE_DISABLED || backoff) { + if (backoff) + clean_status_invalidate_current_proof(istate); ce->ce_flags &= ~CE_FSMONITOR_VALID; untracked_cache_invalidate_path(istate, ce->name, 1); trace_printf_key(&trace_fsmonitor, "mark_fsmonitor_invalid '%s'", ce->name); diff --git a/read-cache-ll.h b/read-cache-ll.h index 05c1db3d1c0e82..fc5fcb7848bf53 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -552,6 +552,7 @@ struct cache_entry *refresh_cache_entry(struct index_state *, struct cache_entry void refresh_index_entry_stat(struct index_state *, int, struct stat *); void set_alternate_index_output(const char *); +const char *get_alternate_index_output(void); extern int verify_index_checksum; extern int verify_ce_order; diff --git a/read-cache.c b/read-cache.c index f2ed21be6c169c..0fda7bbab07616 100644 --- a/read-cache.c +++ b/read-cache.c @@ -185,6 +185,9 @@ static void replace_index_entry(struct index_state *istate, int nr, istate->untracked->use_fsmonitor)) && S_ISREG(old->ce_mode) && S_ISREG(ce->ce_mode) && clean_status_index_entry_is_semantically_safe(istate, old, ce); + int suspended_replacement = preserve_untracked && + clean_status_fsmonitor_backoff_suspended(istate) && + !oideq(&old->oid, &ce->oid); replace_index_entry_in_base(istate, old, ce); remove_name_hash(istate, old); @@ -192,9 +195,11 @@ static void replace_index_entry(struct index_state *istate, int nr, ce->ce_flags &= ~CE_HASHED; set_index_entry(istate, nr, ce); ce->ce_flags |= CE_UPDATE_IN_BASE; - if (preserve_untracked) + if (preserve_untracked) { ce->ce_flags &= ~CE_FSMONITOR_VALID; - else + if (suspended_replacement) + fsmonitor_invalidate_cache_entry(ce); + } else mark_fsmonitor_invalid(istate, ce); if (preserve_paired_history && preserve_untracked) trace2_data_intmax("fsmonitor", istate->repo, @@ -1592,6 +1597,8 @@ int repo_refresh_and_write_index(struct repository *repo, return -1; if (refresh_index(repo->index, refresh_flags, pathspec, seen, header_msg)) ret = 1; + if (fsm_settings__is_watch_limit_backoff(repo)) + write_flags |= SKIP_IF_UNCHANGED; if (0 <= fd && write_locked_index(repo->index, &lock_file, COMMIT_LOCK | write_flags)) ret = -1; return ret; @@ -3413,6 +3420,24 @@ enum write_extensions { static int fsmonitor_can_persist_untracked_revalidation( const struct index_state *istate) { + const char *suffix, *pending; + int suspended = clean_status_fsmonitor_backoff_suspended(istate); + int same_token = istate->fsmonitor_last_update && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token); + + if (suspended) { + if (alternate_index_output || getenv(DB_ENVIRONMENT) || + !fstat_is_reliable() || + repo_config_values(istate->repo)->apply_sparse_checkout || + repo_has_replace_refs_uncached(istate->repo)) + return 0; + if (!same_token && istate->fsmonitor_untracked_token && + skip_prefix(istate->fsmonitor_last_update, "builtin:", &suffix) && + skip_prefix(istate->fsmonitor_untracked_token, "pending:", &pending)) + same_token = !strcmp(suffix, pending); + } return istate->untracked && istate->untracked->root && istate->untracked->root->valid && istate->untracked->fsmonitor_revalidation && @@ -3423,9 +3448,7 @@ static int fsmonitor_can_persist_untracked_revalidation( starts_with(istate->fsmonitor_last_update, "builtin:") && istate->fsmonitor_last_update[strlen("builtin:")] && strcmp(istate->fsmonitor_last_update, "builtin:fake") && - istate->fsmonitor_untracked_token && - !strcmp(istate->fsmonitor_last_update, - istate->fsmonitor_untracked_token) && + same_token && !getenv(INDEX_ENVIRONMENT) && !getenv(GIT_WORK_TREE_ENVIRONMENT) && !getenv(GIT_COMMON_DIR_ENVIRONMENT) && @@ -3437,7 +3460,8 @@ static int fsmonitor_can_persist_untracked_revalidation( (CE_ENTRY_ADDED | CE_ENTRY_REMOVED)) && istate->repo->config_values_private_.trust_ctime && istate->repo->config_values_private_.check_stat && - fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC && + (fsm_settings__get_mode(istate->repo) == FSMONITOR_MODE_IPC || + suspended) && clean_status_fsmonitor_semantic_baseline_pending(istate); } @@ -3802,6 +3826,11 @@ void set_alternate_index_output(const char *name) alternate_index_output = name; } +const char *get_alternate_index_output(void) +{ + return alternate_index_output; +} + static int commit_locked_index(struct lock_file *lk) { if (alternate_index_output) @@ -4032,6 +4061,14 @@ static int write_locked_index_with_receipt( rollback_lock_file(lock); return 0; } + if (fsm_settings__is_watch_limit_backoff(istate->repo)) { + /* An actual write may retain history, never a live clean bitmap. */ + for (size_t i = 0; i < istate->cache_nr; i++) + istate->cache[i]->ce_flags &= ~CE_FSMONITOR_VALID; + istate->fsmonitor_untracked_valid = 0; + if (istate->untracked) + istate->untracked->use_fsmonitor = 0; + } if (istate->fsmonitor_last_update) fill_fsmonitor_bitmap(istate); diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 911f1a29425dce..96184fda451f10 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -267,11 +267,19 @@ test_expect_success PERL_TEST_HELPERS \ <.git/mandatory.trace && test_region index do_write_index .git/mandatory.trace && ! cmp .git/index.before .git/index && - test_grep ! FSMN .git/index && - test_grep ! FSUC .git/index && + if assert_backoff_full_proof .git/index \ + >.git/mandatory-proof.out 2>.git/mandatory-proof.err + then + return 1 + else + : + fi && git -c core.fsmonitor=false diff --cached --name-only \ >.git/staged && - test_grep "^tracked$" .git/staged + test_grep "^tracked$" .git/staged && + git -c core.fsmonitor=false --no-optional-locks show :tracked \ + >.git/staged-content && + test_cmp tracked .git/staged-content ) ' @@ -294,4 +302,472 @@ test_expect_success PERL_TEST_HELPERS \ ) ' +setup_backoff_bound_proof () { + test_create_repo "$1" && + ( + cd "$1" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit sibling sibling && + if test "${2-clean}" = staged + then + test_write_lines staged-before >sibling && + git -c core.fsmonitor=false add sibling + elif test "${2-clean}" = nested + then + mkdir nested && + test_write_lines nested-base >nested/tracked && + git add nested/tracked && + git commit -qm nested + else + : + fi && + git config core.autocrlf false && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + if test "${2-clean}" = nested + then + test-tool chmtime -120 tracked sibling nested/tracked + else + test-tool chmtime -120 tracked sibling + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >.git/prime && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/prime.expect && + test_cmp .git/prime.expect .git/prime && + assert_backoff_full_proof .git/index && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/checkpoint.trace" \ + git status --short >.git/checkpoint.status && + test_trace2_data fsmonitor history/external-stored 1 \ + <.git/checkpoint.trace && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status >.git/sidecar.status && + find .git -maxdepth 1 -type f -name "index.csh1.*" \ + >.git/checkpoints && + test_line_count = 1 .git/checkpoints && + checkpoint=$(cat .git/checkpoints) && + assert_backoff_full_proof .git/index && + cp .git/index .git/index.before-backoff && + cp "$checkpoint" .git/checkpoint.before-backoff && + if test -f .git/index.csts + then + cp .git/index.csts .git/sidecar.before-backoff + else + : + fi + ) +} + +record_authenticated_backoff_marker () { + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + test_path_is_file .git/fsmonitor--daemon.inotify-limit && + test_line_count = 3 .git/fsmonitor--daemon.inotify-limit +} + +snapshot_backoff_index_identity () { + perl - "$1" <<-\EOF + use strict; + use warnings; + my @identity = lstat($ARGV[0]) or die "cannot stat index: $!\n"; + die "index is not a regular file\n" unless -f _ && !-l _; + print join(" ", @identity[0, 1, 2, 3, 4, 5, 7, 9, 10]), "\n"; + EOF +} + +assert_backoff_main_index_write () { + perl - "$1" "$2" "$3" <<-\EOF + use strict; + use warnings; + my ($trace, $index, $expected) = @ARGV; + my $lock = "$index.lock"; + my $writes = 0; + open my $input, "<", $trace or die "cannot read trace: $!\n"; + while (my $line = <$input>) { + next unless $line =~ /"event":"region_enter"/; + next unless $line =~ /"category":"index"/; + next unless $line =~ /"label":"do_write_index"/; + $writes++ if $line =~ /"msg":"\Q$lock\E"/; + } + die "expected a main-index write\n" if $expected eq "yes" && !$writes; + die "unexpected $writes main-index writes\n" + if $expected eq "no" && $writes; + die "unknown main-index write expectation\n" + unless $expected eq "yes" || $expected eq "no"; + EOF +} + +assert_backoff_pending_proof () { + perl - "$1" "$2" <<-\EOF + use strict; + use warnings; + my %tokens; + my %flags; + my %payloads; + my %entries; + for my $which (0, 1) { + open my $input, "<", $ARGV[$which] + or die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + die "invalid index signature\n" + unless substr($index, 0, 4) eq "DIRC"; + $entries{$which} = unpack("N", substr($index, 8, 4)); + for my $name ("FSMN", "FSUC", "FSCF") { + my $offset = index($index, $name); + die "missing $name extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $payload = substr($index, $offset + 8, $size); + die "truncated $name extension\n" + unless length($payload) == $size; + $payloads{"$which:$name"} = $payload; + if ($name eq "FSCF") { + $flags{$which} = unpack("N", substr($payload, 8, 4)); + my $length = unpack("N", substr($payload, 12, 4)); + $tokens{"$which:$name"} = + substr($payload, 20, $length); + } else { + my $end = index($payload, "\0", 4); + die "invalid $name token\n" if $end < 0; + $tokens{"$which:$name"} = + substr($payload, 4, $end - 4); + } + } + } + die "original proof is not fully bound\n" if $flags{0} != 15; + die "original provider tokens are not paired\n" + unless $tokens{"0:FSMN"} eq $tokens{"0:FSUC"} && + $tokens{"0:FSMN"} eq $tokens{"0:FSCF"}; + my ($suffix) = $tokens{"0:FSMN"} =~ /\Abuiltin:(.+)\z/; + die "missing authenticated provider suffix\n" unless defined $suffix; + die "staging advanced the historical provider token\n" + unless $tokens{"1:FSMN"} eq $tokens{"0:FSMN"}; + die "staging retained a fully valid provider proof\n" + unless $flags{1} == 9; + die "downgraded configuration names another provider token\n" + unless $tokens{"1:FSCF"} eq $tokens{"0:FSMN"}; + die "untracked history is not authenticated pending state\n" + unless $tokens{"1:FSUC"} eq "pending:$suffix"; + die "fixture does not have exactly two tracked entries\n" + unless $entries{0} == 2 && $entries{1} == 2; + my $payload = $payloads{"1:FSMN"}; + my $end = index($payload, "\0", 4); + my $size = unpack("N", substr($payload, $end + 1, 4)); + my $bitmap = substr($payload, $end + 5, $size); + die "truncated tracked dirty bitmap\n" unless length($bitmap) == $size; + my ($bits, $words) = unpack("NN", substr($bitmap, 0, 8)); + die "tracked dirty bitmap does not span every entry\n" + unless $bits == $entries{1} && $words == 2 && $size == 28; + my ($rlw_high, $rlw_low, $literal_high, $literal_low) = + unpack("NNNN", substr($bitmap, 8, 16)); + die "unexpected tracked dirty bitmap encoding\n" + unless $rlw_high == 2 && $rlw_low == 0 && + $literal_high == 0; + die "historical token falsely marks a tracked entry valid\n" + unless $literal_low == (1 << $entries{1}) - 1; + die "invalid tracked bitmap running-word position\n" + unless unpack("N", substr($bitmap, 24, 4)) == 0; + EOF +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'clean add refresh preserves authenticated proofs during backoff' ' + setup_backoff_bound_proof watch-backoff-refresh && + ( + cd watch-backoff-refresh && + checkpoint=$(cat .git/checkpoints) && + record_authenticated_backoff_marker && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/refresh.trace" \ + git add --refresh tracked sibling >.git/refresh.actual && + test_must_be_empty .git/refresh.actual && + test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/refresh.trace && + assert_backoff_main_index_write \ + .git/refresh.trace "$PWD/.git/index" no && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/refresh.trace && + assert_backoff_full_proof .git/index && + assert_backoff_history_unchanged .git "$checkpoint" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/refresh.oracle && + test_must_be_empty .git/refresh.oracle + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'dirty stash creation preserves the main index during backoff' ' + setup_backoff_bound_proof watch-backoff-stash staged && + ( + cd watch-backoff-stash && + checkpoint=$(cat .git/checkpoints) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + test-tool dump-cache-tree >.git/cache-tree.before && + test_grep "^invalid " .git/cache-tree.before && + cp .git/index .git/expected-stash.index && + GIT_INDEX_FILE="$PWD/.git/expected-stash.index" \ + git -c core.fsmonitor=false write-tree \ + >.git/expected-stash-tree && + record_authenticated_backoff_marker && + test_write_lines unstaged-worktree >tracked && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/stash.oracle && + test_grep "^1 M\\. .* sibling$" .git/stash.oracle && + test_grep "^1 \\.M .* tracked$" .git/stash.oracle && + snapshot_backoff_index_identity .git/index \ + >.git/stash.index.identity.before && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/stash.trace" \ + git stash create >.git/stash.oid && + test_file_not_empty .git/stash.oid && + stash=$(cat .git/stash.oid) && + git -c core.fsmonitor=false rev-parse "$stash^2^{tree}" \ + >.git/actual-stash-tree && + test_cmp .git/expected-stash-tree .git/actual-stash-tree && + git -c core.fsmonitor=false show "$stash:tracked" \ + >.git/stash-worktree && + test_cmp tracked .git/stash-worktree && + git -c core.fsmonitor=false show "$stash^2:sibling" \ + >.git/stash-index && + test_cmp sibling .git/stash-index && + test_must_fail git rev-parse --verify refs/stash \ + >.git/stash-ref 2>.git/stash-ref.err && + test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/stash.trace && + snapshot_backoff_index_identity .git/index \ + >.git/stash.index.identity.after && + test_cmp .git/stash.index.identity.before \ + .git/stash.index.identity.after && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/stash.trace && + assert_backoff_full_proof .git/index && + assert_backoff_history_unchanged .git "$checkpoint" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/stash.after && + test_cmp .git/stash.oracle .git/stash.after + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'mandatory staging downgrades proofs until authenticated recovery' ' + for outcome in delta trivial + do + setup_backoff_bound_proof "watch-backoff-staging-$outcome" && + ( + cd "watch-backoff-staging-$outcome" && + checkpoint=$(cat .git/checkpoints) && + record_authenticated_backoff_marker && + test_write_lines staged-first >tracked && + test_write_lines staged-second >sibling && + test_write_lines visible >visible && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/first-add.trace" \ + git add tracked && + assert_backoff_main_index_write \ + .git/first-add.trace "$PWD/.git/index" yes && + assert_backoff_pending_proof \ + .git/index.before-backoff .git/index && + test_cmp_bin .git/checkpoint.before-backoff \ + "$checkpoint" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/second-add.trace" \ + git add sibling && + assert_backoff_main_index_write \ + .git/second-add.trace "$PWD/.git/index" yes && + assert_backoff_pending_proof \ + .git/index.before-backoff .git/index && + test_cmp_bin .git/checkpoint.before-backoff \ + "$checkpoint" && + test_write_lines unstaged-after >sibling && + git -c core.fsmonitor=false --no-optional-locks \ + show :tracked >.git/staged-tracked && + test_cmp tracked .git/staged-tracked && + git -c core.fsmonitor=false --no-optional-locks \ + show :sibling >.git/staged-sibling && + test_write_lines staged-second >.git/expected-sibling && + test_cmp .git/expected-sibling .git/staged-sibling && + cp .git/index .git/expected-staging.index && + GIT_INDEX_FILE="$PWD/.git/expected-staging.index" \ + git -c core.fsmonitor=false write-tree \ + >.git/expected-stage-tree && + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/staging.expect && + test_grep "^1 M\\. .* tracked$" .git/staging.expect && + test_grep "^1 MM .* sibling$" .git/staging.expect && + test_grep "^? visible$" .git/staging.expect && + cp .git/index .git/pending.before-status && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/backoff-status.trace" \ + git status --porcelain=v2 \ + >.git/backoff-status.actual && + test_cmp .git/staging.expect \ + .git/backoff-status.actual && + test_cmp_bin .git/pending.before-status .git/index && + assert_backoff_pending_proof \ + .git/index.before-backoff .git/index && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/first-add.trace .git/second-add.trace \ + .git/backoff-status.trace && + rm .git/fsmonitor--daemon.inotify-limit && + case "$outcome" in + delta) sequence=DDCCCCCCCCCCCC ;; + trivial) sequence=TCCCCCCCCCCCC ;; + esac && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE="$sequence" \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status --porcelain=v2 \ + >.git/recovery.actual && + test_cmp .git/staging.expect .git/recovery.actual && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/recovery.trace && + if test "$outcome" = trivial + then + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/recovery.trace + else + : + fi && + assert_backoff_full_proof .git/index && + cp .git/index .git/recovered-staging.index && + GIT_INDEX_FILE="$PWD/.git/recovered-staging.index" \ + git -c core.fsmonitor=false write-tree \ + >.git/recovered-stage-tree && + test_cmp .git/expected-stage-tree \ + .git/recovered-stage-tree + ) || return 1 + done && + setup_backoff_bound_proof watch-backoff-hostile && + ( + cd watch-backoff-hostile && + record_authenticated_backoff_marker && + test_write_lines "tracked text" >.gitattributes && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/hostile.trace" \ + git add .gitattributes && + assert_backoff_main_index_write \ + .git/hostile.trace "$PWD/.git/index" yes && + test_grep ! "pending:" .git/index && + if assert_backoff_full_proof .git/index \ + >.git/hostile-proof.out 2>.git/hostile-proof.err + then + return 1 + else + : + fi && + git -c core.fsmonitor=false --no-optional-locks \ + show :.gitattributes >.git/hostile-staged && + test_cmp .gitattributes .git/hostile-staged + ) && + for location in root nested + do + case "$location" in + root) + mode=clean && + attribute=.gitattributes && + tracked=tracked + ;; + nested) + mode=nested && + attribute=nested/.gitattributes && + tracked=nested/tracked + ;; + esac && + setup_backoff_bound_proof \ + "watch-backoff-hostile-$location" "$mode" && + ( + cd "watch-backoff-hostile-$location" && + record_authenticated_backoff_marker && + test_write_lines "tracked text" >"$attribute" && + printf "changed\\r\\n" >"$tracked" && + test_write_lines changed >.git/hostile.expected && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/hostile-ordinary.trace" \ + git add "$tracked" && + assert_backoff_main_index_write \ + .git/hostile-ordinary.trace "$PWD/.git/index" yes && + git -c core.fsmonitor=false --no-optional-locks \ + show ":$tracked" >.git/hostile.actual && + test_cmp .git/hostile.expected .git/hostile.actual && + test_grep ! "pending:" .git/index && + if assert_backoff_full_proof .git/index \ + >.git/hostile-proof.out 2>.git/hostile-proof.err + then + return 1 + else + : + fi && + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/hostile.oracle && + test_grep "^1 M\\. .* $tracked$" \ + .git/hostile.oracle && + test_grep "^? $attribute$" .git/hostile.oracle + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'conditional diff refresh preserves authenticated backoff proofs' ' + setup_backoff_bound_proof watch-backoff-diff-control && + ( + cd watch-backoff-diff-control && + test-tool chmtime +120 tracked && + GIT_TRACE2_EVENT="$PWD/.git/control.trace" \ + git -c core.fsmonitor=false \ + -c diff.autoRefreshIndex=true diff -- tracked \ + >.git/control.actual && + test_must_be_empty .git/control.actual && + assert_backoff_main_index_write \ + .git/control.trace "$PWD/.git/index" yes + ) && + setup_backoff_bound_proof watch-backoff-diff && + ( + cd watch-backoff-diff && + checkpoint=$(cat .git/checkpoints) && + record_authenticated_backoff_marker && + test-tool chmtime +120 tracked && + git -c core.fsmonitor=false -c diff.autoRefreshIndex=true \ + --no-optional-locks diff -- tracked >.git/diff.expect && + test_must_be_empty .git/diff.expect && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/diff.trace" \ + git -c diff.autoRefreshIndex=true diff -- tracked \ + >.git/diff.actual && + test_cmp .git/diff.expect .git/diff.actual && + test_trace2_data fsm_client \ + settings/inotify-watch-limit-backoff 1 \ + <.git/diff.trace && + assert_backoff_main_index_write \ + .git/diff.trace "$PWD/.git/index" no && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/diff.trace && + assert_backoff_full_proof .git/index && + assert_backoff_history_unchanged .git "$checkpoint" + ) +' + test_done From 1baa4c7ba3c51c79fcb6cab38739f223b08923a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 21:29:54 -0500 Subject: [PATCH 384/403] fsmonitor: suspend history with inactive configured filters 4e82cbf148 (fsmonitor: retain suspended history across backoff writers, 2026-08-17) rejects suspended history whenever a filter driver is configured. A global LFS installation is enough, even when no tracked path uses that driver. During temporary watch-limit backoff, clean add --refresh and dirty stash create then rewrite the main index and discard FSMN and FSUC. Mandatory staging also loses the historical boundary needed for later recovery. Admit configured filters through the existing authenticated on-index history checks. Keep the public filter-scope and current-proof predicates invalid; only the private suspended path may bypass the current-scope check. Each same-path regular-file replacement still revalidates its root and ancestor attributes and rejects an actual clean, process, or required filter. Unsafe mutations cannot revive the suspended epoch. Cover two consecutive writes with unused global LFS configuration, accepted recovery, an active transforming filter, and a required-filter failure on byte-equal contents. Exercise clean refresh and dirty stash with preload disabled and enabled, checking the physical main index and independently constructed stash trees. --- clean-status.c | 6 +- t/t7536-fsmonitor-watch-limit-backoff.sh | 357 +++++++++++++++++++++++ 2 files changed, 361 insertions(+), 2 deletions(-) diff --git a/clean-status.c b/clean-status.c index 3a3d7b9beac9b7..c5c2a21aa4d5be 100644 --- a/clean-status.c +++ b/clean-status.c @@ -233,7 +233,7 @@ int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate) repo_has_replace_refs_uncached(istate->repo) || !state->config_enforced || !state->current_config_valid || !state->current_semantic_valid || !state->current_attr_valid || - !state->current_tracked_policy_valid || state->filter_configured || + !state->current_tracked_policy_valid || state->external_history_restored || !state->disk_config_valid || state->disk_config_invalid || !state->disk_config_raw.len || !state->disk_semantic_valid || !state->disk_attr_valid || @@ -290,6 +290,7 @@ int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate) state->config_revalidated = 0; state->initial_coherent = 0; state->config_mismatch = 1; + /* Replacements check their attributes; no current filter scope survives. */ state->filter_scope_valid = 0; FREE_AND_NULL(state->config_revalidated_token); state->backoff_token = xstrdup(istate->fsmonitor_last_update); @@ -433,7 +434,8 @@ int clean_status_index_entry_is_semantically_safe( if (!state || (!suspended && !clean_status_revalidated_token_matches(istate)) || - (state->filter_configured && !state->filter_scope_valid) || + (!suspended && state->filter_configured && + !state->filter_scope_valid) || istate->split_index || istate->sparse_index || !entry) return 0; diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 96184fda451f10..553284cb8ae080 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -770,4 +770,361 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'unused global LFS preserves two mandatory backoff writes and recovery' ' + test_config_global filter.lfs.clean "git-lfs clean -- %f" && + test_config_global filter.lfs.smudge "git-lfs smudge -- %f" && + test_config_global filter.lfs.process "git-lfs filter-process" && + test_config_global filter.lfs.required true && + setup_backoff_bound_proof watch-backoff-unused-lfs && + ( + cd watch-backoff-unused-lfs && + checkpoint=$(cat .git/checkpoints) && + record_authenticated_backoff_marker && + test_write_lines staged-first >tracked && + test_write_lines staged-second >sibling && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/first-add.trace" \ + git add tracked && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <.git/first-add.trace && + assert_backoff_main_index_write \ + .git/first-add.trace "$PWD/.git/index" yes && + assert_backoff_pending_proof .git/index.before-backoff .git/index && + test_cmp_bin .git/checkpoint.before-backoff "$checkpoint" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/second-add.trace" \ + git add sibling && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <.git/second-add.trace && + assert_backoff_main_index_write \ + .git/second-add.trace "$PWD/.git/index" yes && + assert_backoff_pending_proof .git/index.before-backoff .git/index && + test_cmp_bin .git/checkpoint.before-backoff "$checkpoint" && + git -c core.fsmonitor=false --no-optional-locks \ + show :tracked >.git/staged-tracked && + git -c core.fsmonitor=false --no-optional-locks \ + show :sibling >.git/staged-sibling && + test_cmp tracked .git/staged-tracked && + test_cmp sibling .git/staged-sibling && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >.git/expected && + test_grep "^1 M\\. .* tracked$" .git/expected && + test_grep "^1 M\\. .* sibling$" .git/expected && + rm .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status --porcelain=v2 >.git/recovery.actual && + test_cmp .git/expected .git/recovery.actual && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/recovery.trace && + assert_backoff_full_proof .git/index && + cp .git/index .git/recovered.before-warm && + # Scripted provider tokens restart in each Git invocation. + # Check read-only reuse, not optional publication of a new token. + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/warm.actual && + test_cmp .git/expected .git/warm.actual && + test_trace2_data fsmonitor config/coherent 1 <.git/warm.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/warm.trace && + assert_backoff_main_index_write \ + .git/warm.trace "$PWD/.git/index" no && + test_cmp_bin .git/recovered.before-warm .git/index && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/first-add.trace .git/second-add.trace .git/recovery.trace \ + .git/warm.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'an activated clean filter converts content instead of retaining backoff proof' ' + test_config_global filter.lfs.clean "sed s/raw/converted/" && + test_config_global filter.lfs.smudge cat && + test_unconfig --global filter.lfs.process && + test_config_global filter.lfs.required true && + setup_backoff_bound_proof watch-backoff-active-filter && + ( + cd watch-backoff-active-filter && + record_authenticated_backoff_marker && + test_write_lines "tracked filter=lfs" >.gitattributes && + test_write_lines raw >tracked && + test_write_lines converted >.git/converted.expected && + cp .git/index .git/filtered.before && + cp .git/index .git/filtered.oracle.index && + GIT_INDEX_FILE="$PWD/.git/filtered.oracle.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + add tracked && + GIT_INDEX_FILE="$PWD/.git/filtered.oracle.index" \ + git -c core.fsmonitor=false write-tree >.git/expected-tree && + GIT_INDEX_FILE="$PWD/.git/filtered.oracle.index" \ + git -c core.fsmonitor=false show :tracked >.git/oracle-content && + test_cmp .git/converted.expected .git/oracle-content && + test_cmp_bin .git/filtered.before .git/index && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/filtered-add.trace" \ + git add tracked && + assert_backoff_main_index_write \ + .git/filtered-add.trace "$PWD/.git/index" yes && + git -c core.fsmonitor=false --no-optional-locks \ + show :tracked >.git/filtered-content && + test_cmp .git/converted.expected .git/filtered-content && + cp .git/index .git/filtered.actual.index && + GIT_INDEX_FILE="$PWD/.git/filtered.actual.index" \ + git -c core.fsmonitor=false write-tree >.git/actual-tree && + test_cmp .git/expected-tree .git/actual-tree && + test_grep ! "pending:" .git/index && + if assert_backoff_full_proof .git/index \ + >.git/filtered-proof.out 2>.git/filtered-proof.err + then + return 1 + else + : + fi && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >.git/filtered.expected && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/filtered-status.trace" \ + git status --porcelain=v2 >.git/filtered.actual && + test_cmp .git/filtered.expected .git/filtered.actual && + test_grep "^1 M\\. .* tracked$" .git/filtered.actual && + test_grep "^? .gitattributes$" .git/filtered.actual && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/filtered-add.trace .git/filtered-status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'an activated required filter fails closed during backoff' ' + test_config_global filter.lfs.clean false && + test_config_global filter.lfs.smudge cat && + test_unconfig --global filter.lfs.process && + test_config_global filter.lfs.required true && + setup_backoff_bound_proof watch-backoff-required-filter && + ( + cd watch-backoff-required-filter && + record_authenticated_backoff_marker && + cp .git/index .git/required.before && + test_write_lines "tracked filter=lfs" >.git/info/attributes && + test-tool chmtime +120 tracked && + test_must_fail git -c core.fsmonitor=false \ + -c core.untrackedCache=false --no-optional-locks \ + diff -- tracked >.git/required.oracle.out \ + 2>.git/required.oracle.err && + test_grep "clean filter .lfs. failed" .git/required.oracle.err && + test_cmp_bin .git/required.before .git/index && + test_must_fail env GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/required-status.trace" \ + git status --porcelain=v2 >.git/required-status.out \ + 2>.git/required-status.err && + test_grep "clean filter .lfs. failed" .git/required-status.err && + test_cmp_bin .git/required.before .git/index && + test_must_fail env GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/required-add.trace" \ + git add tracked >.git/required-add.out \ + 2>.git/required-add.err && + test_grep "clean filter .lfs. failed" .git/required-add.err && + test_cmp_bin .git/required.before .git/index && + assert_backoff_main_index_write \ + .git/required-add.trace "$PWD/.git/index" no && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/required-status.trace .git/required-add.trace + ) +' + +test_lazy_prereq STATUS_BULK_PRELOAD ' + test_create_repo backoff-bulk-preload-prereq && + ( + cd backoff-bulk-preload-prereq && + sane_unset GIT_TEST_PRELOAD_INDEX_BULK && + test_write_lines tracked >tracked && + test_write_lines sibling >sibling && + git -c core.fsmonitor=false add tracked sibling && + git -c core.fsmonitor=false commit -qm base && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$PWD/.git/bulk.trace" \ + git -c core.fsmonitor=false \ + -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data index preload/bulk_result complete \ + <.git/bulk.trace + ) +' + +configure_backoff_unused_lfs () { + test_config_global filter.lfs.clean "git-lfs clean -- %f" && + test_config_global filter.lfs.smudge "git-lfs smudge -- %f" && + test_config_global filter.lfs.process "git-lfs filter-process" && + test_config_global filter.lfs.required true +} + +extract_backoff_root_trace () { + perl - "$1" <<-\EOF + use strict; + use warnings; + open my $input, "<", $ARGV[0] or die "cannot read trace: $!\n"; + my @lines = <$input>; + my ($root) = map { /"sid":"([^"]+)"/ ? $1 : () } + grep { /"event":"start"/ } @lines; + die "missing root Trace2 start\n" unless defined $root; + print grep { /"sid":"\Q$root\E"/ } @lines; + EOF +} + +assert_backoff_no_bulk_scan () { + ! test_trace2_data index preload/bulk_useful "[0-9][0-9]*" <"$1" && + ! test_trace2_data index preload/bulk_dirs "[0-9][0-9]*" <"$1" && + ! test_trace2_data index preload/bulk_entries "[0-9][0-9]*" <"$1" +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'unused global LFS preserves clean add refresh with preload controls' ' + configure_backoff_unused_lfs && + setup_backoff_bound_proof watch-backoff-unused-lfs-refresh && + ( + cd watch-backoff-unused-lfs-refresh && + sane_unset GIT_TEST_PRELOAD_INDEX_BULK && + checkpoint=$(cat .git/checkpoints) && + record_authenticated_backoff_marker && + for preload in false true + do + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$PWD/.git/refresh-$preload.trace" \ + git -c core.preloadIndex=$preload \ + -c core.preloadIndexBulk=$preload \ + add --refresh -- tracked \ + >".git/refresh-$preload.actual" && + test_must_be_empty ".git/refresh-$preload.actual" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <".git/refresh-$preload.trace" && + assert_backoff_main_index_write \ + ".git/refresh-$preload.trace" "$PWD/.git/index" no && + extract_backoff_root_trace ".git/refresh-$preload.trace" \ + >".git/refresh-$preload.root.trace" && + assert_backoff_no_bulk_scan \ + ".git/refresh-$preload.root.trace" && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + ".git/refresh-$preload.trace" && + assert_backoff_full_proof .git/index && + assert_backoff_history_unchanged .git "$checkpoint" || return 1 + done && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/refresh.oracle && + test_must_be_empty .git/refresh.oracle + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'unused global LFS preserves dirty stash trees and reports bulk work' ' + if test_have_prereq STATUS_BULK_PRELOAD + then + bulk_available=yes + else + bulk_available=no + fi && + configure_backoff_unused_lfs && + setup_backoff_bound_proof watch-backoff-unused-lfs-stash staged && + ( + cd watch-backoff-unused-lfs-stash && + sane_unset GIT_TEST_PRELOAD_INDEX_BULK && + checkpoint=$(cat .git/checkpoints) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + test-tool dump-cache-tree >.git/cache-tree.before && + test_grep "^invalid " .git/cache-tree.before && + cp .git/index .git/expected-stash.index && + GIT_INDEX_FILE="$PWD/.git/expected-stash.index" \ + git -c core.fsmonitor=false write-tree \ + >.git/expected-stash-tree && + record_authenticated_backoff_marker && + test_write_lines unstaged-worktree >tracked && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/stash.oracle && + test_grep "^1 M\\. .* sibling$" .git/stash.oracle && + test_grep "^1 \\.M .* tracked$" .git/stash.oracle && + snapshot_backoff_index_identity .git/index \ + >.git/stash.index.identity.before && + for preload in false true + do + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TRACE2_EVENT="$PWD/.git/stash-$preload.trace" \ + git -c core.preloadIndex=$preload \ + -c core.preloadIndexBulk=$preload \ + stash create >".git/stash-$preload.oid" && + test_file_not_empty ".git/stash-$preload.oid" && + stash=$(cat ".git/stash-$preload.oid") && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse "$stash^2^{tree}" \ + >".git/stash-$preload.index-tree" && + test_cmp .git/expected-stash-tree \ + ".git/stash-$preload.index-tree" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse "$stash^{tree}" \ + >".git/stash-$preload.worktree-tree" && + git -c core.fsmonitor=false --no-optional-locks \ + show "$stash:tracked" >".git/stash-$preload.worktree" && + test_cmp tracked ".git/stash-$preload.worktree" && + git -c core.fsmonitor=false --no-optional-locks \ + show "$stash^2:sibling" >".git/stash-$preload.index" && + test_cmp sibling ".git/stash-$preload.index" && + test_must_fail git -c core.fsmonitor=false \ + --no-optional-locks rev-parse --verify refs/stash \ + >".git/stash-$preload.ref" \ + 2>".git/stash-$preload.ref.err" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <".git/stash-$preload.trace" && + snapshot_backoff_index_identity .git/index \ + >".git/stash-$preload.index.identity.after" && + test_cmp .git/stash.index.identity.before \ + ".git/stash-$preload.index.identity.after" && + extract_backoff_root_trace ".git/stash-$preload.trace" \ + >".git/stash-$preload.root.trace" && + if test "$preload" = true && test "$bulk_available" = yes + then + test_trace2_data index preload/bulk_cache_nr 2 \ + <".git/stash-$preload.root.trace" && + test_trace2_data index preload/bulk_useful 2 \ + <".git/stash-$preload.root.trace" && + test_trace2_data index preload/bulk_result complete \ + <".git/stash-$preload.root.trace" && + test_trace2_data index preload/bulk_dirs "[1-9][0-9]*" \ + <".git/stash-$preload.root.trace" && + test_trace2_data index preload/bulk_entries "[1-9][0-9]*" \ + <".git/stash-$preload.root.trace" + elif test "$preload" = false + then + assert_backoff_no_bulk_scan \ + ".git/stash-$preload.root.trace" + else + : + fi && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + ".git/stash-$preload.trace" && + assert_backoff_full_proof .git/index && + assert_backoff_history_unchanged .git "$checkpoint" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >".git/stash-$preload.after" && + test_cmp .git/stash.oracle ".git/stash-$preload.after" || return 1 + done && + test_cmp .git/stash-false.worktree-tree \ + .git/stash-true.worktree-tree + ) +' + test_done From bdf9d93c77bd502589f3c69eea7cf4ebe205d0c7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 21:30:13 -0500 Subject: [PATCH 385/403] t7536: cover structural writes during watch-limit backoff Suspended history is limited to same-path regular-file replacements. Adding, removing, or renaming an index entry must still publish the requested tree, but cannot retain the old pending untracked proof. Existing coverage does not check both that boundary and the recovery which follows it. Compare each structural operation with an independently constructed, fsmonitor-disabled index. Require the backoff status to preserve the resulting index without publishing a full proof, then exercise a TRIVIAL-to-clean recovery and verify the new full proof and staged tree. Use a read-only final status to test coherent reuse without asserting that process-local scripted tokens are stable across writable commands. --- t/t7536-fsmonitor-watch-limit-backoff.sh | 117 +++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 553284cb8ae080..0db063891480f4 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -1127,4 +1127,121 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'backoff membership changes invalidate proof and recover correct trees' ' + for operation in add-new remove rename + do + setup_backoff_bound_proof "watch-backoff-structural-$operation" && + ( + cd "watch-backoff-structural-$operation" && + record_authenticated_backoff_marker && + cp .git/index .git/structural.oracle.index && + case "$operation" in + add-new) + test_write_lines added-content >created && + GIT_INDEX_FILE="$PWD/.git/structural.oracle.index" \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false add created && + printf "A\\tcreated\\n" >.git/expected-names && + set -- git add created + ;; + remove) + GIT_INDEX_FILE="$PWD/.git/structural.oracle.index" \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false rm --cached tracked \ + >.git/oracle-remove.out && + printf "D\\ttracked\\n" >.git/expected-names && + set -- git rm tracked + ;; + rename) + tracked_oid=$(git -c core.fsmonitor=false \ + --no-optional-locks rev-parse :tracked) && + GIT_INDEX_FILE="$PWD/.git/structural.oracle.index" \ + git -c core.fsmonitor=false \ + update-index --force-remove tracked && + GIT_INDEX_FILE="$PWD/.git/structural.oracle.index" \ + git -c core.fsmonitor=false update-index --add \ + --cacheinfo "100644,$tracked_oid,renamed" && + printf "A\\trenamed\\nD\\ttracked\\n" >.git/expected-names && + set -- git mv tracked renamed + ;; + esac && + GIT_INDEX_FILE="$PWD/.git/structural.oracle.index" \ + git -c core.fsmonitor=false write-tree \ + >.git/expected-tree && + test_cmp_bin .git/index.before-backoff .git/index && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/structural.trace" \ + "$@" >.git/structural.out && + assert_backoff_main_index_write \ + .git/structural.trace "$PWD/.git/index" yes && + git -c core.fsmonitor=false --no-optional-locks \ + diff --cached --name-status --no-renames \ + >.git/actual-names && + test_cmp .git/expected-names .git/actual-names && + cp .git/index .git/structural.actual.index && + GIT_INDEX_FILE="$PWD/.git/structural.actual.index" \ + git -c core.fsmonitor=false write-tree \ + >.git/actual-tree && + test_cmp .git/expected-tree .git/actual-tree && + test_grep ! "pending:" .git/index && + if assert_backoff_full_proof .git/index \ + >.git/structural-proof.out 2>.git/structural-proof.err + then + return 1 + else + : + fi && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/expected && + test_file_not_empty .git/expected && + cp .git/index .git/structural.before-status && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$PWD/.git/backoff-status.trace" \ + git status --porcelain=v2 >.git/backoff.actual && + test_cmp .git/expected .git/backoff.actual && + assert_backoff_main_index_write \ + .git/backoff-status.trace "$PWD/.git/index" no && + test_cmp_bin .git/structural.before-status .git/index && + rm .git/fsmonitor--daemon.inotify-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status --porcelain=v2 >.git/recovery.actual && + test_cmp .git/expected .git/recovery.actual && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/recovery.trace && + assert_backoff_main_index_write \ + .git/recovery.trace "$PWD/.git/index" yes && + assert_backoff_full_proof .git/index && + cp .git/index .git/recovered.before-warm && + cp .git/index .git/recovered.oracle.index && + GIT_INDEX_FILE="$PWD/.git/recovered.oracle.index" \ + git -c core.fsmonitor=false write-tree \ + >.git/recovered-tree && + test_cmp .git/expected-tree .git/recovered-tree && + # Scripted tokens restart; qualify read-only warm reuse. + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/warm.actual && + test_cmp .git/expected .git/warm.actual && + test_trace2_data fsmonitor config/coherent 1 <.git/warm.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <.git/warm.trace && + assert_backoff_main_index_write \ + .git/warm.trace "$PWD/.git/index" no && + test_cmp_bin .git/recovered.before-warm .git/index && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/structural.trace .git/backoff-status.trace \ + .git/recovery.trace .git/warm.trace + ) || return 1 + done +' + test_done From 0d366ab7f393ca1b64c81c9e787ca447af9d33c6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 22:22:53 -0500 Subject: [PATCH 386/403] fsmonitor: recognize the main index in commit hooks 4e82cbf148 (fsmonitor: retain suspended history across backoff writers, 2026-08-17) rejects suspended history whenever GIT_INDEX_FILE is set. But run_commit_hook() exports that variable even for an as-is commit using the main index. A clean add --refresh in such a hook rewrites the index and discards FSMN and FSUC. Repeated staging loses the historical boundary needed for later recovery as well. Authenticate the selected index against the worktree-specific gitdir's physical index, independently of the environment-selected index path. Accept normalized names only when they identify the same singly linked regular file, and bind the existing proof-epoch descriptor to that file. Carry the actual lock destination into pending-FSUC serialization so a private output cannot inherit this exception. Reuse the same check for the optional cache-tree write. Live-IPC admission is unchanged. Suspended history still needs revalidation before publishing a full proof. Exercise genuine primary and linked-worktree hooks, normalized canonical names, repeated staging, and an invalid cache tree. Seed the temporary indexes used by commit -a and partial commits with an authenticated proof and require them to reject it. Also check copied, symlinked, and hardlinked index aliases without letting the probes rewrite the main index. --- cache-tree.c | 20 +- clean-status-index.c | 52 ++ clean-status-index.h | 8 + clean-status.c | 15 +- read-cache.c | 26 +- t/t7536-fsmonitor-watch-limit-backoff.sh | 676 +++++++++++++++++++++++ 6 files changed, 770 insertions(+), 27 deletions(-) diff --git a/cache-tree.c b/cache-tree.c index 3ab5219f84b16b..e26835fb85c1a7 100644 --- a/cache-tree.c +++ b/cache-tree.c @@ -2,9 +2,7 @@ #define DISABLE_SIGN_COMPARE_WARNINGS #include "git-compat-util.h" -#include "abspath.h" -#include "dir.h" -#include "environment.h" +#include "clean-status-index.h" #include "fsmonitor-settings.h" #include "gettext.h" #include "hex.h" @@ -816,20 +814,10 @@ static int skip_backoff_cache_tree_write(struct index_state *istate, const char *index_path) { struct repository *repo = istate->repo; - char *main_index, *named, *canonical; - int skip; - if (getenv(INDEX_ENVIRONMENT) || get_alternate_index_output() || - !fsm_settings__is_watch_limit_backoff(repo)) - return 0; - main_index = xstrfmt("%s/index", repo_get_git_dir(repo)); - named = real_pathdup(index_path, 0); - canonical = real_pathdup(main_index, 0); - skip = named && canonical && !fspathcmp(named, canonical); - free(main_index); - free(named); - free(canonical); - return skip; + return !get_alternate_index_output() && + fsm_settings__is_watch_limit_backoff(repo) && + clean_status_index_path_is_main(repo, index_path); } int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix) diff --git a/clean-status-index.c b/clean-status-index.c index e9eb4a940d79d4..50cb6d870d8ecc 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -1,8 +1,10 @@ #include "git-compat-util.h" +#include "abspath.h" #include "clean-status.h" #include "clean-status-index.h" #include "clean-status-internal.h" #include "clean-status-sidecar.h" +#include "dir.h" #include "environment.h" #include "hash-framing.h" #include "object.h" @@ -16,6 +18,56 @@ #define LOGICAL_INDEX_BENIGN_FLAGS \ (CE_UPTODATE | CE_HASHED | CE_FSMONITOR_VALID) +static int index_path_matches_main( + const char *path, const char *canonical, + const struct clean_status_identity *main_identity) +{ + struct clean_status_identity identity; + struct stat st; + char *resolved; + int matches; + + if (!path || !*path || lstat(path, &st) || + clean_status_identity_from_stat(&identity, &st) || + !clean_status_identity_equal(&identity, main_identity)) + return 0; + resolved = real_pathdup(path, 0); + matches = resolved && !fspathcmp(resolved, canonical); + free(resolved); + return matches; +} + +int clean_status_index_path_is_main(struct repository *repo, const char *path) +{ + struct clean_status_identity main_identity; + struct stat st; + const char *selected = getenv(INDEX_ENVIRONMENT); + char *main_index, *canonical = NULL; + int matches = 0; + + if (!repo || !repo->initialized || !repo->gitdir || !*repo->gitdir || + !repo->index_file || !*repo->index_file || !path || !*path) + return 0; + /* repo_git_path("index") follows GIT_INDEX_FILE, including lockfiles. */ + main_index = xstrfmt("%s/index", repo_get_git_dir(repo)); + if (lstat(main_index, &st) || + clean_status_identity_from_stat(&main_identity, &st)) + goto done; + canonical = real_pathdup(main_index, 0); + if (!canonical || + !index_path_matches_main(repo->index_file, canonical, &main_identity) || + !index_path_matches_main(path, canonical, &main_identity) || + (selected && + !index_path_matches_main(selected, canonical, &main_identity)) || + !index_path_matches_main(main_index, canonical, &main_identity)) + goto done; + matches = 1; +done: + free(canonical); + free(main_index); + return matches; +} + static int snapshot_read( int fd, const struct stat *st, const struct git_hash_algo *algo, uint32_t *version, uint32_t *cache_nr, struct object_id *checksum) diff --git a/clean-status-index.h b/clean-status-index.h index 370792b66f7e4b..ddc177cef5436e 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -5,6 +5,14 @@ #include "hash.h" struct index_state; +struct repository; + +/* + * Check the physical worktree-specific main index, including any selected + * GIT_INDEX_FILE. Accept normalized names, but not leaf symlinks, hardlinks, + * private indexes, or lockfiles. This grants no clean-proof authority. + */ +int clean_status_index_path_is_main(struct repository *repo, const char *path); struct clean_status_index_snapshot { struct clean_status_identity identity; diff --git a/clean-status.c b/clean-status.c index c5c2a21aa4d5be..7b955ed902cb0c 100644 --- a/clean-status.c +++ b/clean-status.c @@ -213,6 +213,7 @@ int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate) const struct git_hash_algo *algo; const struct untracked_cache *uc = istate->untracked; const char *suffix, *pending; + char *main_index; const uint32_t historical = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | FSMONITOR_CLEAN_PROOF_FULL_INDEX; int paired, suspended = 0; @@ -223,7 +224,9 @@ int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate) if (!state || !istate->repo || !istate->repo->worktree || !fsm_settings__is_watch_limit_backoff(istate->repo) || !fstat_is_reliable() || istate != istate->repo->index || - getenv(INDEX_ENVIRONMENT) || getenv(GIT_WORK_TREE_ENVIRONMENT) || + !clean_status_index_path_is_main(istate->repo, + istate->repo->index_file) || + getenv(GIT_WORK_TREE_ENVIRONMENT) || getenv(GIT_COMMON_DIR_ENVIRONMENT) || getenv(DB_ENVIRONMENT) || getenv(ALTERNATE_DB_ENVIRONMENT) || istate->split_index || istate->sparse_index != INDEX_EXPANDED || @@ -278,8 +281,13 @@ int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate) return 0; if (clean_status_index_snapshot_pin_proof_epoch(&source, istate)) return 0; - if (!untracked_cache_preserve_for_revalidation(istate) || - !clean_status_index_snapshot_still_matches_proof_epoch(&source, istate)) + main_index = xstrfmt("%s/index", repo_get_git_dir(istate->repo)); + if (!clean_status_index_snapshot_still_matches_path( + &source, main_index, algo) || + !untracked_cache_preserve_for_revalidation(istate) || + !clean_status_index_snapshot_still_matches_proof_epoch(&source, istate) || + !clean_status_index_snapshot_still_matches_path( + &source, main_index, algo)) goto done; /* Only historical path semantics survive. Nothing is currently clean. */ @@ -300,6 +308,7 @@ int clean_status_suspend_fsmonitor_for_backoff(struct index_state *istate) trace2_data_intmax("fsmonitor", istate->repo, "history/watch-limit-suspended", 1); done: + free(main_index); clean_status_index_snapshot_release(&source); return suspended; } diff --git a/read-cache.c b/read-cache.c index 0fda7bbab07616..63f2e69bd5c90f 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3418,7 +3418,7 @@ enum write_extensions { #define WRITE_ALL_EXTENSIONS ((enum write_extensions)-1) static int fsmonitor_can_persist_untracked_revalidation( - const struct index_state *istate) + const struct index_state *istate, const char *index_path) { const char *suffix, *pending; int suspended = clean_status_fsmonitor_backoff_suspended(istate); @@ -3428,7 +3428,9 @@ static int fsmonitor_can_persist_untracked_revalidation( istate->fsmonitor_untracked_token); if (suspended) { - if (alternate_index_output || getenv(DB_ENVIRONMENT) || + if (alternate_index_output || + !clean_status_index_path_is_main(istate->repo, index_path) || + getenv(DB_ENVIRONMENT) || !fstat_is_reliable() || repo_config_values(istate->repo)->apply_sparse_checkout || repo_has_replace_refs_uncached(istate->repo)) @@ -3449,7 +3451,7 @@ static int fsmonitor_can_persist_untracked_revalidation( istate->fsmonitor_last_update[strlen("builtin:")] && strcmp(istate->fsmonitor_last_update, "builtin:fake") && same_token && - !getenv(INDEX_ENVIRONMENT) && + (!getenv(INDEX_ENVIRONMENT) || suspended) && !getenv(GIT_WORK_TREE_ENVIRONMENT) && !getenv(GIT_COMMON_DIR_ENVIRONMENT) && !getenv(ALTERNATE_DB_ENVIRONMENT) && @@ -3470,10 +3472,12 @@ static int fsmonitor_can_persist_untracked_revalidation( * of a `struct lock_file`, we will therefore effectively perform * a 'close_lock_file_gently()`. Since that is an implementation * detail of lockfiles, callers of `do_write_index()` should not - * rely on it. + * rely on it. The optional index_path names a lockfile's intended + * destination; suspended history may only be carried to the main index. */ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, - enum write_extensions write_extensions, unsigned flags) + enum write_extensions write_extensions, unsigned flags, + const char *index_path) { uint64_t start = getnanotime(); struct hashfile *f; @@ -3717,7 +3721,7 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, istate->untracked && istate->fsmonitor_last_update && (istate->fsmonitor_untracked_valid || - fsmonitor_can_persist_untracked_revalidation(istate)) && + fsmonitor_can_persist_untracked_revalidation(istate, index_path)) && !istate->fsmonitor_legacy_untracked_fallback) { strbuf_reset(&sb); @@ -3847,6 +3851,7 @@ static int do_write_locked_index( int ret; int was_full = istate->sparse_index == INDEX_EXPANDED; int receipt_prepared = 0; + char *index_path = NULL; if (receipt && (flags & COMMIT_LOCK) && !alternate_index_output && !(write_extensions & WRITE_SPLIT_INDEX_EXTENSION)) @@ -3862,9 +3867,14 @@ static int do_write_locked_index( return ret; } + if (clean_status_fsmonitor_backoff_suspended(istate) && + !alternate_index_output) + index_path = get_locked_file_path(lock); trace2_region_enter_printf("index", "do_write_index", istate->repo, "%s", get_lock_file_path(lock)); - ret = do_write_index(istate, lock->tempfile, write_extensions, flags); + ret = do_write_index(istate, lock->tempfile, write_extensions, flags, + index_path); + free(index_path); trace2_region_leave_printf("index", "do_write_index", istate->repo, "%s", get_lock_file_path(lock)); @@ -3989,7 +3999,7 @@ static int write_shared_index(struct index_state *istate, trace2_region_enter_printf("index", "shared/do_write_index", the_repository, "%s", get_tempfile_path(*temp)); - ret = do_write_index(si->base, *temp, WRITE_NO_EXTENSION, flags); + ret = do_write_index(si->base, *temp, WRITE_NO_EXTENSION, flags, NULL); trace2_region_leave_printf("index", "shared/do_write_index", the_repository, "%s", get_tempfile_path(*temp)); diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 0db063891480f4..1c340d6f083277 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -1244,4 +1244,680 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done ' +setup_backoff_hook_pair () { + test_create_repo "$1-main" && + test_when_finished "git -C \"$1-main\" -c core.fsmonitor=false \ + worktree remove --force \"../$1-linked\" >/dev/null 2>&1 || :" && + ( + cd "$1-main" && + test_commit base tracked && + test_commit sibling sibling && + git -c core.fsmonitor=false worktree add --detach "../$1-linked" HEAD && + git config core.autocrlf false && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../$1-linked" + do + gitdir=$(git -C "$worktree" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + test_write_lines staged-before >"$worktree/sibling" && + git -C "$worktree" -c core.fsmonitor=false add sibling && + git -C "$worktree" -c core.fsmonitor=false write-tree \ + >"$gitdir/hook.expected-index-tree" && + test-tool chmtime -120 "$worktree/tracked" "$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 >"$gitdir/prime" && + git -C "$worktree" -c core.fsmonitor=false \ + -c core.untrackedCache=false --no-optional-locks \ + status --porcelain=v2 >"$gitdir/prime.expect" && + test_cmp "$gitdir/prime.expect" "$gitdir/prime" && + test_grep "^1 M\\. .* sibling$" "$gitdir/prime" && + assert_backoff_full_proof "$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ + git -C "$worktree" status --short >"$gitdir/checkpoint.status" && + test_trace2_data fsmonitor history/external-stored 1 \ + <"$gitdir/checkpoint.trace" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status >"$gitdir/sidecar.status" && + find "$gitdir" -maxdepth 1 -type f -name "index.csh1.*" \ + >"$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/checkpoints" && + checkpoint=$(cat "$gitdir/checkpoints") && + assert_backoff_full_proof "$gitdir/index" && + cp "$gitdir/index" "$gitdir/index.before-backoff" && + cp "$checkpoint" "$gitdir/checkpoint.before-backoff" && + if test -f "$gitdir/index.csts" + then + cp "$gitdir/index.csts" "$gitdir/sidecar.before-backoff" + else + : + fi || return 1 + done + ) +} + +assert_backoff_checkpoint_unchanged () { + test_cmp_bin "$1/checkpoint.before-backoff" "$2" && + if test -f "$1/sidecar.before-backoff" + then + test_cmp_bin "$1/sidecar.before-backoff" "$1/index.csts" + else + test_path_is_missing "$1/index.csts" + fi +} + +write_backoff_hook_identity_helper () { + cat >"$1" <<-\EOF + use strict; + use warnings; + use Cwd qw(abs_path getcwd); + use File::Spec; + use Fcntl qw(:mode); + my ($mode, $path, $main) = @ARGV; + my @selected = lstat($path) or die "cannot stat selected index: $!\n"; + die "selected index is not a singly linked regular file\n" + unless S_ISREG($selected[2]) && $selected[3] == 1; + if ($mode eq "identity") { + print join(" ", @selected[0, 1, 2, 3, 4, 5, 7, 9, 10]), "\n"; + } elsif ($mode eq "relative") { + print File::Spec->abs2rel(abs_path($path), getcwd()), "\n"; + } elsif ($mode eq "canonical") { + my @authority = lstat($main) or die "cannot stat physical index: $!\n"; + my $selected_path = abs_path($path); + my $physical_path = abs_path($main); + die "hook did not receive the physical canonical index\n" unless + defined($selected_path) && defined($physical_path) && + $selected_path eq $physical_path && + S_ISREG($authority[2]) && $authority[3] == 1 && + $selected[0] == $authority[0] && $selected[1] == $authority[1]; + print "$selected_path\n"; + } else { + die "unknown hook index operation\n"; + } + EOF +} + +retain_backoff_linked_hook_evidence () { + archive="$1/retained-linked-hook-$5" && + test_path_is_missing "$archive" && + mkdir "$archive" && + cp -R "$3" "$archive/hook" && + cp "$2/hook.expected-index-tree" "$archive/expected-index-tree" && + cp "$2/prime.expect" "$archive/expected-status" && + test_cmp "$2/hook.expected-index-tree" "$archive/expected-index-tree" && + test_cmp "$2/prime.expect" "$archive/expected-status" && + test_write_lines "$2" "$3" "$4" >"$archive/source-paths" && + cp "$2/index.before-backoff" "$archive/index.before" && + cp "$2/index" "$archive/index.after" && + snapshot_backoff_index_identity "$2/index" >"$archive/index.after.identity" && + cp "$2/checkpoint.before-backoff" "$archive/checkpoint.before" && + cp "$4" "$archive/checkpoint.after" && + snapshot_backoff_index_identity "$4" >"$archive/checkpoint.after.identity" && + if test -f "$2/sidecar.before-backoff" + then + cp "$2/sidecar.before-backoff" "$archive/sidecar.before" && + test_cmp_bin "$2/sidecar.before-backoff" "$archive/sidecar.before" + else + test_write_lines absent >"$archive/sidecar.before.absent" + fi && + if test -f "$2/index.csts" + then + cp "$2/index.csts" "$archive/sidecar.after" && + test_cmp_bin "$2/index.csts" "$archive/sidecar.after" + else + test_write_lines absent >"$archive/sidecar.after.absent" + fi && + test_cmp_bin "$2/index.before-backoff" "$archive/index.before" && + test_cmp_bin "$2/index" "$archive/index.after" && + test_cmp_bin "$2/checkpoint.before-backoff" "$archive/checkpoint.before" && + test_cmp_bin "$4" "$archive/checkpoint.after" +} +install_backoff_canonical_hook () { + test_hook -C "$1" pre-commit <<-\EOF + set -eu + test -n "$GIT_INDEX_FILE" + evidence=$BACKOFF_HOOK_EVIDENCE + main=$BACKOFF_HOOK_MAIN_INDEX + helper=$BACKOFF_HOOK_IDENTITY_HELPER + printf "%s\n" "$GIT_INDEX_FILE" >"$evidence/index.env" + perl "$helper" canonical "$GIT_INDEX_FILE" "$main" \ + >"$evidence/selected.path" + perl "$helper" identity "$main" >"$evidence/main.identity.before" + cp "$main" "$evidence/main.before" + cp "$GIT_INDEX_FILE" "$evidence/selected.before" + case "$BACKOFF_HOOK_ACTION" in + refresh) + GIT_TRACE2_EVENT="$evidence/refresh-emitted.trace" \ + git add --refresh -- tracked + cp "$main" "$evidence/after-emitted" + perl "$helper" identity "$main" >"$evidence/identity-emitted" + absolute=$(dirname "$main")/./index + relative=./$(perl "$helper" relative "$main") + printf "%s\n" "$absolute" "$relative" >"$evidence/normalized.paths" + GIT_INDEX_FILE="$absolute" \ + GIT_TRACE2_EVENT="$evidence/refresh-absolute.trace" \ + git add --refresh -- tracked + cp "$main" "$evidence/after-absolute" + perl "$helper" identity "$main" >"$evidence/identity-absolute" + GIT_INDEX_FILE="$relative" \ + GIT_TRACE2_EVENT="$evidence/refresh-relative.trace" \ + git add --refresh -- tracked + cp "$main" "$evidence/after-relative" + perl "$helper" identity "$main" >"$evidence/identity-relative" + ;; + stage) + printf "%s\n" hook-first >tracked + GIT_TRACE2_EVENT="$evidence/first-add.trace" git add tracked + cp "$main" "$evidence/after-first" + perl "$helper" identity "$main" >"$evidence/identity-first" + printf "%s\n" hook-second >sibling + GIT_TRACE2_EVENT="$evidence/second-add.trace" git add sibling + cp "$main" "$evidence/after-second" + perl "$helper" identity "$main" >"$evidence/identity-second" + test-tool dump-cache-tree >"$evidence/cache-tree.before-write-tree" + GIT_TRACE2_EVENT="$evidence/write-tree.trace" \ + git write-tree >"$evidence/write-tree" + cp "$main" "$evidence/after-write-tree" + perl "$helper" identity "$main" >"$evidence/identity-write-tree" + ;; + *) + exit 2 + ;; + esac + cp "$main" "$evidence/main.after" + perl "$helper" identity "$main" >"$evidence/main.identity.after" + printf "%s\n" complete >"$evidence/completed" + exit 1 + EOF +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'canonical pre-commit refresh preserves primary and linked backoff proofs' ' + sane_unset GIT_INDEX_FILE && + setup_backoff_hook_pair watch-backoff-hook-refresh && + common=$(git -C watch-backoff-hook-refresh-main -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + write_backoff_hook_identity_helper "$common/hook-index.pl" && + install_backoff_canonical_hook watch-backoff-hook-refresh-main && + for kind in main linked + do + ( + cd "watch-backoff-hook-refresh-$kind" && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + checkpoint=$(cat "$gitdir/checkpoints") && + evidence="$gitdir/hook-refresh-evidence" && + mkdir "$evidence" && + main_index=$(perl "$common/hook-index.pl" canonical \ + "$gitdir/index" "$gitdir/index") && + printf "%s\n" "$main_index" >"$evidence/expected.path" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >"$evidence/head.before" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" \ + >"$evidence/refs.before" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + test_must_fail env GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$evidence/commit.trace" \ + BACKOFF_HOOK_ACTION=refresh BACKOFF_HOOK_EVIDENCE="$evidence" \ + BACKOFF_HOOK_MAIN_INDEX="$main_index" \ + BACKOFF_HOOK_IDENTITY_HELPER="$common/hook-index.pl" \ + git commit -qm "canonical refresh hook" && + test_grep "^complete$" "$evidence/completed" && + test_file_not_empty "$evidence/index.env" && + test_cmp "$evidence/expected.path" "$evidence/selected.path" && + assert_backoff_full_proof "$evidence/selected.before" && + for spelling in emitted absolute relative + do + test_cmp_bin "$evidence/selected.before" \ + "$evidence/after-$spelling" && + test_cmp "$evidence/main.identity.before" \ + "$evidence/identity-$spelling" && + assert_backoff_full_proof "$evidence/after-$spelling" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <"$evidence/refresh-$spelling.trace" && + assert_backoff_main_index_write \ + "$evidence/refresh-$spelling.trace" "$main_index" no || + return 1 + done && + test_cmp "$evidence/main.identity.before" \ + "$evidence/main.identity.after" && + assert_backoff_history_unchanged "$gitdir" "$checkpoint" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >"$evidence/head.after" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" \ + >"$evidence/refs.after" && + test_cmp "$evidence/head.before" "$evidence/head.after" && + test_cmp "$evidence/refs.before" "$evidence/refs.after" && + cp "$gitdir/index" "$evidence/tree.index" && + GIT_INDEX_FILE="$evidence/tree.index" \ + git -c core.fsmonitor=false write-tree >"$evidence/actual-tree" && + test_cmp "$gitdir/hook.expected-index-tree" "$evidence/actual-tree" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/status.after" && + test_cmp "$gitdir/prime.expect" "$evidence/status.after" && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/commit.trace" "$evidence"/refresh-*.trace && + if test "$kind" = linked + then + retain_backoff_linked_hook_evidence \ + "$common" "$gitdir" "$evidence" "$checkpoint" refresh + else + : + fi + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'canonical pre-commit staging retains pending history across repeated writes' ' + sane_unset GIT_INDEX_FILE && + setup_backoff_hook_pair watch-backoff-hook-stage && + common=$(git -C watch-backoff-hook-stage-main -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + write_backoff_hook_identity_helper "$common/hook-index.pl" && + install_backoff_canonical_hook watch-backoff-hook-stage-main && + for kind in main linked + do + ( + cd "watch-backoff-hook-stage-$kind" && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + checkpoint=$(cat "$gitdir/checkpoints") && + evidence="$gitdir/hook-stage-evidence" && + mkdir "$evidence" && + main_index=$(perl "$common/hook-index.pl" canonical \ + "$gitdir/index" "$gitdir/index") && + printf "%s\n" "$main_index" >"$evidence/expected.path" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >"$evidence/head.before" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" \ + >"$evidence/refs.before" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + test_must_fail env GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$evidence/commit.trace" \ + BACKOFF_HOOK_ACTION=stage BACKOFF_HOOK_EVIDENCE="$evidence" \ + BACKOFF_HOOK_MAIN_INDEX="$main_index" \ + BACKOFF_HOOK_IDENTITY_HELPER="$common/hook-index.pl" \ + git commit -qm "canonical staging hook" && + test_grep "^complete$" "$evidence/completed" && + test_cmp "$evidence/expected.path" "$evidence/selected.path" && + assert_backoff_full_proof "$evidence/selected.before" && + for stage in first second + do + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <"$evidence/$stage-add.trace" && + assert_backoff_main_index_write \ + "$evidence/$stage-add.trace" "$main_index" yes && + assert_backoff_pending_proof "$evidence/selected.before" \ + "$evidence/after-$stage" || return 1 + done && + test_grep "^invalid " "$evidence/cache-tree.before-write-tree" && + ! test_cmp_bin "$evidence/selected.before" "$evidence/after-first" && + ! test_cmp_bin "$evidence/after-first" "$evidence/after-second" && + test_cmp_bin "$evidence/after-second" "$evidence/after-write-tree" && + test_cmp "$evidence/identity-second" "$evidence/identity-write-tree" && + test_cmp_bin "$evidence/after-write-tree" "$gitdir/index" && + assert_backoff_pending_proof "$evidence/selected.before" "$gitdir/index" && + assert_backoff_checkpoint_unchanged "$gitdir" "$checkpoint" && + test_path_is_missing "$gitdir/index.lock" && + cp "$evidence/selected.before" "$evidence/oracle.index" && + GIT_INDEX_FILE="$evidence/oracle.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + add tracked sibling && + GIT_INDEX_FILE="$evidence/oracle.index" \ + git -c core.fsmonitor=false write-tree >"$evidence/expected-tree" && + test_cmp "$evidence/expected-tree" "$evidence/write-tree" && + cp "$gitdir/index" "$evidence/actual.index" && + GIT_INDEX_FILE="$evidence/actual.index" \ + git -c core.fsmonitor=false write-tree >"$evidence/actual-tree" && + test_cmp "$evidence/expected-tree" "$evidence/actual-tree" && + git -c core.fsmonitor=false --no-optional-locks \ + show :tracked >"$evidence/staged-tracked" && + git -c core.fsmonitor=false --no-optional-locks \ + show :sibling >"$evidence/staged-sibling" && + test_cmp tracked "$evidence/staged-tracked" && + test_cmp sibling "$evidence/staged-sibling" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/status.expected" && + test_grep "^1 M\\. .* tracked$" "$evidence/status.expected" && + test_grep "^1 M\\. .* sibling$" "$evidence/status.expected" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$evidence/status.trace" \ + git status --porcelain=v2 >"$evidence/status.actual" && + test_cmp "$evidence/status.expected" "$evidence/status.actual" && + test_cmp_bin "$evidence/after-second" "$gitdir/index" && + assert_backoff_checkpoint_unchanged "$gitdir" "$checkpoint" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >"$evidence/head.after" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" \ + >"$evidence/refs.after" && + test_cmp "$evidence/head.before" "$evidence/head.after" && + test_cmp "$evidence/refs.before" "$evidence/refs.after" && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/commit.trace" "$evidence/first-add.trace" \ + "$evidence/second-add.trace" "$evidence/write-tree.trace" \ + "$evidence/status.trace" && + if test "$kind" = linked + then + retain_backoff_linked_hook_evidence \ + "$common" "$gitdir" "$evidence" "$checkpoint" stage + else + : + fi + ) || return 1 + done +' + +test_lazy_prereq HARDLINKS ' + : >hardlink-a && + ln hardlink-a hardlink-b +' + +write_backoff_rejected_index_helper () { + cat >"$1" <<-\EOF + use strict; + use warnings; + use Cwd qw(abs_path); + use File::Basename qw(basename dirname); + use Fcntl qw(:mode); + my ($operation, $kind, $path, $main) = @ARGV; + my $dir = abs_path(dirname($main)) or die "cannot resolve gitdir\n"; + my $physical = "$dir/index"; + my @authority = lstat($main) or die "cannot stat physical index: $!\n"; + my @selected = lstat($path) or die "cannot stat selected index: $!\n"; + my $parent = abs_path(dirname($path)) or die "cannot resolve selected parent\n"; + my $named = "$parent/" . basename($path); + die "invalid physical index authority\n" unless + S_ISREG($authority[2]) && $authority[4] == $> && + abs_path($main) eq $physical; + die "selected path is not fixture-owned\n" unless + $selected[4] == $> && $parent eq $dir && $named ne $physical; + my $same = $selected[0] == $authority[0] && + $selected[1] == $authority[1]; + if ($operation eq "temporary") { + die "selected index is not an independent regular lockfile\n" unless + S_ISREG($selected[2]) && $selected[3] == 1 && + $authority[3] == 1 && !$same && + abs_path($path) eq $named; + die "unexpected commit -a index\n" if + $kind eq "all" && $named ne "$physical.lock"; + die "unexpected partial-commit index\n" if + $kind eq "partial" && + $named !~ /\A\Q$dir\E\/next-index-[0-9]+\.lock\z/; + die "unknown commit style\n" unless $kind eq "all" || $kind eq "partial"; + print "$named\n"; + } elsif ($operation eq "alias") { + die "unexpected owned alias name\n" unless + $named eq "$dir/index.alias-$kind"; + if ($kind eq "copy") { + die "invalid copied-index control\n" unless + S_ISREG($selected[2]) && $selected[3] == 1 && + $authority[3] == 1 && !$same; + } elsif ($kind eq "symlink") { + my @target = stat($path) or die "cannot stat alias target: $!\n"; + die "invalid leaf-symlink control\n" unless + S_ISLNK($selected[2]) && $selected[3] == 1 && + $authority[3] == 1 && readlink($path) eq "index" && + $target[0] == $authority[0] && $target[1] == $authority[1]; + } elsif ($kind eq "hardlink") { + die "invalid hardlink control\n" unless + S_ISREG($selected[2]) && $selected[3] == 2 && + $authority[3] == 2 && $same; + } else { + die "unknown alias kind\n"; + } + print join(" ", @selected[0, 1, 2, 3, 4, 5, 7, 9, 10]), "\n"; + } else { + die "unknown rejected-index operation\n"; + } + EOF +} + +assert_backoff_rejected_index_trace () { + test_trace2_data fsm_client settings/inotify-watch-limit-backoff 1 <"$1" && + ! test_trace2_data fsmonitor history/watch-limit-suspended 1 <"$1" && + ! test_trace2_data fsmonitor token_closure/accepted 1 <"$1" && + ! test_trace2_data fsmonitor config/revalidated 1 <"$1" && + ! test_trace2_data fsmonitor config/tracked-epoch-valid 1 <"$1" && + ! test_trace2_data status clean-proof/hit 1 <"$1" && + ! test_trace2_data status clean-proof/sidecar 1 <"$1" && + test_grep ! "\"event\":\"child_start\".*\"fsmonitor--daemon\"" "$1" +} + +install_backoff_temporary_hook () { + test_hook -C "$1" pre-commit <<-\EOF + set -eu + test -n "$GIT_INDEX_FILE" + evidence=$BACKOFF_HOOK_EVIDENCE + main=$BACKOFF_HOOK_MAIN_INDEX + helper=$BACKOFF_HOOK_IDENTITY_HELPER + reject=$BACKOFF_HOOK_REJECT_HELPER + printf "%s\n" "$GIT_INDEX_FILE" >"$evidence/index.env" + selected=$(perl "$reject" temporary "$BACKOFF_HOOK_STYLE" \ + "$GIT_INDEX_FILE" "$main") + printf "%s\n" "$selected" >"$evidence/selected.path" + perl "$helper" identity "$main" >"$evidence/main.identity.hook-before" + perl "$helper" identity "$selected" >"$evidence/selected.identity.original" + cp "$selected" "$evidence/selected.original" + cp "$selected" "$evidence/original-tree.index" + GIT_INDEX_FILE="$evidence/original-tree.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + write-tree >"$evidence/original-tree" + cmp "$evidence/main.seed" "$main" + perl "$reject" temporary "$BACKOFF_HOOK_STYLE" \ + "$GIT_INDEX_FILE" "$main" >"$evidence/selected.path.rechecked" + cmp "$evidence/selected.path" "$evidence/selected.path.rechecked" + # Deliberately inject the canonical proof into the genuine private index. + # Its naturally produced contents are retained separately above. + cp "$main" "$selected" + cp "$selected" "$evidence/selected.seeded" + GIT_TRACE2_EVENT="$evidence/refresh.trace" \ + git add --refresh -- sibling + perl "$reject" temporary "$BACKOFF_HOOK_STYLE" \ + "$GIT_INDEX_FILE" "$main" >"$evidence/selected.path.after" + cp "$selected" "$evidence/selected.after" + cp "$selected" "$evidence/after-tree.index" + GIT_INDEX_FILE="$evidence/after-tree.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + write-tree >"$evidence/after-tree" + cp "$main" "$evidence/main.after-hook" + perl "$helper" identity "$main" >"$evidence/main.identity.hook-after" + printf "%s\n" complete >"$evidence/completed" + exit 1 + EOF +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'commit temporary indexes and noncanonical aliases reject backoff authority' ' + sane_unset GIT_INDEX_FILE && + for style in all partial + do + setup_backoff_bound_proof "watch-backoff-hook-reject-$style" staged && + write_backoff_hook_identity_helper \ + "watch-backoff-hook-reject-$style/.git/hook-index.pl" && + write_backoff_rejected_index_helper \ + "watch-backoff-hook-reject-$style/.git/rejected-index.pl" && + install_backoff_temporary_hook "watch-backoff-hook-reject-$style" && + ( + cd "watch-backoff-hook-reject-$style" && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + main_index="$gitdir/index" && + checkpoint=$(cat .git/checkpoints) && + evidence="$gitdir/hook-reject-evidence" && + mkdir "$evidence" && + test_write_lines worktree-change >tracked && + cp "$main_index" "$evidence/main.seed" && + assert_backoff_full_proof "$evidence/main.seed" && + cp "$main_index" "$evidence/oracle-main.index" && + GIT_INDEX_FILE="$evidence/oracle-main.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + write-tree >"$evidence/expected-main-tree" && + case "$style" in + all) + cp "$main_index" "$evidence/oracle-selected.index" && + GIT_INDEX_FILE="$evidence/oracle-selected.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false add -u && + set -- -a + ;; + partial) + GIT_INDEX_FILE="$evidence/oracle-selected.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false read-tree HEAD && + GIT_INDEX_FILE="$evidence/oracle-selected.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false add -- tracked && + set -- -- tracked + ;; + esac && + GIT_INDEX_FILE="$evidence/oracle-selected.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + write-tree >"$evidence/expected-selected-tree" && + ! test_cmp "$evidence/expected-main-tree" "$evidence/expected-selected-tree" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/status.before" && + test_grep "^1 \\.M .* tracked$" "$evidence/status.before" && + test_grep "^1 M\\. .* sibling$" "$evidence/status.before" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >"$evidence/head.before" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" >"$evidence/refs.before" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.before" && + record_authenticated_backoff_marker && + test_must_fail env GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$evidence/commit.trace" \ + BACKOFF_HOOK_STYLE="$style" BACKOFF_HOOK_EVIDENCE="$evidence" \ + BACKOFF_HOOK_MAIN_INDEX="$main_index" \ + BACKOFF_HOOK_IDENTITY_HELPER="$gitdir/hook-index.pl" \ + BACKOFF_HOOK_REJECT_HELPER="$gitdir/rejected-index.pl" \ + git commit -qm "temporary index hook" "$@" && + test_grep "^complete$" "$evidence/completed" && + test_cmp "$evidence/expected-selected-tree" "$evidence/original-tree" && + test_cmp "$evidence/expected-main-tree" "$evidence/after-tree" && + test_cmp_bin "$evidence/main.seed" "$evidence/selected.seeded" && + assert_backoff_full_proof "$evidence/selected.seeded" && + assert_backoff_rejected_index_trace "$evidence/refresh.trace" && + test_region index do_write_index "$evidence/refresh.trace" && + test_grep ! "pending:" "$evidence/selected.after" && + if assert_backoff_full_proof "$evidence/selected.after" \ + >"$evidence/rejected-proof.out" 2>"$evidence/rejected-proof.err" + then + return 1 + else + : + fi && + test_cmp "$evidence/selected.path" "$evidence/selected.path.after" && + test_cmp_bin "$evidence/main.seed" "$evidence/main.after-hook" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.hook-before" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.hook-after" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.after" && + assert_backoff_full_proof "$main_index" && + assert_backoff_history_unchanged .git "$checkpoint" && + selected=$(cat "$evidence/selected.path") && + test_path_is_missing "$selected" && + test_path_is_missing "$selected.lock" && + find "$gitdir" -maxdepth 1 -name "next-index-*.lock" \ + >"$evidence/remaining-temporary-indexes" && + test_must_be_empty "$evidence/remaining-temporary-indexes" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/status.after" && + test_cmp "$evidence/status.before" "$evidence/status.after" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >"$evidence/head.after" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" >"$evidence/refs.after" && + test_cmp "$evidence/head.before" "$evidence/head.after" && + test_cmp "$evidence/refs.before" "$evidence/refs.after" && + test_grep ! "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/commit.trace" + ) || return 1 + done && + setup_backoff_bound_proof watch-backoff-index-aliases && + test_when_finished "rm -f \ + \"$PWD/watch-backoff-index-aliases/.git/index.alias-copy\" \ + \"$PWD/watch-backoff-index-aliases/.git/index.alias-symlink\" \ + \"$PWD/watch-backoff-index-aliases/.git/index.alias-hardlink\"" && + ( + cd watch-backoff-index-aliases && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + main_index="$gitdir/index" && + checkpoint=$(cat .git/checkpoints) && + evidence="$gitdir/alias-evidence" && + mkdir "$evidence" && + write_backoff_rejected_index_helper "$gitdir/rejected-index.pl" && + test_write_lines changed >tracked && + test_write_lines visible >visible && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/status.expected" && + test_grep "^1 \\.M .* tracked$" "$evidence/status.expected" && + test_grep "^? visible$" "$evidence/status.expected" && + record_authenticated_backoff_marker && + for kind in copy symlink hardlink + do + case "$kind" in + copy) alias_prereq= ;; + symlink) alias_prereq=SYMLINKS ;; + hardlink) alias_prereq=HARDLINKS ;; + esac && + if test -n "$alias_prereq" && ! test_have_prereq "$alias_prereq" + then + test_write_lines "$alias_prereq prerequisite unavailable" \ + >"$evidence/$kind.skipped" && + continue + fi && + alias="$gitdir/index.alias-$kind" && + test_path_is_missing "$alias" && + snapshot_backoff_index_identity "$main_index" \ + >"$evidence/$kind.main.before-setup" && + case "$kind" in + copy) cp "$main_index" "$alias" ;; + symlink) ln -s index "$alias" ;; + hardlink) ln "$main_index" "$alias" ;; + esac && + perl "$gitdir/rejected-index.pl" alias "$kind" "$alias" "$main_index" \ + >"$evidence/$kind.selected.before" && + snapshot_backoff_index_identity "$main_index" \ + >"$evidence/$kind.main.after-setup" && + cp "$alias" "$evidence/$kind.seeded" && + test_cmp_bin .git/index.before-backoff "$evidence/$kind.seeded" && + assert_backoff_full_proof "$evidence/$kind.seeded" && + GIT_INDEX_FILE="$alias" GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$evidence/$kind.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >"$evidence/$kind.status" && + test_cmp "$evidence/status.expected" "$evidence/$kind.status" && + assert_backoff_rejected_index_trace "$evidence/$kind.trace" && + test_region ! index do_write_index "$evidence/$kind.trace" && + test_cmp_bin "$evidence/$kind.seeded" "$alias" && + perl "$gitdir/rejected-index.pl" alias "$kind" "$alias" "$main_index" \ + >"$evidence/$kind.selected.after" && + test_cmp "$evidence/$kind.selected.before" "$evidence/$kind.selected.after" && + snapshot_backoff_index_identity "$main_index" \ + >"$evidence/$kind.main.after-probe" && + test_cmp "$evidence/$kind.main.after-setup" "$evidence/$kind.main.after-probe" && + assert_backoff_history_unchanged .git "$checkpoint" && + rm "$alias" && + test_path_is_missing "$alias" && + snapshot_backoff_index_identity "$main_index" \ + >"$evidence/$kind.main.after-cleanup" && + assert_backoff_history_unchanged .git "$checkpoint" || return 1 + done + ) +' + test_done From 25e2d02fa35381378ca5bf37be895f086976d4af Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:00:44 -0500 Subject: [PATCH 387/403] apply: preserve suspended fsmonitor history 4e82cbf148 (fsmonitor: retain suspended history across backoff writers, 2026-08-17) preserves authenticated index history while a watch-limit marker temporarily disables the provider. The apply entrypoints still enable that history only for a live IPC provider, and the patch writer requires a currently paired token. Consequently, even a same-path patch discards FSMN and FSUC. In git am, the initial index refresh can discard them before the patch changes any staged content. Attach history before the first index read during authenticated backoff. Let the existing same-path regular-file replacement path accept a suspended semantic baseline with pending untracked candidates. Keep the live-token checks unchanged, and retain the whole-patch-list preflight so any structural or otherwise unsafe patch revokes preservation for the batch. The index writer still emits only historical FSCF flags and an all-dirty tracked bitmap until a fresh provider boundary closes. Cover consecutive apply --index and am operations, a mixed structural batch, immediate index and tree checks, and recovery through a genuine TRIVIAL response followed by a successful closure. --- apply.c | 35 ++-- builtin/am.c | 3 +- builtin/apply.c | 3 +- t/t7536-fsmonitor-watch-limit-backoff.sh | 256 +++++++++++++++++++++++ 4 files changed, 283 insertions(+), 14 deletions(-) diff --git a/apply.c b/apply.c index 5aad604fc9c6d1..48cf4083130f89 100644 --- a/apply.c +++ b/apply.c @@ -4448,6 +4448,7 @@ static int patch_preserves_clean_history(struct apply_state *state, { struct index_state *istate = state->repo->index; const struct cache_entry *old; + int suspended = clean_status_fsmonitor_backoff_suspended(istate); int pos; if (!state->update_index || state->ita_only || state->threeway || @@ -4462,7 +4463,8 @@ static int patch_preserves_clean_history(struct apply_state *state, repo_config_values(istate->repo)->apply_sparse_checkout || !istate->repo->config_values_private_.trust_ctime || !istate->repo->config_values_private_.check_stat || - fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + (fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC && + !suspended) || repo_has_replace_refs_uncached(istate->repo) || patch->is_new > 0 || patch->is_delete > 0 || patch->is_copy || patch->is_rename || patch->conflicted_threeway || @@ -4472,19 +4474,28 @@ static int patch_preserves_clean_history(struct apply_state *state, create_ce_mode(patch->old_mode) != create_ce_mode(patch->new_mode) || !clean_status_external_history_enabled(istate) || - !clean_status_has_persistent_fsmonitor_semantic_history(istate) || - !clean_status_revalidated_token_matches(istate) || - !istate->fsmonitor_token_valid || - !istate->fsmonitor_untracked_valid || - !istate->fsmonitor_untracked_extension_seen || - istate->fsmonitor_untracked_extension_invalid || - !istate->fsmonitor_last_update || - !istate->fsmonitor_untracked_token || - strcmp(istate->fsmonitor_last_update, - istate->fsmonitor_untracked_token) || - !istate->untracked || !istate->untracked->use_fsmonitor || + !istate->untracked || !istate->untracked->root) return 0; + if (suspended) { + /* Keep only the authenticated historical boundary during backoff. */ + if (!clean_status_fsmonitor_semantic_baseline_pending(istate) || + !istate->untracked->root->valid || + !istate->untracked->fsmonitor_revalidation) + return 0; + } else if (!clean_status_has_persistent_fsmonitor_semantic_history(istate) || + !clean_status_revalidated_token_matches(istate) || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_untracked_valid || + !istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + !istate->fsmonitor_last_update || + !istate->fsmonitor_untracked_token || + strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token) || + !istate->untracked->use_fsmonitor) { + return 0; + } pos = index_name_pos(istate, patch->old_name, strlen(patch->old_name)); diff --git a/builtin/am.c b/builtin/am.c index 0c1039070325e9..57e1920be67a91 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -2469,7 +2469,8 @@ int cmd_am(int argc, git_committer_info(IDENT_STRICT); if (fstat_is_reliable() && !getenv(INDEX_ENVIRONMENT) && - fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + (fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC || + fsm_settings__is_watch_limit_backoff(the_repository)) && !clean_status_config_read_repository(the_repository, &clean_digest)) { clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &clean_digest); diff --git a/builtin/apply.c b/builtin/apply.c index 4cc2ac83369b35..49c98fabc678c8 100644 --- a/builtin/apply.c +++ b/builtin/apply.c @@ -60,7 +60,8 @@ int cmd_apply(int argc, !repo_config_values(the_repository)->apply_sparse_checkout && the_repository->config_values_private_.trust_ctime && the_repository->config_values_private_.check_stat && - fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && + (fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC || + fsm_settings__is_watch_limit_backoff(the_repository)) && !repo_has_replace_refs_uncached(the_repository) && !clean_status_config_read_repository(the_repository, &clean_digest)) { diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 1c340d6f083277..165da244e76e17 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -1920,4 +1920,260 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ) ' +make_backoff_patch_series () ( + shape=$1 && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >.git/patch-base.commit && + parent=$(cat .git/patch-base.commit) && + cp .git/index .git/patch-maker.index && + for step in first second + do + case "$step" in + first) patch_target=tracked ;; + second) patch_target=sibling ;; + esac && + test_write_lines "patched-$step" >".git/patch-$step.contents" && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ + <".git/patch-$step.contents") && + GIT_INDEX_FILE="$PWD/.git/patch-maker.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + update-index --cacheinfo "100644,$oid,$patch_target" && + if test "$shape" = mixed + then + test_write_lines created >.git/patch-created.contents && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ + <.git/patch-created.contents) && + GIT_INDEX_FILE="$PWD/.git/patch-maker.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + update-index --add \ + --cacheinfo "100644,$oid,zz-created" + else + : + fi && + GIT_INDEX_FILE="$PWD/.git/patch-maker.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + write-tree >".git/patch-$step.tree" && + tree=$(cat ".git/patch-$step.tree") && + commit=$(git -c core.fsmonitor=false -c commit.gpgSign=false \ + commit-tree "$tree" -p "$parent" -m "backoff $step") && + test_write_lines "$commit" >".git/patch-$step.commit" && + git -c core.fsmonitor=false --no-optional-locks \ + diff-tree --binary --full-index --no-renames --no-commit-id -p \ + "$parent" "$commit" -- >".git/patch-$step.diff" && + git -c core.fsmonitor=false --no-optional-locks \ + format-patch -1 --stdout --no-signature --no-renames "$commit" \ + >".git/patch-$step.mbox" && + parent=$commit || return 1 + test "$shape" != mixed || break + done && + test_cmp_bin .git/index.before-backoff .git/index +) + +assert_backoff_patch_tree () { + cp "$1" "$2.actual.index" && + GIT_INDEX_FILE="$PWD/$2.actual.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + write-tree >"$2.actual.tree" && + test_cmp "$2.tree" "$2.actual.tree" +} + +recover_backoff_patch_history () { + expected_status=$1 && + expected_tree=$2 && + rm .git/fsmonitor--daemon.inotify-limit && + GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$PWD/.git/patch-recovery.trace" \ + git status --porcelain=v2 >.git/patch-recovery.actual && + test_cmp "$expected_status" .git/patch-recovery.actual && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/patch-recovery.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/patch-recovery.trace && + assert_backoff_full_proof .git/index && + cp .git/index .git/patch-recovered.index && + cp .git/index .git/patch-recovered.oracle.index && + GIT_INDEX_FILE="$PWD/.git/patch-recovered.oracle.index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + write-tree >.git/patch-recovered.tree && + test_cmp "$expected_tree" .git/patch-recovered.tree && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/patch-warm.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/patch-warm.actual && + test_cmp "$expected_status" .git/patch-warm.actual && + test_trace2_data fsmonitor config/coherent 1 <.git/patch-warm.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <.git/patch-warm.trace && + assert_backoff_main_index_write .git/patch-warm.trace \ + "$PWD/.git/index" no && + test_cmp_bin .git/patch-recovered.index .git/index && + test_grep ! \ + "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + .git/patch-*.trace +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'apply and am retain pending backoff history across same-path patches' ' + for operation in apply am + do + setup_backoff_bound_proof "watch-backoff-patch-$operation" && + ( + cd "watch-backoff-patch-$operation" && + checkpoint=$(cat .git/checkpoints) && + make_backoff_patch_series same-path && + cp .git/patch-base.commit .git/patch-previous.commit && + record_authenticated_backoff_marker && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/patch-noop.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/patch-noop.actual && + test_cmp .git/prime.expect .git/patch-noop.actual && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <.git/patch-noop.trace && + assert_backoff_history_unchanged .git "$checkpoint" && + test_write_lines visible >visible && + for step in first second + do + case "$operation" in + apply) set -- git apply --index ".git/patch-$step.diff" ;; + am) set -- git am ".git/patch-$step.mbox" ;; + esac && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/patch-$step.trace" \ + "$@" >".git/patch-$step.out" && + cp .git/index ".git/patch-after-$step.index" && + snapshot_backoff_index_identity .git/index \ + >".git/patch-after-$step.identity" && + assert_backoff_pending_proof .git/index.before-backoff \ + ".git/patch-after-$step.index" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <".git/patch-$step.trace" && + assert_backoff_main_index_write ".git/patch-$step.trace" \ + "$PWD/.git/index" yes && + assert_backoff_checkpoint_unchanged .git "$checkpoint" && + assert_backoff_patch_tree ".git/patch-after-$step.index" \ + ".git/patch-$step" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >".git/patch-$step.head" && + if test "$operation" = am + then + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD^ >".git/patch-$step.parent" && + test_cmp .git/patch-previous.commit \ + ".git/patch-$step.parent" && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD^{tree} >".git/patch-$step.head-tree" && + test_cmp ".git/patch-$step.tree" \ + ".git/patch-$step.head-tree" && + cp ".git/patch-$step.head" .git/patch-previous.commit && + test_path_is_missing .git/rebase-apply + else + test_cmp .git/patch-base.commit ".git/patch-$step.head" + fi || return 1 + done && + test_cmp .git/patch-first.contents tracked && + test_cmp .git/patch-second.contents sibling && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/patch-status.expected && + test_grep "^? visible$" .git/patch-status.expected && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/patch-status.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/patch-status.actual && + test_cmp .git/patch-status.expected .git/patch-status.actual && + test_cmp_bin .git/patch-after-second.index .git/index && + assert_backoff_pending_proof .git/index.before-backoff .git/index && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/patch-status.trace && + recover_backoff_patch_history .git/patch-status.expected \ + .git/patch-second.tree + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'a structural patch revokes backoff history for the complete apply or am batch' ' + for operation in apply am + do + setup_backoff_bound_proof "watch-backoff-mixed-patch-$operation" && + ( + cd "watch-backoff-mixed-patch-$operation" && + make_backoff_patch_series mixed && + git -c core.fsmonitor=false --no-optional-locks \ + diff-tree --no-commit-id --name-status --no-renames \ + "$(cat .git/patch-base.commit)" \ + "$(cat .git/patch-first.commit)" \ + >.git/patch-first.names && + printf "M\ttracked\nA\tzz-created\n" >.git/patch-expected.names && + test_cmp .git/patch-expected.names .git/patch-first.names && + record_authenticated_backoff_marker && + test_write_lines visible >visible && + case "$operation" in + apply) set -- git apply --index .git/patch-first.diff ;; + am) set -- git am .git/patch-first.mbox ;; + esac && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/patch-first.trace" \ + "$@" >.git/patch-first.out && + cp .git/index .git/patch-after-first.index && + snapshot_backoff_index_identity .git/index \ + >.git/patch-after-first.identity && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <.git/patch-first.trace && + assert_backoff_main_index_write .git/patch-first.trace \ + "$PWD/.git/index" yes && + test_grep ! FSUC .git/patch-after-first.index && + test_grep ! "pending:" .git/patch-after-first.index && + if assert_backoff_full_proof .git/patch-after-first.index \ + >.git/patch-proof.out 2>.git/patch-proof.err + then + return 1 + else + : + fi && + assert_backoff_patch_tree .git/patch-after-first.index \ + .git/patch-first && + test_cmp .git/patch-first.contents tracked && + test_cmp .git/patch-created.contents zz-created && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD >.git/patch-first.head && + if test "$operation" = am + then + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD^ >.git/patch-first.parent && + test_cmp .git/patch-base.commit .git/patch-first.parent && + git -c core.fsmonitor=false --no-optional-locks \ + rev-parse HEAD^{tree} >.git/patch-first.head-tree && + test_cmp .git/patch-first.tree .git/patch-first.head-tree && + test_path_is_missing .git/rebase-apply + else + test_cmp .git/patch-base.commit .git/patch-first.head + fi && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + --no-optional-locks status --porcelain=v2 \ + >.git/patch-status.expected && + test_grep "^? visible$" .git/patch-status.expected && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/patch-status.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >.git/patch-status.actual && + test_cmp .git/patch-status.expected .git/patch-status.actual && + test_cmp_bin .git/patch-after-first.index .git/index && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/patch-status.trace && + recover_backoff_patch_history .git/patch-status.expected \ + .git/patch-first.tree + ) || return 1 + done +' + test_done From 2d2e9d0c52451691474a0c3c237df9c0428c796b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:11:11 -0500 Subject: [PATCH 388/403] commit: retain suspended history across successful hooks 0d366ab7f3 (fsmonitor: recognize the main index in commit hooks, 2026-08-17) keeps temporary indexes from admitting main-index history. That distinction is necessary, but a normal commit passes its real index lock to pre-commit and publishes that same file on success. A hook which refreshes the temporary index can therefore discard the suspended history which the parent was about to publish. Capture a historical-only checkpoint while the parent owns the main index lock, and seal the entries actually written before running any post-index-change hook. At successful publication, restore the history only if the hook's final entries, canonical source, configuration, and attribute inputs still match. Rewrite the pinned hook output through a nested lock and the normal index serializer, preserving its stat data and the usual racy-entry handling. The restored tracked bitmap remains entirely dirty, and the untracked cache still requires revalidation. No current clean proof is granted. Partial commits and hooks which change entries or semantic inputs keep their own output unchanged. Cover successful no-op and refresh hooks, partial commits, and real hook mutations in both worktree layouts. --- builtin/commit.c | 17 +- clean-status-history.c | 289 ++++++++++++++ clean-status.h | 25 ++ read-cache-ll.h | 9 + read-cache.c | 113 +++++- t/t7536-fsmonitor-watch-limit-backoff.sh | 465 +++++++++++++++++++++++ 6 files changed, 909 insertions(+), 9 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 86211af7996fc0..e28bf152c3cce0 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -118,6 +118,7 @@ static const char *color_status_slots[] = { static const char *use_message_buffer; static struct lock_file index_lock; /* real index */ static struct lock_file false_lock; /* used only for partial commits */ +static struct clean_status_commit_checkpoint *commit_checkpoint; static enum { COMMIT_AS_IS = 1, COMMIT_NORMAL, @@ -250,6 +251,12 @@ static void status_init_config_with_clean_digest( s->hints = advice_enabled(ADVICE_STATUS_HINTS); /* must come after repo_config() */ } +static void release_commit_checkpoint(void) +{ + clean_status_release_commit_checkpoint(commit_checkpoint); + commit_checkpoint = NULL; +} + static void rollback_index_files(void) { switch (commit_style) { @@ -263,6 +270,7 @@ static void rollback_index_files(void) rollback_lock_file(&false_lock); break; } + release_commit_checkpoint(); } static int commit_index_files(void) @@ -273,6 +281,8 @@ static int commit_index_files(void) case COMMIT_AS_IS: break; /* nothing to do */ case COMMIT_NORMAL: + restore_locked_index_for_commit(the_repository->index, + &index_lock, commit_checkpoint); err = commit_lock_file(&index_lock); break; case COMMIT_PARTIAL: @@ -280,6 +290,7 @@ static int commit_index_files(void) rollback_lock_file(&false_lock); break; } + release_commit_checkpoint(); return err; } @@ -502,7 +513,10 @@ static const char *prepare_index(const char **argv, const char *prefix, refresh_cache_or_die(refresh_flags); cache_tree_update(the_repository->index, WRITE_TREE_SILENT); - if (write_locked_index(the_repository->index, &index_lock, 0)) + if (is_status ? + write_locked_index(the_repository->index, &index_lock, 0) : + write_locked_index_for_commit(the_repository->index, &index_lock, + &commit_checkpoint)) die(_("unable to write new index file")); commit_style = COMMIT_NORMAL; ret = get_lock_file_path(&index_lock); @@ -2583,6 +2597,7 @@ int cmd_commit(int argc, NULL, NULL, NULL, NULL); cleanup: + release_commit_checkpoint(); wt_status_collect_free_buffers(&s); free_commit_extra_headers(extra); commit_list_free(parents); diff --git a/clean-status-history.c b/clean-status-history.c index 5ce8afbb3e821e..207b7cee350fe7 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -13,6 +13,7 @@ #include "fsmonitor-settings.h" #include "hash-framing.h" #include "hex.h" +#include "lockfile.h" #include "read-cache-ll.h" #include "replace-object.h" #include "repository.h" @@ -2100,3 +2101,291 @@ int clean_status_transfer_current_proof_if_semantically_same_index( return transferred; } + +struct clean_status_commit_checkpoint { + struct repository *repo; + struct lock_file *lock; + struct clean_status_index_snapshot source; + struct clean_status_index_snapshot written; + struct attr_source_snapshot *attrs; + struct strbuf config; + struct strbuf untracked; + char *main_path; + char *token; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char tracked_policy_hash[GIT_MAX_RAWSZ]; + unsigned char logical_hash[GIT_MAX_RAWSZ]; + int writer_fd; + unsigned sealed : 1; +}; + +static int commit_checkpoint_source_matches( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock) +{ + struct clean_status_config_digest digest; + struct repository *repo; + char *destination; + int matches; + + if (!checkpoint || !lock || checkpoint->lock != lock || + !is_lock_file_locked(lock)) + return 0; + repo = checkpoint->repo; + if (!fsm_settings__is_watch_limit_backoff(repo) || + get_alternate_index_output() || + !clean_status_index_path_is_main(repo, checkpoint->main_path) || + repo_has_replace_refs_uncached(repo) || + !clean_status_index_snapshot_still_matches_path( + &checkpoint->source, checkpoint->main_path, repo->hash_algo) || + !attr_source_snapshot_matches_repository(repo, checkpoint->attrs) || + clean_status_config_read_repository(repo, &digest) || + !digest.finalized || + memcmp(digest.hash, checkpoint->config_hash, repo->hash_algo->rawsz) || + memcmp(digest.semantic_hash, checkpoint->semantic_hash, + repo->hash_algo->rawsz) || + memcmp(digest.tracked_policy_hash, checkpoint->tracked_policy_hash, + repo->hash_algo->rawsz)) + return 0; + destination = get_locked_file_path(lock); + matches = !fspathcmp(destination, checkpoint->main_path); + free(destination); + return matches; +} + +void clean_status_release_commit_checkpoint( + struct clean_status_commit_checkpoint *checkpoint) +{ + if (!checkpoint) + return; + clean_status_index_snapshot_release(&checkpoint->source); + clean_status_index_snapshot_release(&checkpoint->written); + if (checkpoint->writer_fd >= 0) + close(checkpoint->writer_fd); + attr_source_snapshot_free(checkpoint->attrs); + strbuf_release(&checkpoint->config); + strbuf_release(&checkpoint->untracked); + free(checkpoint->main_path); + free(checkpoint->token); + free(checkpoint); +} + +struct clean_status_commit_checkpoint *clean_status_capture_commit_checkpoint( + struct index_state *istate, struct lock_file *lock) +{ +#if defined(F_DUPFD_CLOEXEC) && defined(F_GETFL) && defined(O_ACCMODE) + struct clean_status_commit_checkpoint *checkpoint; + const struct clean_status_state *state; + const struct attr_fingerprint *attrs; + struct fsmonitor_clean_proof proof; + struct stat st; + const char *suffix, *pending; + int fd, flags; + const uint32_t historical = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + if (!istate || !istate->repo || !lock) + return NULL; + state = istate->clean_status; + if (!clean_status_fsmonitor_backoff_suspended(istate) || + !clean_status_fsmonitor_semantic_baseline_pending(istate) || + !is_lock_file_locked(lock) || get_alternate_index_output() || + !fstat_is_reliable() || + getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || getenv(DB_ENVIRONMENT) || + getenv(ALTERNATE_DB_ENVIRONMENT) || + istate != istate->repo->index || istate->resolve_undo || + istate->split_index || istate->sparse_index != INDEX_EXPANDED || + repo_config_values(istate->repo)->apply_sparse_checkout || + !istate->repo->config_values_private_.trust_ctime || + !istate->repo->config_values_private_.check_stat || + (istate->cache_changed & (CE_ENTRY_ADDED | CE_ENTRY_REMOVED)) || + !istate->fsmonitor_token_valid || + !istate->fsmonitor_untracked_extension_seen || + istate->fsmonitor_untracked_extension_invalid || + istate->fsmonitor_legacy_untracked_fallback || + !istate->fsmonitor_untracked_token || + !skip_prefix(istate->fsmonitor_last_update, "builtin:", &suffix) || + !*suffix || !strcmp(suffix, "fake") || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.current_invalidated || state->manifest.global_fallback || + state->manifest.current_flags != historical || + !istate->untracked || !istate->untracked->root || + !istate->untracked->root->valid || + !istate->untracked->fsmonitor_revalidation || + istate->untracked->fsmonitor_dirty_paths.len) + return NULL; + if (strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token) && + (!skip_prefix(istate->fsmonitor_untracked_token, "pending:", &pending) || + strcmp(suffix, pending))) + return NULL; + fd = get_lock_file_fd(lock); + flags = fd < 0 ? -1 : fcntl(fd, F_GETFL); + if (flags < 0 || (flags & O_ACCMODE) != O_RDWR || + fstat(fd, &st) || !S_ISREG(st.st_mode) || + st.st_nlink != 1 || st.st_uid != geteuid()) + return NULL; + + CALLOC_ARRAY(checkpoint, 1); + checkpoint->repo = istate->repo; + checkpoint->lock = lock; + checkpoint->source.fd = -1; + checkpoint->written.fd = -1; + checkpoint->writer_fd = -1; + strbuf_init(&checkpoint->config, 0); + strbuf_init(&checkpoint->untracked, 0); + checkpoint->main_path = get_locked_file_path(lock); + checkpoint->token = xstrdup(istate->fsmonitor_last_update); + memcpy(checkpoint->config_hash, state->current_config_hash, + istate->repo->hash_algo->rawsz); + memcpy(checkpoint->semantic_hash, state->current_semantic_hash, + istate->repo->hash_algo->rawsz); + memcpy(checkpoint->tracked_policy_hash, state->current_tracked_policy_hash, + istate->repo->hash_algo->rawsz); + + /* The first write replaces istate->oid, so pin its canonical source now. */ + if (clean_status_index_snapshot_pin_proof_epoch(&checkpoint->source, istate) || + fstat(checkpoint->source.fd, &st) || st.st_uid != geteuid() || + attr_source_snapshot_repository(istate->repo, &checkpoint->attrs)) + goto fail; + attrs = attr_source_snapshot_fingerprint(checkpoint->attrs); + if (!attrs || + attrs->sources_present != state->current_attr_sources_present || + memcmp(attrs->content_hash, state->current_attr_hash, + istate->repo->hash_algo->rawsz) || + !commit_checkpoint_source_matches(checkpoint, lock)) + goto fail; + clean_status_write_fsmonitor_config(&checkpoint->config, istate); + if (fsmonitor_clean_proof_parse(&proof, checkpoint->config.buf, + checkpoint->config.len, istate->repo->hash_algo) || + proof.flags != historical || + proof.token_len != strlen(checkpoint->token) || + memcmp(proof.token, checkpoint->token, proof.token_len)) + goto fail; + write_untracked_extension(&checkpoint->untracked, istate->untracked); + checkpoint->writer_fd = fcntl(fd, F_DUPFD_CLOEXEC, 0); + if (checkpoint->writer_fd < 0) + goto fail; + return checkpoint; +fail: + clean_status_release_commit_checkpoint(checkpoint); + return NULL; +#else + (void)istate; + (void)lock; + return NULL; +#endif +} + +void clean_status_record_commit_checkpoint( + struct clean_status_commit_checkpoint *checkpoint, + struct index_state *istate, struct lock_file *lock) +{ + struct index_state written = INDEX_STATE_INIT(istate->repo); + struct clean_status_identity identity; + struct stat st; + + if (!checkpoint || checkpoint->sealed || checkpoint->writer_fd < 0) + return; + /* Run before post-index-change can replace the close-only output. */ + if (checkpoint->repo != istate->repo || + !clean_status_fsmonitor_backoff_suspended(istate) || + !commit_checkpoint_source_matches(checkpoint, lock) || + fstat(checkpoint->writer_fd, &st) || st.st_uid != geteuid() || + clean_status_identity_from_stat(&identity, &st) || + clean_status_index_snapshot_open_allow_null_checksum( + &checkpoint->written, get_lock_file_path(lock), + istate->repo->hash_algo) || + !clean_status_identity_equal(&identity, &checkpoint->written.identity) || + checkpoint->written.version != istate->version || + checkpoint->written.cache_nr != istate->cache_nr || + !oideq(&checkpoint->written.checksum, &istate->oid) || + read_index_entries_from_fd(&written, checkpoint->writer_fd) || + clean_status_index_logical_digest(&written, checkpoint->logical_hash) || + !clean_status_index_snapshot_still_matches_path( + &checkpoint->written, get_lock_file_path(lock), + istate->repo->hash_algo)) + goto done; + checkpoint->sealed = 1; +done: + close(checkpoint->writer_fd); + checkpoint->writer_fd = -1; + if (!checkpoint->sealed) + clean_status_index_snapshot_release(&checkpoint->written); + release_index(&written); +} + +int clean_status_commit_checkpoint_changed( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock) +{ + return checkpoint && checkpoint->sealed && lock && + checkpoint->lock == lock && + is_lock_file_locked(lock) && + !clean_status_index_snapshot_still_matches_path( + &checkpoint->written, get_lock_file_path(lock), + checkpoint->repo->hash_algo); +} + +int clean_status_commit_checkpoint_still_valid( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock) +{ + return checkpoint && checkpoint->sealed && + commit_checkpoint_source_matches(checkpoint, lock); +} + +int clean_status_prepare_commit_checkpoint_restore( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock, const struct index_state *current, + struct index_state *replacement, int fd) +{ + struct clean_status_state *state; + unsigned char hash[GIT_MAX_RAWSZ]; + const char *suffix; + + if (!current || !replacement || + !clean_status_commit_checkpoint_still_valid(checkpoint, lock) || + current->repo != checkpoint->repo || current != current->repo->index || + current->resolve_undo || + clean_status_index_logical_digest(current, hash) || + memcmp(hash, checkpoint->logical_hash, current->repo->hash_algo->rawsz) || + read_index_entries_from_fd(replacement, fd) || + clean_status_index_logical_digest(replacement, hash) || + memcmp(hash, checkpoint->logical_hash, current->repo->hash_algo->rawsz)) + return 0; + + replacement->untracked = read_untracked_extension( + checkpoint->untracked.buf, checkpoint->untracked.len); + if (!replacement->untracked || !replacement->untracked->root || + !replacement->untracked->root->valid || + !skip_prefix(checkpoint->token, "builtin:", &suffix) || !*suffix) + return 0; + clean_status_read_fsmonitor_config(replacement, checkpoint->config.buf, + checkpoint->config.len); + clean_status_attach_config(replacement); + state = replacement->clean_status; + if (!state || !state->disk_config_valid || state->disk_config_invalid || + !state->current_config_valid || !state->current_attr_valid) + return 0; + clean_status_manifest_adopt_disk(&state->manifest); + state->backoff_token = xstrdup(checkpoint->token); + state->backoff_suspended = 1; + state->semantic_baseline_pending = 1; + state->config_mismatch = 1; + state->filter_scope_valid = 0; + replacement->fsmonitor_extension_seen = 1; + replacement->fsmonitor_token_valid = 1; + replacement->fsmonitor_last_update = xstrdup(checkpoint->token); + replacement->fsmonitor_untracked_extension_seen = 1; + replacement->fsmonitor_untracked_token = xstrfmt("pending:%s", suffix); + replacement->untracked->fsmonitor_revalidation = 1; + replacement->untracked->use_fsmonitor = 0; + replacement->cache_changed |= FSMONITOR_CHANGED | UNTRACKED_CHANGED; + for (size_t i = 0; i < replacement->cache_nr; i++) + replacement->cache[i]->ce_flags &= + ~(CE_FSMONITOR_VALID | CE_UPTODATE); + return 1; +} diff --git a/clean-status.h b/clean-status.h index 17741df3de6621..f0a9c189174d43 100644 --- a/clean-status.h +++ b/clean-status.h @@ -9,6 +9,7 @@ struct attr_source_snapshot; struct clean_status_progress; struct clean_status_proof_epoch; struct clean_status_index_snapshot; +struct clean_status_commit_checkpoint; struct lock_file; struct repository; struct stat; @@ -160,6 +161,30 @@ int clean_status_transfer_current_proof_if_same_index( int clean_status_transfer_current_proof_if_semantically_same_index( struct index_state *dst, const struct index_state *src); +/* + * Historical-only state for a parent-owned, uncommitted main-index write. + * Capture before the first write; record its closed output before hooks. + * The caller releases the checkpoint and any replacement index state. The + * entries-only restore reader borrows fd and grants no current clean proof. + */ +struct clean_status_commit_checkpoint *clean_status_capture_commit_checkpoint( + struct index_state *istate, struct lock_file *lock); +void clean_status_record_commit_checkpoint( + struct clean_status_commit_checkpoint *checkpoint, + struct index_state *istate, struct lock_file *lock); +int clean_status_commit_checkpoint_changed( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock); +int clean_status_commit_checkpoint_still_valid( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock); +int clean_status_prepare_commit_checkpoint_restore( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock, const struct index_state *current, + struct index_state *replacement, int fd); +void clean_status_release_commit_checkpoint( + struct clean_status_commit_checkpoint *checkpoint); + void clean_status_release(struct index_state *istate); #endif /* CLEAN_STATUS_H */ diff --git a/read-cache-ll.h b/read-cache-ll.h index fc5fcb7848bf53..35f1e97ba6f147 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -32,6 +32,7 @@ struct cache_entry { }; struct clean_status_index_write_receipt; +struct clean_status_commit_checkpoint; struct clean_status_proof_epoch; struct preload_bulk_stat_update; @@ -361,6 +362,14 @@ int is_index_unborn(struct index_state *); */ int write_locked_index(struct index_state *, struct lock_file *lock, unsigned flags); +/* Commit's close-only main-index write and optional historical-only repair. */ +int write_locked_index_for_commit( + struct index_state *, struct lock_file *, + struct clean_status_commit_checkpoint **); +void restore_locked_index_for_commit( + struct index_state *, struct lock_file *, + const struct clean_status_commit_checkpoint *); + /* * Like repo_update_index_if_able(), with an optional receipt for the canonical * file actually written. The receipt must be initialized by the caller and diff --git a/read-cache.c b/read-cache.c index 63f2e69bd5c90f..d60f9b177bedef 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3353,7 +3353,8 @@ int has_racy_timestamp(struct index_state *istate) static int write_locked_index_with_receipt( struct index_state *istate, struct lock_file *lock, - unsigned flags, struct clean_status_index_write_receipt *receipt); + unsigned flags, struct clean_status_index_write_receipt *receipt, + struct clean_status_commit_checkpoint *checkpoint); void repo_update_index_if_able_with_receipt( struct repository *repo, struct lock_file *lockfile, @@ -3365,7 +3366,7 @@ void repo_update_index_if_able_with_receipt( has_racy_timestamp(repo->index)) && repo_verify_index(repo)) write_locked_index_with_receipt(repo->index, lockfile, - COMMIT_LOCK, receipt); + COMMIT_LOCK, receipt, NULL); else rollback_lock_file(lockfile); } @@ -3846,7 +3847,8 @@ static int commit_locked_index(struct lock_file *lk) static int do_write_locked_index( struct index_state *istate, struct lock_file *lock, unsigned flags, enum write_extensions write_extensions, - struct clean_status_index_write_receipt *receipt) + struct clean_status_index_write_receipt *receipt, + struct clean_status_commit_checkpoint *checkpoint) { int ret; int was_full = istate->sparse_index == INDEX_EXPANDED; @@ -3896,6 +3898,8 @@ static int do_write_locked_index( else clean_status_index_write_receipt_release(receipt); } + if (!ret && checkpoint && !(flags & COMMIT_LOCK)) + clean_status_record_commit_checkpoint(checkpoint, istate, lock); run_hooks_l(the_repository, "post-index-change", istate->updated_workdir ? "1" : "0", @@ -3913,7 +3917,7 @@ static int write_split_index(struct index_state *istate, int ret; prepare_to_write_split_index(istate); ret = do_write_locked_index(istate, lock, flags, WRITE_ALL_EXTENSIONS, - NULL); + NULL, NULL); finish_writing_split_index(istate); return ret; } @@ -4057,7 +4061,8 @@ static int too_many_not_shared_entries(struct index_state *istate) static int write_locked_index_with_receipt( struct index_state *istate, struct lock_file *lock, - unsigned flags, struct clean_status_index_write_receipt *receipt) + unsigned flags, struct clean_status_index_write_receipt *receipt, + struct clean_status_commit_checkpoint *checkpoint) { int new_shared_index, ret, test_split_index_env; struct split_index *si = istate->split_index; @@ -4090,7 +4095,7 @@ static int write_locked_index_with_receipt( (istate->cache_changed & ~EXTMASK)) { ret = do_write_locked_index(istate, lock, flags, ~WRITE_SPLIT_INDEX_EXTENSION, - receipt); + receipt, checkpoint); goto out; } @@ -4121,7 +4126,7 @@ static int write_locked_index_with_receipt( if (!temp) { ret = do_write_locked_index(istate, lock, flags, ~WRITE_SPLIT_INDEX_EXTENSION, - receipt); + receipt, checkpoint); goto out; } ret = write_shared_index(istate, &temp, flags); @@ -4154,7 +4159,99 @@ static int write_locked_index_with_receipt( int write_locked_index(struct index_state *istate, struct lock_file *lock, unsigned flags) { - return write_locked_index_with_receipt(istate, lock, flags, NULL); + return write_locked_index_with_receipt(istate, lock, flags, NULL, NULL); +} + +int write_locked_index_for_commit( + struct index_state *istate, struct lock_file *lock, + struct clean_status_commit_checkpoint **checkpoint) +{ + struct clean_status_commit_checkpoint *candidate; + int ret; + + clean_status_release_commit_checkpoint(*checkpoint); + *checkpoint = NULL; + candidate = clean_status_capture_commit_checkpoint(istate, lock); + ret = write_locked_index_with_receipt(istate, lock, 0, NULL, candidate); + if (ret) + clean_status_release_commit_checkpoint(candidate); + else + *checkpoint = candidate; + return ret; +} + +void restore_locked_index_for_commit( + struct index_state *istate, struct lock_file *lock, + const struct clean_status_commit_checkpoint *checkpoint) +{ + struct repository *repo = istate->repo; + struct index_state replacement = INDEX_STATE_INIT(repo); + struct clean_status_index_snapshot current = { .fd = -1 }; + struct lock_file rewrite = LOCK_INIT; + struct strbuf cache_tree_data = STRBUF_INIT; + struct stat st; + char *destination = NULL; + const char *path; + int ret; + + if (!clean_status_commit_checkpoint_changed(checkpoint, lock) || + !clean_status_commit_checkpoint_still_valid(checkpoint, lock)) + return; + path = get_lock_file_path(lock); + destination = get_locked_file_path(lock); + if (!clean_status_index_path_is_main(repo, destination) || + clean_status_index_snapshot_open_allow_null_checksum( + ¤t, path, repo->hash_algo) || + fstat(current.fd, &st) || st.st_uid != geteuid() || + hold_lock_file_for_update(&rewrite, path, LOCK_NO_DEREF) < 0 || + !clean_status_index_snapshot_still_matches_path( + ¤t, path, repo->hash_algo) || + !clean_status_prepare_commit_checkpoint_restore( + checkpoint, lock, istate, &replacement, current.fd)) + goto done; + + /* The logical entries are equal, so the parent's cache tree is reusable. */ + if (istate->cache_tree) { + cache_tree_write(&cache_tree_data, istate->cache_tree); + replacement.cache_tree = cache_tree_read( + cache_tree_data.buf, cache_tree_data.len); + } + + /* + * Only this parent owns the final canonical destination. A nested lock + * preserves the hook's file on rejection or I/O failure, and avoids + * reopening an untrusted pathname with O_TRUNC. The regular serializer + * retains the post-hook stat data and performs its usual racy smudging. + */ + repo->index = &replacement; + if (!untracked_cache_preserve_for_revalidation(&replacement)) { + repo->index = istate; + goto done; + } + fill_fsmonitor_bitmap(&replacement); + trace2_region_enter_printf("index", "do_write_index", repo, + "%s", get_lock_file_path(&rewrite)); + ret = do_write_index(&replacement, rewrite.tempfile, + ~WRITE_SPLIT_INDEX_EXTENSION, 0, destination); + trace2_region_leave_printf("index", "do_write_index", repo, + "%s", get_lock_file_path(&rewrite)); + repo->index = istate; + if (ret || + !clean_status_index_snapshot_still_matches_path( + ¤t, path, repo->hash_algo) || + !clean_status_commit_checkpoint_still_valid(checkpoint, lock) || + commit_lock_file(&rewrite)) + goto done; + + /* The original logical write already ran post-index-change. */ + trace2_data_intmax("fsmonitor", repo, + "history/commit-backoff-restored", 1); +done: + rollback_lock_file(&rewrite); + clean_status_index_snapshot_release(¤t); + strbuf_release(&cache_tree_data); + release_index(&replacement); + free(destination); } /* diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 165da244e76e17..a275dfec2f35c2 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -2176,4 +2176,469 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done ' +# A successful COMMIT_NORMAL publishes the lockfile handed to pre-commit. +# The older hook tests abort before that publication. Keep the immediate +# on-disk result separate from any later status which could repair it. +backoff_commit_index () ( + selected_index=$1 && + shift && + sane_unset GIT_TEST_PRELOAD_INDEX_BULK \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH && + GIT_INDEX_FILE="$selected_index" \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.preloadIndex=false -c core.preloadIndexBulk=false \ + --no-optional-locks "$@" +) + +backoff_commit_entries () { + # Include the stage, mode, object ID, name, and assume/skip-worktree flags. + backoff_commit_index "$1" ls-files --stage -v -z +} + +assert_backoff_commit_unbound () { + test_grep ! "pending:" "$1" && + if assert_backoff_full_proof "$1" >"$2.out" 2>"$2.err" + then + echo "unexpected complete proof in $1" >&2 && + return 1 + else + : + fi +} + +install_backoff_successful_commit_hook () { + test_hook -C "$1" pre-commit <<-\EOF + set -eu + test -n "$GIT_INDEX_FILE" + evidence=$BACKOFF_HOOK_EVIDENCE + main=$BACKOFF_HOOK_MAIN_INDEX + helper=$BACKOFF_HOOK_IDENTITY_HELPER + reject=$BACKOFF_HOOK_REJECT_HELPER + printf "%s\n" "$GIT_INDEX_FILE" >"$evidence/index.env" + selected=$(perl "$reject" temporary "$BACKOFF_HOOK_STYLE" \ + "$GIT_INDEX_FILE" "$main") + printf "%s\n" "$selected" >"$evidence/selected.path" + perl "$helper" identity "$main" >"$evidence/main.identity.hook-before" + cp "$main" "$evidence/main.in-hook.before" + cp "$selected" "$evidence/selected.before" + if test "$BACKOFF_HOOK_STYLE" = partial + then + perl "$reject" temporary all "$main.lock" "$main" \ + >"$evidence/real-lock.path" + cp "$main.lock" "$evidence/real-lock.before" + fi + : >"$evidence/pre-mutation-refresh.trace" + case "$BACKOFF_HOOK_ACTION" in + noop | refresh) + : + ;; + *) + GIT_TRACE2_EVENT="$evidence/pre-mutation-refresh.trace" \ + git add --refresh -- sibling + cp "$selected" "$evidence/selected.after-refresh" + ;; + esac + : >"$evidence/hook.trace" + GIT_TRACE2_EVENT="$evidence/hook.trace" + export GIT_TRACE2_EVENT + case "$BACKOFF_HOOK_ACTION" in + noop) + : + ;; + refresh) + git add --refresh -- "$BACKOFF_HOOK_REFRESH_PATH" + ;; + content) + printf "%s\n" hook-content >sibling + git add sibling + ;; + add-new) + printf "%s\n" hook-added >created + git add created + ;; + remove) + git rm sibling + ;; + rename) + git mv sibling renamed + ;; + mode) + git update-index --chmod=+x sibling + ;; + assume-unchanged) + git update-index --assume-unchanged sibling + ;; + info-attributes) + printf "%s\n" "sibling -text" >"$BACKOFF_HOOK_ATTRIBUTES" + git add --refresh -- sibling + ;; + *) + exit 2 + ;; + esac + perl "$reject" temporary "$BACKOFF_HOOK_STYLE" \ + "$GIT_INDEX_FILE" "$main" >"$evidence/selected.path.after" + cp "$selected" "$evidence/selected.after" + cp "$main" "$evidence/main.in-hook.after" + perl "$helper" identity "$main" >"$evidence/main.identity.hook-after" + if test "$BACKOFF_HOOK_STYLE" = partial + then + cp "$main.lock" "$evidence/real-lock.after" + fi + printf "%s\n" success >"$evidence/completed" + exit 0 + EOF +} + +setup_backoff_successful_commit_pair () { + setup_backoff_hook_pair "$1" && + common=$(git -C "$1-main" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + write_backoff_hook_identity_helper "$common/hook-index.pl" && + write_backoff_rejected_index_helper "$common/rejected-index.pl" && + install_backoff_successful_commit_hook "$1-main" +} + +make_backoff_successful_commit_oracles () ( + style=$1 && + action=$2 && + evidence=$3 && + cp "$evidence/main.seed" "$evidence/oracle-main.index" && + backoff_commit_index "$evidence/oracle-main.index" add -u && + case "$style" in + all) + cp "$evidence/oracle-main.index" "$evidence/oracle-commit.index" + ;; + partial) + backoff_commit_index "$evidence/oracle-commit.index" read-tree HEAD && + backoff_commit_index "$evidence/oracle-commit.index" add -- tracked + ;; + esac && + backoff_commit_entries "$evidence/oracle-commit.index" \ + >"$evidence/expected-pre-hook.entries" && + case "$action" in + noop | refresh | info-attributes) + : + ;; + content | add-new) + case "$action" in + content) contents=hook-content target=sibling ;; + add-new) contents=hook-added target=created ;; + esac && + test_write_lines "$contents" >"$evidence/expected-content" && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ + <"$evidence/expected-content") && + backoff_commit_index "$evidence/oracle-commit.index" \ + update-index --add --cacheinfo "100644,$oid,$target" + ;; + remove) + backoff_commit_index "$evidence/oracle-commit.index" \ + update-index --force-remove sibling + ;; + rename) + oid=$(backoff_commit_index "$evidence/oracle-commit.index" \ + rev-parse :sibling) && + backoff_commit_index "$evidence/oracle-commit.index" \ + update-index --force-remove sibling && + backoff_commit_index "$evidence/oracle-commit.index" \ + update-index --add --cacheinfo "100644,$oid,renamed" + ;; + mode) + backoff_commit_index "$evidence/oracle-commit.index" \ + update-index --chmod=+x sibling + ;; + assume-unchanged) + backoff_commit_index "$evidence/oracle-commit.index" \ + update-index --assume-unchanged sibling + ;; + *) + return 1 + ;; + esac && + backoff_commit_index "$evidence/oracle-commit.index" write-tree \ + >"$evidence/expected-commit.tree" && + backoff_commit_entries "$evidence/oracle-commit.index" \ + >"$evidence/expected-commit.entries" && + case "$action" in + noop | refresh | info-attributes) + test_cmp_bin "$evidence/expected-pre-hook.entries" \ + "$evidence/expected-commit.entries" + ;; + *) + ! test_cmp_bin "$evidence/expected-pre-hook.entries" \ + "$evidence/expected-commit.entries" + ;; + esac && + if test "$style" = all + then + cp "$evidence/oracle-commit.index" "$evidence/oracle-main.index" + else + : + fi && + backoff_commit_index "$evidence/oracle-main.index" write-tree \ + >"$evidence/expected-main.tree" && + backoff_commit_entries "$evidence/oracle-main.index" \ + >"$evidence/expected-main.entries" && + if test "$style" = partial + then + ! test_cmp "$evidence/expected-commit.tree" "$evidence/expected-main.tree" + else + test_cmp "$evidence/expected-commit.tree" "$evidence/expected-main.tree" + fi +) + +check_backoff_successful_commit () ( + prefix=$1 && + style=$2 && + action=$3 && + kind=$4 && + common=$5 && + case "$kind" in + main) + other_gitdir=$(git -C "$prefix-linked" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) + ;; + linked) + other_gitdir=$common + ;; + esac && + cd "$prefix-$kind" && + sane_unset GIT_INDEX_FILE GIT_TEST_PRELOAD_INDEX_BULK \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + main_index="$gitdir/index" && + checkpoint=$(cat "$gitdir/checkpoints") && + # Keep linked-worktree evidence in the common gitdir after its cleanup. + evidence="$common/successful-$style-$action-$kind" && + mkdir "$evidence" && + cp "$main_index" "$evidence/main.seed" && + cp "$checkpoint" "$evidence/checkpoint.before" && + cp "$other_gitdir/index" "$evidence/other-index.before" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.before" && + snapshot_backoff_index_identity "$other_gitdir/index" \ + >"$evidence/other-index.identity.before" && + assert_backoff_full_proof "$evidence/main.seed" && + test_path_is_missing "$common/info/attributes" && + test_write_lines worktree-change >tracked && + make_backoff_successful_commit_oracles "$style" "$action" "$evidence" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.before" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + test_path_is_file "$gitdir/fsmonitor--daemon.inotify-limit" && + case "$style" in + all) refresh_path=sibling && set -- -a ;; + partial) refresh_path=tracked && set -- -- tracked ;; + esac && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$evidence/commit.trace" \ + BACKOFF_HOOK_STYLE="$style" BACKOFF_HOOK_ACTION="$action" \ + BACKOFF_HOOK_EVIDENCE="$evidence" BACKOFF_HOOK_MAIN_INDEX="$main_index" \ + BACKOFF_HOOK_IDENTITY_HELPER="$common/hook-index.pl" \ + BACKOFF_HOOK_REJECT_HELPER="$common/rejected-index.pl" \ + BACKOFF_HOOK_REFRESH_PATH="$refresh_path" \ + BACKOFF_HOOK_ATTRIBUTES="$common/info/attributes" \ + git commit -qm "successful $style $action hook" "$@" \ + >"$evidence/commit.out" && + # This must be the first observation after the successful commit. + cp "$main_index" "$evidence/index.published" && + snapshot_backoff_index_identity "$main_index" \ + >"$evidence/index.published.identity" && + test_grep "^success$" "$evidence/completed" && + test_cmp "$evidence/selected.path" "$evidence/selected.path.after" && + test_cmp_bin "$evidence/main.seed" "$evidence/main.in-hook.before" && + test_cmp_bin "$evidence/main.seed" "$evidence/main.in-hook.after" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.hook-before" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.hook-after" && + ! test_cmp "$evidence/main.identity.before" "$evidence/index.published.identity" && + test_cmp_bin "$evidence/checkpoint.before" "$checkpoint" && + test_path_is_missing "$main_index.lock" && + selected=$(cat "$evidence/selected.path") && + test_path_is_missing "$selected" && + test_path_is_missing "$selected.lock" && + find "$gitdir" -maxdepth 1 -name "next-index-*.lock" \ + >"$evidence/remaining-temporary-indexes" && + test_must_be_empty "$evidence/remaining-temporary-indexes" && + backoff_commit_entries "$evidence/selected.before" \ + >"$evidence/selected-before.entries" && + backoff_commit_entries "$evidence/selected.after" \ + >"$evidence/selected-after.entries" && + backoff_commit_entries "$evidence/index.published" \ + >"$evidence/published.entries" && + test_cmp_bin "$evidence/expected-pre-hook.entries" \ + "$evidence/selected-before.entries" && + test_cmp_bin "$evidence/expected-commit.entries" \ + "$evidence/selected-after.entries" && + test_cmp_bin "$evidence/expected-main.entries" "$evidence/published.entries" && + cp "$evidence/index.published" "$evidence/published-oracle.index" && + backoff_commit_index "$evidence/published-oracle.index" write-tree \ + >"$evidence/published.tree" && + test_cmp "$evidence/expected-main.tree" "$evidence/published.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} \ + >"$evidence/committed.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^ \ + >"$evidence/committed.parent" && + test_cmp "$evidence/expected-commit.tree" "$evidence/committed.tree" && + test_cmp "$evidence/head.before" "$evidence/committed.parent" && + extract_backoff_root_trace "$evidence/commit.trace" >"$evidence/commit.root.trace" && + test_trace2_data fsm_client settings/inotify-watch-limit-backoff 1 \ + <"$evidence/commit.root.trace" && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <"$evidence/commit.root.trace" && + ! test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/hook.trace" && + case "$style:$action" in + all:noop | all:refresh) + assert_backoff_pending_proof "$evidence/main.seed" "$evidence/selected.before" && + assert_backoff_pending_proof "$evidence/main.seed" "$evidence/index.published" && + if test "$action" = refresh + then + assert_backoff_rejected_index_trace "$evidence/hook.trace" && + assert_backoff_commit_unbound "$evidence/selected.after" \ + "$evidence/selected-unbound" && + test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/commit.root.trace" + else + test_cmp_bin "$evidence/selected.before" "$evidence/selected.after" + fi + ;; + partial:*) + # The false commit index and real lockfile have different trees. + # Only the latter is published; no normal-commit repair may run. + test_cmp_bin "$evidence/real-lock.before" "$evidence/real-lock.after" && + test_cmp_bin "$evidence/real-lock.after" "$evidence/index.published" && + assert_backoff_commit_unbound "$evidence/selected.after" \ + "$evidence/selected-unbound" && + ! test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/commit.root.trace" && + if test "$action" = refresh + then + assert_backoff_rejected_index_trace "$evidence/hook.trace" + else + : + fi + ;; + all:*) + assert_backoff_pending_proof "$evidence/main.seed" "$evidence/selected.before" && + assert_backoff_rejected_index_trace "$evidence/pre-mutation-refresh.trace" && + assert_backoff_commit_unbound "$evidence/selected.after-refresh" \ + "$evidence/after-refresh-unbound" && + # A real hook may serialize its own optional metadata. The + # parent must publish those exact bytes, not strengthen them. + test_cmp_bin "$evidence/selected.after" "$evidence/index.published" && + ! test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/commit.root.trace" + ;; + esac && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.preloadIndex=false -c core.preloadIndexBulk=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/status.expected" && + if test "$style" = partial + then + test_grep "^1 M\\. .* sibling$" "$evidence/status.expected" + else + : + fi && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TRACE2_EVENT="$evidence/status.trace" \ + git status --porcelain=v2 >"$evidence/status.actual" && + test_cmp "$evidence/status.expected" "$evidence/status.actual" && + test_cmp_bin "$evidence/index.published" "$main_index" && + snapshot_backoff_index_identity "$main_index" >"$evidence/index.after-status.identity" && + test_cmp "$evidence/index.published.identity" "$evidence/index.after-status.identity" && + assert_backoff_main_index_write "$evidence/status.trace" "$main_index" no && + test_cmp_bin "$evidence/checkpoint.before" "$checkpoint" && + # CE_VALID is itself a negative admission case. Check its publication + # first, then return the fixture to an eligible shape for real recovery. + if test "$action" = assume-unchanged + then + git -c core.fsmonitor=false -c core.untrackedCache=false \ + update-index --no-assume-unchanged sibling && + backoff_commit_index "$evidence/oracle-main.index" \ + update-index --no-assume-unchanged sibling + else + : + fi && + backoff_commit_entries "$evidence/oracle-main.index" \ + >"$evidence/expected-recovery.entries" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.preloadIndex=false -c core.preloadIndexBulk=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/recovery.expected" && + rm "$gitdir/fsmonitor--daemon.inotify-limit" && + GIT_INDEX_FILE="$main_index" GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$evidence/recovery.trace" \ + git status --porcelain=v2 >"$evidence/recovery.actual" && + test_cmp "$evidence/recovery.expected" "$evidence/recovery.actual" && + test_trace2_data fsm_client query/trivial-response 1 <"$evidence/recovery.trace" && + test_trace2_data fsmonitor token_closure/accepted 1 <"$evidence/recovery.trace" && + assert_backoff_full_proof "$main_index" && + cp "$main_index" "$evidence/index.recovered" && + cp "$main_index" "$evidence/recovered-oracle.index" && + backoff_commit_index "$evidence/recovered-oracle.index" write-tree \ + >"$evidence/recovered.tree" && + backoff_commit_entries "$evidence/index.recovered" \ + >"$evidence/recovered.entries" && + test_cmp "$evidence/expected-main.tree" "$evidence/recovered.tree" && + test_cmp_bin "$evidence/expected-recovery.entries" "$evidence/recovered.entries" && + test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" && + snapshot_backoff_index_identity "$other_gitdir/index" \ + >"$evidence/other-index.identity.after" && + test_cmp "$evidence/other-index.identity.before" "$evidence/other-index.identity.after" && + test_path_is_missing "$main_index.lock" && + test_grep ! "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/commit.trace" "$evidence/hook.trace" \ + "$evidence/pre-mutation-refresh.trace" \ + "$evidence/status.trace" "$evidence/recovery.trace" +) + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'successful normal commit hooks publish authenticated pending history' ' + sane_unset GIT_INDEX_FILE && + for action in noop refresh + do + prefix="watch-backoff-successful-all-$action" && + setup_backoff_successful_commit_pair "$prefix" && + for kind in main linked + do + check_backoff_successful_commit "$prefix" all "$action" "$kind" "$common" || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'successful partial commit hooks do not transplant the false index' ' + sane_unset GIT_INDEX_FILE && + for action in noop refresh + do + prefix="watch-backoff-successful-partial-$action" && + setup_backoff_successful_commit_pair "$prefix" && + for kind in main linked + do + check_backoff_successful_commit "$prefix" partial "$action" "$kind" "$common" || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'successful normal hooks cannot reuse history across changed entries or attributes' ' + sane_unset GIT_INDEX_FILE && + for action in content add-new remove rename mode assume-unchanged info-attributes + do + for kind in main linked + do + # The semantic-input control changes common info/attributes; + # every arm starts from its own genuinely primed pair. + prefix="watch-backoff-successful-$action-$kind" && + setup_backoff_successful_commit_pair "$prefix" && + check_backoff_successful_commit "$prefix" all "$action" "$kind" "$common" || + return 1 + done || return 1 + done +' + test_done From 27e12e730f36bafc0db9bbb1819c69191d3a1f16 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:14:03 -0500 Subject: [PATCH 389/403] t7536: allow the hook to remove staged content The successful-hook fixture deliberately starts with staged changes to sibling. Its removal hook must therefore pass -f to git rm; otherwise Git correctly refuses the removal before the publication assertions can run. Keep the independent expected tree and the no-restoration checks. --- t/t7536-fsmonitor-watch-limit-backoff.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index a275dfec2f35c2..0460092500c7f4 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -2257,7 +2257,7 @@ install_backoff_successful_commit_hook () { git add created ;; remove) - git rm sibling + git rm -f sibling ;; rename) git mv sibling renamed From 947ab15e48d07622c007636ab36d983e38ff8ee7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:26:53 -0500 Subject: [PATCH 390/403] fsmonitor: guard commit checkpoint ownership checks 2d2e9d0c52 (commit: retain suspended history across successful hooks, 2026-08-18) guards checkpoint capture with the required descriptor operations, but its recording and restoration paths still compile on Windows. Their direct calls to geteuid() break the native Windows build. Using Git for Windows' placeholder getuid() would not authenticate the owner of an index file. Keep the ownership predicate with the checkpoint code and enable it only where anchored file verification is available. Unsupported platforms decline the checkpoint. Check the hook's final owner through the same pinned descriptor when preparing the restore, so the index writer no longer needs its own POSIX-only call. The supported-platform ownership checks and historical-only proof semantics are unchanged. --- clean-status-history.c | 20 +++++++++++++++++--- read-cache.c | 2 -- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index 207b7cee350fe7..868ba61247497f 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -2120,6 +2120,16 @@ struct clean_status_commit_checkpoint { unsigned sealed : 1; }; +static int commit_checkpoint_owner_matches(const struct stat *st) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + return st->st_uid == geteuid(); +#else + (void)st; + return 0; +#endif +} + static int commit_checkpoint_source_matches( const struct clean_status_commit_checkpoint *checkpoint, struct lock_file *lock) @@ -2225,7 +2235,7 @@ struct clean_status_commit_checkpoint *clean_status_capture_commit_checkpoint( flags = fd < 0 ? -1 : fcntl(fd, F_GETFL); if (flags < 0 || (flags & O_ACCMODE) != O_RDWR || fstat(fd, &st) || !S_ISREG(st.st_mode) || - st.st_nlink != 1 || st.st_uid != geteuid()) + st.st_nlink != 1 || !commit_checkpoint_owner_matches(&st)) return NULL; CALLOC_ARRAY(checkpoint, 1); @@ -2247,7 +2257,8 @@ struct clean_status_commit_checkpoint *clean_status_capture_commit_checkpoint( /* The first write replaces istate->oid, so pin its canonical source now. */ if (clean_status_index_snapshot_pin_proof_epoch(&checkpoint->source, istate) || - fstat(checkpoint->source.fd, &st) || st.st_uid != geteuid() || + fstat(checkpoint->source.fd, &st) || + !commit_checkpoint_owner_matches(&st) || attr_source_snapshot_repository(istate->repo, &checkpoint->attrs)) goto fail; attrs = attr_source_snapshot_fingerprint(checkpoint->attrs); @@ -2293,7 +2304,8 @@ void clean_status_record_commit_checkpoint( if (checkpoint->repo != istate->repo || !clean_status_fsmonitor_backoff_suspended(istate) || !commit_checkpoint_source_matches(checkpoint, lock) || - fstat(checkpoint->writer_fd, &st) || st.st_uid != geteuid() || + fstat(checkpoint->writer_fd, &st) || + !commit_checkpoint_owner_matches(&st) || clean_status_identity_from_stat(&identity, &st) || clean_status_index_snapshot_open_allow_null_checksum( &checkpoint->written, get_lock_file_path(lock), @@ -2343,6 +2355,7 @@ int clean_status_prepare_commit_checkpoint_restore( struct index_state *replacement, int fd) { struct clean_status_state *state; + struct stat st; unsigned char hash[GIT_MAX_RAWSZ]; const char *suffix; @@ -2350,6 +2363,7 @@ int clean_status_prepare_commit_checkpoint_restore( !clean_status_commit_checkpoint_still_valid(checkpoint, lock) || current->repo != checkpoint->repo || current != current->repo->index || current->resolve_undo || + fstat(fd, &st) || !commit_checkpoint_owner_matches(&st) || clean_status_index_logical_digest(current, hash) || memcmp(hash, checkpoint->logical_hash, current->repo->hash_algo->rawsz) || read_index_entries_from_fd(replacement, fd) || diff --git a/read-cache.c b/read-cache.c index d60f9b177bedef..9648b1c29d531b 100644 --- a/read-cache.c +++ b/read-cache.c @@ -4189,7 +4189,6 @@ void restore_locked_index_for_commit( struct clean_status_index_snapshot current = { .fd = -1 }; struct lock_file rewrite = LOCK_INIT; struct strbuf cache_tree_data = STRBUF_INIT; - struct stat st; char *destination = NULL; const char *path; int ret; @@ -4202,7 +4201,6 @@ void restore_locked_index_for_commit( if (!clean_status_index_path_is_main(repo, destination) || clean_status_index_snapshot_open_allow_null_checksum( ¤t, path, repo->hash_algo) || - fstat(current.fd, &st) || st.st_uid != geteuid() || hold_lock_file_for_update(&rewrite, path, LOCK_NO_DEREF) < 0 || !clean_status_index_snapshot_still_matches_path( ¤t, path, repo->hash_algo) || From 0d1e07f756d493040a2ad84b8497c2757bfeae32 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:03:09 -0500 Subject: [PATCH 391/403] fsmonitor: prepare history before canonical index reads Interactive add and scoped stash can refresh and rewrite the main index before enabling external fsmonitor history. During watch-limit backoff, even quitting an interactive prompt can then discard the pending token and untracked history. The apply and am entrypoints also reject every GIT_INDEX_FILE, including the canonical index passed by ordinary hooks. Centralize the pre-read setup around the existing physical-main-index check. Admit only the trusted repository namespace and an IPC provider or authenticated watch-limit backoff, then attach the repository config digest before reading the index. Reuse this setup in interactive add, scoped stash, apply, and am. Allow apply's existing history-preservation predicate to accept an explicitly selected canonical index, without loosening its patch-shape, attribute, or filter checks. This only prepares authenticated history reads; it does not grant current cleanliness or authorize temporary, alternate, or aliased index outputs. Cover scoped stash, genuine hook-invoked apply, canonical-index am, and the corresponding temporary-index rejection cases. --- add-interactive.c | 2 + add-patch.c | 2 + apply.c | 5 +- builtin/am.c | 11 +- builtin/apply.c | 24 +- builtin/stash.c | 20 +- clean-status.c | 26 ++ clean-status.h | 1 + t/t7536-fsmonitor-watch-limit-backoff.sh | 331 +++++++++++++++++++++++ 9 files changed, 373 insertions(+), 49 deletions(-) diff --git a/add-interactive.c b/add-interactive.c index 3cf8a1dbf85e3f..f22b0c83e73ccc 100644 --- a/add-interactive.c +++ b/add-interactive.c @@ -2,6 +2,7 @@ #include "git-compat-util.h" #include "add-interactive.h" +#include "clean-status.h" #include "color.h" #include "diffcore.h" #include "gettext.h" @@ -1123,6 +1124,7 @@ int run_add_i(struct repository *r, const struct pathspec *ps, _("staged"), _("unstaged"), _("path")); opts.list_opts.header = header.buf; + clean_status_prepare_main_index_history(r); discard_index(r->index); if (repo_read_index(r) < 0 || repo_refresh_and_write_index(r, REFRESH_QUIET, 0, 1, diff --git a/add-patch.c b/add-patch.c index f27edcbe8d4151..d8256e3ba752cc 100644 --- a/add-patch.c +++ b/add-patch.c @@ -4,6 +4,7 @@ #include "git-compat-util.h" #include "add-patch.h" #include "advice.h" +#include "clean-status.h" #include "commit.h" #include "config.h" #include "diff.h" @@ -2080,6 +2081,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode, s.mode = &patch_mode_add; s.revision = revision; + clean_status_prepare_main_index_history(r); discard_index(r->index); if (repo_read_index(r) < 0 || (!s.mode->index_only && diff --git a/apply.c b/apply.c index 48cf4083130f89..cb058f92390250 100644 --- a/apply.c +++ b/apply.c @@ -14,6 +14,7 @@ #include "abspath.h" #include "base85.h" #include "clean-status.h" +#include "clean-status-index.h" #include "config.h" #include "odb.h" #include "delta.h" @@ -4454,7 +4455,9 @@ static int patch_preserves_clean_history(struct apply_state *state, if (!state->update_index || state->ita_only || state->threeway || state->apply_with_reject || state->fake_ancestor || state->index_file || !fstat_is_reliable() || - getenv(INDEX_ENVIRONMENT) || + (getenv(INDEX_ENVIRONMENT) && + !clean_status_index_path_is_main(istate->repo, + istate->repo->index_file)) || getenv(GIT_WORK_TREE_ENVIRONMENT) || getenv(GIT_COMMON_DIR_ENVIRONMENT) || getenv(DB_ENVIRONMENT) || getenv(ALTERNATE_DB_ENVIRONMENT) || diff --git a/builtin/am.c b/builtin/am.c index 57e1920be67a91..f4735c031022c2 100644 --- a/builtin/am.c +++ b/builtin/am.c @@ -9,12 +9,10 @@ #include "builtin.h" #include "abspath.h" #include "advice.h" -#include "clean-status-config.h" #include "clean-status.h" #include "config.h" #include "editor.h" #include "environment.h" -#include "fsmonitor-settings.h" #include "gettext.h" #include "hex.h" #include "parse-options.h" @@ -2318,7 +2316,6 @@ int cmd_am(int argc, struct repository *repo UNUSED) { struct am_state state; - struct clean_status_config_digest clean_digest; int binary = -1; int keep_cr = -1; int patch_format = PATCH_FORMAT_UNKNOWN; @@ -2468,13 +2465,7 @@ int cmd_am(int argc, /* Ensure a valid committer ident can be constructed */ git_committer_info(IDENT_STRICT); - if (fstat_is_reliable() && !getenv(INDEX_ENVIRONMENT) && - (fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC || - fsm_settings__is_watch_limit_backoff(the_repository)) && - !clean_status_config_read_repository(the_repository, &clean_digest)) { - clean_status_enable_external_history(the_repository); - clean_status_set_config_digest(the_repository, &clean_digest); - } + clean_status_prepare_main_index_history(the_repository); if (repo_read_index_preload(the_repository, NULL, 0) < 0) die(_("failed to read the index")); diff --git a/builtin/apply.c b/builtin/apply.c index 49c98fabc678c8..99c69f12889696 100644 --- a/builtin/apply.c +++ b/builtin/apply.c @@ -1,12 +1,8 @@ #define USE_THE_REPOSITORY_VARIABLE #include "builtin.h" -#include "clean-status-config.h" #include "clean-status.h" -#include "environment.h" -#include "fsmonitor-settings.h" #include "gettext.h" #include "hash.h" -#include "replace-object.h" #include "apply.h" static const char * const apply_usage[] = { @@ -22,7 +18,6 @@ int cmd_apply(int argc, int force_apply = 0; int options = 0; int ret; - struct clean_status_config_digest clean_digest; struct apply_state state; if (init_apply_state(&state, the_repository, prefix)) @@ -51,23 +46,8 @@ int cmd_apply(int argc, if (state.apply && state.check_index && !state.threeway && !state.apply_with_reject && !state.ita_only && - !state.fake_ancestor && !state.index_file && - !getenv(INDEX_ENVIRONMENT) && - !getenv(GIT_WORK_TREE_ENVIRONMENT) && - !getenv(GIT_COMMON_DIR_ENVIRONMENT) && - !getenv(DB_ENVIRONMENT) && - !getenv(ALTERNATE_DB_ENVIRONMENT) && fstat_is_reliable() && - !repo_config_values(the_repository)->apply_sparse_checkout && - the_repository->config_values_private_.trust_ctime && - the_repository->config_values_private_.check_stat && - (fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC || - fsm_settings__is_watch_limit_backoff(the_repository)) && - !repo_has_replace_refs_uncached(the_repository) && - !clean_status_config_read_repository(the_repository, - &clean_digest)) { - clean_status_enable_external_history(the_repository); - clean_status_set_config_digest(the_repository, &clean_digest); - } + !state.fake_ancestor && !state.index_file) + clean_status_prepare_main_index_history(the_repository); ret = apply_all_patches(&state, argc, argv, options); diff --git a/builtin/stash.c b/builtin/stash.c index 03838fee5424d2..38fc0a32368106 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1724,7 +1724,6 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q { int ret = 0; int preserve_clean_history = !ps->nr && !include_untracked; - int preserve_scoped_history = 0; struct lock_file index_lock = LOCK_INIT; struct stash_info info = STASH_INFO_INIT; struct strbuf patch = STRBUF_INIT; @@ -1753,30 +1752,19 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q goto done; } - preserve_scoped_history = ps->nr && !include_untracked && - !patch_mode && !only_staged && keep_index != 1 && - !getenv(INDEX_ENVIRONMENT) && - !getenv(GIT_WORK_TREE_ENVIRONMENT) && - !getenv(GIT_COMMON_DIR_ENVIRONMENT) && - !getenv(DB_ENVIRONMENT) && - !getenv(ALTERNATE_DB_ENVIRONMENT) && fstat_is_reliable() && - !repo_config_values(the_repository)->apply_sparse_checkout && - the_repository->config_values_private_.trust_ctime && - the_repository->config_values_private_.check_stat && - fsm_settings__get_mode(the_repository) == FSMONITOR_MODE_IPC && - !repo_has_replace_refs_uncached(the_repository); - /* * Keep authenticated history bound while inspecting the worktree. * Whole-worktree changes still invalidate their proof below. A scoped * regular-file replacement keeps it only when each writer proves that * its provider, semantic inputs, and untracked cache remain paired. */ - if (preserve_clean_history || preserve_scoped_history) { + if (preserve_clean_history) { clean_status_enable_external_history(the_repository); clean_status_set_config_digest(the_repository, &stash_clean_digest); - } + } else if (ps->nr && !include_untracked && !patch_mode && + !only_staged && keep_index != 1) + clean_status_prepare_main_index_history(the_repository); repo_read_index_preload(the_repository, NULL, 0); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); diff --git a/clean-status.c b/clean-status.c index 7b955ed902cb0c..6c0dd8f1bebac8 100644 --- a/clean-status.c +++ b/clean-status.c @@ -39,6 +39,32 @@ void clean_status_enable_external_history(struct repository *repo) external_history_repo = repo; } +void clean_status_prepare_main_index_history(struct repository *repo) +{ + struct clean_status_config_digest digest; + + /* + * Attach the command's configuration before its first index read. This + * does not grant a proof: the reader must still authenticate the on-disk + * epoch, and each writer must validate its own logical changes. + */ + if (!repo || !repo->worktree || + !clean_status_index_path_is_main(repo, repo->index_file) || + getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || getenv(DB_ENVIRONMENT) || + getenv(ALTERNATE_DB_ENVIRONMENT) || !fstat_is_reliable() || + repo_config_values(repo)->apply_sparse_checkout || + !repo->config_values_private_.trust_ctime || + !repo->config_values_private_.check_stat || + (fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC && + !fsm_settings__is_watch_limit_backoff(repo)) || + repo_has_replace_refs_uncached(repo) || + clean_status_config_read_repository(repo, &digest)) + return; + clean_status_enable_external_history(repo); + clean_status_set_config_digest(repo, &digest); +} + int clean_status_external_history_enabled(const struct index_state *istate) { return istate && istate->repo == external_history_repo; diff --git a/clean-status.h b/clean-status.h index f0a9c189174d43..465971fd75d855 100644 --- a/clean-status.h +++ b/clean-status.h @@ -25,6 +25,7 @@ void clean_status_set_config_digest( struct repository *repo, const struct clean_status_config_digest *digest); void clean_status_enable_external_history(struct repository *repo); +void clean_status_prepare_main_index_history(struct repository *repo); int clean_status_external_history_enabled(const struct index_state *istate); void clean_status_enable_progress(struct repository *repo); struct clean_status_progress *clean_status_start_progress( diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 0460092500c7f4..7b61e2690e38eb 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -2641,4 +2641,335 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done ' +# Keep the selected writer's result separate from any later status refresh. +backoff_scoped_index_tree () { + cp "$1" "$2.index" && + backoff_commit_index "$2.index" write-tree >"$2.tree" +} + +backoff_scoped_recover () ( + gitdir=$1 && evidence=$2 && expected_tree=$3 && + backoff_commit_index "$gitdir/index" status --porcelain=v2 \ + >"$evidence/status.expected" && + cp "$gitdir/index" "$evidence/index.before-recovery" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/backoff-status.trace" \ + git --no-optional-locks status --porcelain=v2 \ + >"$evidence/status.actual" && + test_cmp "$evidence/status.expected" "$evidence/status.actual" && + test_cmp_bin "$evidence/index.before-recovery" "$gitdir/index" && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <"$evidence/backoff-status.trace" && + rm "$gitdir/fsmonitor--daemon.inotify-limit" && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$evidence/recovery.trace" \ + git status --porcelain=v2 >"$evidence/recovery.actual" && + test_cmp "$evidence/status.expected" "$evidence/recovery.actual" && + test_trace2_data fsm_client query/trivial-response 1 <"$evidence/recovery.trace" && + test_trace2_data fsmonitor token_closure/accepted 1 <"$evidence/recovery.trace" && + assert_backoff_full_proof "$gitdir/index" && + cp "$gitdir/index" "$evidence/index.recovered" && + backoff_scoped_index_tree "$gitdir/index" "$evidence/recovered" && + test_cmp "$expected_tree" "$evidence/recovered.tree" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/warm.trace" \ + git --no-optional-locks status --porcelain=v2 >"$evidence/warm.actual" && + test_cmp "$evidence/status.expected" "$evidence/warm.actual" && + test_trace2_data fsmonitor config/coherent 1 <"$evidence/warm.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <"$evidence/warm.trace" && + test_region ! index do_write_index "$evidence/warm.trace" && + test_cmp_bin "$evidence/index.recovered" "$gitdir/index" && + test_path_is_missing "$gitdir/index.lock" && + test_path_is_missing "$gitdir/rebase-apply" && + test_grep ! "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/backoff-status.trace" "$evidence/recovery.trace" "$evidence/warm.trace" +) + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'scoped stash push retains pending backoff history and selected trees' ' + sane_unset GIT_INDEX_FILE && + setup_backoff_hook_pair watch-backoff-scoped-stash && + common=$(git -C watch-backoff-scoped-stash-main -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + for kind in main linked + do + ( + cd "watch-backoff-scoped-stash-$kind" && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + evidence="$common/scoped-stash-$kind" && + mkdir "$evidence" && + checkpoint=$(cat "$gitdir/checkpoints") && + cp "$gitdir/index" "$evidence/index.seed" && + backoff_scoped_index_tree "$evidence/index.seed" "$evidence/expected-main" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.before" && + git -c core.fsmonitor=false show HEAD:tracked >"$evidence/expected-tracked" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + test_write_lines scoped-staged >tracked && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/stage.trace" git add tracked && + cp "$gitdir/index" "$evidence/index.staged" && + assert_backoff_pending_proof "$evidence/index.seed" "$evidence/index.staged" && + backoff_scoped_index_tree "$evidence/index.staged" "$evidence/expected-stash-index" && + cp "$evidence/index.staged" "$evidence/worktree-oracle.index" && + test_write_lines scoped-worktree >"$evidence/selected.contents" && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ + <"$evidence/selected.contents") && + backoff_commit_index "$evidence/worktree-oracle.index" \ + update-index --cacheinfo "100644,$oid,tracked" && + backoff_commit_index "$evidence/worktree-oracle.index" write-tree \ + >"$evidence/expected-stash-worktree.tree" && + cp "$evidence/selected.contents" tracked && + test_write_lines outside-path >sibling && + cp sibling "$evidence/expected-sibling" && + test_write_lines visible >visible && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/stash.trace" \ + git stash push -- tracked >"$evidence/stash.out" && + cp "$gitdir/index" "$evidence/index.published" && + snapshot_backoff_index_identity "$gitdir/index" \ + >"$evidence/index.published.identity" && + assert_backoff_pending_proof "$evidence/index.seed" "$evidence/index.published" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 <"$evidence/stash.trace" && + assert_backoff_checkpoint_unchanged "$gitdir" "$checkpoint" && + backoff_scoped_index_tree "$evidence/index.published" "$evidence/published" && + test_cmp "$evidence/expected-main.tree" "$evidence/published.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse stash^{tree} \ + >"$evidence/stash-worktree.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse stash^2^{tree} \ + >"$evidence/stash-index.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse stash^1 \ + >"$evidence/stash-parent" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.after" && + test_cmp "$evidence/expected-stash-worktree.tree" "$evidence/stash-worktree.tree" && + test_cmp "$evidence/expected-stash-index.tree" "$evidence/stash-index.tree" && + test_cmp "$evidence/head.before" "$evidence/stash-parent" && + test_cmp "$evidence/head.before" "$evidence/head.after" && + test_cmp "$evidence/expected-tracked" tracked && + test_cmp "$evidence/expected-sibling" sibling && + test_grep "^visible$" visible && + test_grep ! "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/stage.trace" "$evidence/stash.trace" && + backoff_scoped_recover "$gitdir" "$evidence" "$evidence/expected-main.tree" + ) || return 1 + done +' + +make_backoff_hook_patch_series () ( + gitdir=$1 && evidence=$2 && + cp "$gitdir/index.before-backoff" "$evidence/maker.index" && + backoff_commit_index "$evidence/maker.index" write-tree >"$evidence/base.tree" && + parent=$(cat "$evidence/base.tree") && + for step in first second + do + case "$step" in first) target=tracked ;; second) target=sibling ;; esac && + test_write_lines "hook-patch-$step" >"$evidence/$step.contents" && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin <"$evidence/$step.contents") && + backoff_commit_index "$evidence/maker.index" \ + update-index --cacheinfo "100644,$oid,$target" && + backoff_commit_index "$evidence/maker.index" write-tree >"$evidence/$step.tree" && + tree=$(cat "$evidence/$step.tree") && + git -c core.fsmonitor=false --no-optional-locks \ + diff-tree --binary --full-index --no-renames --no-commit-id -p \ + "$parent" "$tree" -- >"$evidence/$step.diff" && + parent=$tree || return 1 + done && + test_cmp_bin "$gitdir/index.before-backoff" "$gitdir/index" +) + +install_backoff_hook_patch () { + test_hook -C "$1" pre-commit <<-\EOF + set -eu + evidence=$BACKOFF_HOOK_EVIDENCE + main=$BACKOFF_HOOK_MAIN_INDEX + printf "%s\n" "$GIT_INDEX_FILE" >"$evidence/index.env" + if test "$BACKOFF_HOOK_PATCH_STYLE" = canonical + then + perl "$BACKOFF_HOOK_IDENTITY_HELPER" canonical "$GIT_INDEX_FILE" "$main" \ + >"$evidence/selected.path" + else + perl "$BACKOFF_HOOK_REJECT_HELPER" temporary all "$GIT_INDEX_FILE" "$main" \ + >"$evidence/selected.path" + fi + cp "$GIT_INDEX_FILE" "$evidence/selected.before" + cp "$main" "$evidence/main.in-hook.before" + GIT_TRACE2_EVENT="$evidence/child.trace" \ + git apply --index "$evidence/second.diff" + cp "$GIT_INDEX_FILE" "$evidence/selected.after" + cp "$main" "$evidence/main.in-hook.after" + printf "%s\n" success >"$evidence/completed" + EOF +} + +check_backoff_hook_patch () ( + prefix=$1 && kind=$2 && style=$3 && common=$4 && + cd "$prefix-$kind" && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks rev-parse --absolute-git-dir) && + evidence="$common/hook-patch-$style-$kind" && + mkdir "$evidence" && + cp "$gitdir/index" "$evidence/index.seed" && + make_backoff_hook_patch_series "$gitdir" "$evidence" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD >"$evidence/head.before" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 test-tool fsmonitor-client record-watch-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/direct.trace" \ + git apply --index "$evidence/first.diff" && + cp "$gitdir/index" "$evidence/index.first" && + assert_backoff_pending_proof "$evidence/index.seed" "$evidence/index.first" && + case "$style" in canonical) set -- ;; temporary) set -- -a ;; *) return 1 ;; esac && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/commit.trace" \ + BACKOFF_HOOK_PATCH_STYLE="$style" BACKOFF_HOOK_EVIDENCE="$evidence" \ + BACKOFF_HOOK_MAIN_INDEX="$gitdir/index" \ + BACKOFF_HOOK_IDENTITY_HELPER="$common/hook-index.pl" \ + BACKOFF_HOOK_REJECT_HELPER="$common/rejected-index.pl" \ + git commit -qm "successful $style patch hook" "$@" >"$evidence/commit.out" && + cp "$gitdir/index" "$evidence/index.published" && + snapshot_backoff_index_identity "$gitdir/index" >"$evidence/index.published.identity" && + test_grep "^success$" "$evidence/completed" && + assert_backoff_pending_proof "$evidence/index.seed" "$evidence/selected.before" && + case "$style" in + canonical) + assert_backoff_pending_proof "$evidence/index.seed" "$evidence/selected.after" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 <"$evidence/child.trace" + ;; + temporary) + assert_backoff_rejected_index_trace "$evidence/child.trace" && + test_grep ! FSUC "$evidence/selected.after" && + assert_backoff_commit_unbound "$evidence/selected.after" "$evidence/selected-unbound" && + assert_backoff_commit_unbound "$evidence/index.published" "$evidence/published-unbound" && + test_cmp_bin "$evidence/main.in-hook.before" "$evidence/main.in-hook.after" + ;; + esac && + # AS_IS status refresh after a successful hook may deliberately revoke UC + # history. The child transition above, not that later write, is this gate. + backoff_scoped_index_tree "$evidence/selected.after" "$evidence/selected" && + backoff_scoped_index_tree "$evidence/index.published" "$evidence/published" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} \ + >"$evidence/committed.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^ \ + >"$evidence/committed.parent" && + test_cmp "$evidence/second.tree" "$evidence/selected.tree" && + test_cmp "$evidence/second.tree" "$evidence/published.tree" && + test_cmp "$evidence/second.tree" "$evidence/committed.tree" && + test_cmp "$evidence/head.before" "$evidence/committed.parent" && + test_cmp "$evidence/first.contents" tracked && + test_cmp "$evidence/second.contents" sibling && + test_grep ! "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/direct.trace" "$evidence/commit.trace" "$evidence/child.trace" && + backoff_scoped_recover "$gitdir" "$evidence" "$evidence/second.tree" +) + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'canonical-index hooks and am retain same-path backoff history' ' + sane_unset GIT_INDEX_FILE && + prefix=watch-backoff-canonical-patch && + setup_backoff_hook_pair "$prefix" && + common=$(git -C "$prefix-main" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + write_backoff_hook_identity_helper "$common/hook-index.pl" && + write_backoff_rejected_index_helper "$common/rejected-index.pl" && + install_backoff_hook_patch "$prefix-main" && + for kind in main linked + do + check_backoff_hook_patch "$prefix" "$kind" canonical "$common" || return 1 + done && + setup_backoff_bound_proof watch-backoff-canonical-am && + ( + cd watch-backoff-canonical-am && + make_backoff_patch_series same-path && + record_authenticated_backoff_marker && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git am .git/patch-first.mbox && + cp .git/index .git/canonical-am.first && + assert_backoff_pending_proof .git/index.before-backoff .git/canonical-am.first && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >.git/canonical-am.parent.expected && + GIT_INDEX_FILE="$PWD/.git/index" GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/canonical-am.trace" \ + git am .git/patch-second.mbox && + cp .git/index .git/canonical-am.published && + assert_backoff_pending_proof .git/index.before-backoff .git/canonical-am.published && + test_trace2_data fsmonitor history/watch-limit-suspended 1 <.git/canonical-am.trace && + assert_backoff_patch_tree .git/canonical-am.published .git/patch-second && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} \ + >.git/canonical-am.committed.tree && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^ \ + >.git/canonical-am.parent && + test_cmp .git/patch-second.tree .git/canonical-am.committed.tree && + test_cmp .git/canonical-am.parent.expected .git/canonical-am.parent && + mkdir .git/canonical-am-recovery && + backoff_scoped_recover "$PWD/.git" "$PWD/.git/canonical-am-recovery" \ + "$PWD/.git/patch-second.tree" + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'temporary and alternate patch indexes cannot import backoff history' ' + sane_unset GIT_INDEX_FILE && + prefix=watch-backoff-temporary-patch && + setup_backoff_hook_pair "$prefix" && + common=$(git -C "$prefix-main" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + write_backoff_hook_identity_helper "$common/hook-index.pl" && + write_backoff_rejected_index_helper "$common/rejected-index.pl" && + install_backoff_hook_patch "$prefix-main" && + check_backoff_hook_patch "$prefix" main temporary "$common" && + for operation in apply am + do + setup_backoff_bound_proof "watch-backoff-alternate-patch-$operation" && + ( + cd "watch-backoff-alternate-patch-$operation" && + make_backoff_patch_series same-path && + record_authenticated_backoff_marker && + evidence="$PWD/.git/alternate-patch" && mkdir "$evidence" && + cp .git/index "$evidence/main.before" && + cp .git/index "$evidence/alternate.index" && + snapshot_backoff_index_identity .git/index >"$evidence/main.identity.before" && + backoff_scoped_index_tree "$evidence/main.before" "$evidence/expected-main" && + case "$operation" in + apply) set -- git apply --index .git/patch-first.diff ;; + am) set -- git am .git/patch-first.mbox ;; + esac && + GIT_INDEX_FILE="$evidence/alternate.index" GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/command.trace" "$@" >"$evidence/command.out" && + cp "$evidence/alternate.index" "$evidence/selected.after" && + cp .git/index "$evidence/main.after" && + snapshot_backoff_index_identity .git/index >"$evidence/main.identity.after" && + test_cmp_bin "$evidence/main.before" "$evidence/main.after" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.after" && + assert_backoff_rejected_index_trace "$evidence/command.trace" && + test_grep ! FSUC "$evidence/selected.after" && + assert_backoff_commit_unbound "$evidence/selected.after" "$evidence/selected-unbound" && + assert_backoff_patch_tree "$evidence/selected.after" .git/patch-first && + if test "$operation" = am + then + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} \ + >"$evidence/committed.tree" && + test_cmp .git/patch-first.tree "$evidence/committed.tree" + else + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.after" && + test_cmp .git/patch-base.commit "$evidence/head.after" + fi && + backoff_scoped_recover "$PWD/.git" "$evidence" "$evidence/expected-main.tree" + ) || return 1 + done +' + test_done From 12cb4a786221cad0f0087530b8e6acce7a04635a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:03:52 -0500 Subject: [PATCH 392/403] commit: preserve suspended history after interactive selection A successful commit -p writes its selected entries through a private index. That index cannot inherit proof authority from the main index, so the interactive child correctly discards fsmonitor history. Publishing the result then loses history even when the selection only changes the contents of existing regular files. Capture the parent's authenticated historical checkpoint before handing the temporary index to the interactive child. After selection, compare the held original descriptor with the parent's current index and the newly opened selected file. Advance the checkpoint only when entry names, modes, stages, and persistent flags agree, and every changed object passes the existing same-path attribute and filter checks. Recheck the original main index and repository configuration before sealing the selected entries. The ordinary successful-commit writer can then restore historical-only state at final publication. Subsequent hooks must leave the selected logical entries unchanged. The private index never gains authority, structural changes still revoke history, and every tracked entry remains dirty until a genuine token closure succeeds. Add quit and accepted-hunk interactive regressions, private-index and structural-selection controls, and a worktree-attribute recovery case. The attribute test permits historical retention but requires authoritative status and later recovery to detect the changed conversion rules. --- builtin/commit.c | 9 +- clean-status-history.c | 170 ++++++- clean-status.h | 4 + t/t7536-fsmonitor-watch-limit-backoff.sh | 612 +++++++++++++++++++++++ 4 files changed, 775 insertions(+), 20 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index e28bf152c3cce0..5e8c425fa13f1d 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -450,7 +450,10 @@ static const char *prepare_index(const char **argv, const char *prefix, refresh_cache_or_die(refresh_flags); - if (write_locked_index(the_repository->index, &index_lock, 0)) + if (is_status ? + write_locked_index(the_repository->index, &index_lock, 0) : + write_locked_index_for_commit(the_repository->index, &index_lock, + &commit_checkpoint)) die(_("unable to create temporary index")); old_repo_index_file = the_repository->index_file; @@ -479,6 +482,10 @@ static const char *prepare_index(const char **argv, const char *prefix, die(_("unable to update temporary index")); } else warning(_("Failed to update main cache tree")); + if (!is_status && + !clean_status_advance_commit_checkpoint( + commit_checkpoint, the_repository->index, &index_lock)) + release_commit_checkpoint(); commit_style = COMMIT_NORMAL; ret = get_lock_file_path(&index_lock); diff --git a/clean-status-history.c b/clean-status-history.c index 868ba61247497f..ffe09585623e7d 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -2116,8 +2116,11 @@ struct clean_status_commit_checkpoint { unsigned char semantic_hash[GIT_MAX_RAWSZ]; unsigned char tracked_policy_hash[GIT_MAX_RAWSZ]; unsigned char logical_hash[GIT_MAX_RAWSZ]; + uint64_t written_dev; + uint64_t written_ino; int writer_fd; unsigned sealed : 1; + unsigned needs_restore : 1; }; static int commit_checkpoint_owner_matches(const struct stat *st) @@ -2320,6 +2323,8 @@ void clean_status_record_commit_checkpoint( &checkpoint->written, get_lock_file_path(lock), istate->repo->hash_algo)) goto done; + checkpoint->written_dev = st.st_dev; + checkpoint->written_ino = st.st_ino; checkpoint->sealed = 1; done: close(checkpoint->writer_fd); @@ -2336,9 +2341,10 @@ int clean_status_commit_checkpoint_changed( return checkpoint && checkpoint->sealed && lock && checkpoint->lock == lock && is_lock_file_locked(lock) && - !clean_status_index_snapshot_still_matches_path( + (checkpoint->needs_restore || + !clean_status_index_snapshot_still_matches_path( &checkpoint->written, get_lock_file_path(lock), - checkpoint->repo->hash_algo); + checkpoint->repo->hash_algo)); } int clean_status_commit_checkpoint_still_valid( @@ -2349,28 +2355,13 @@ int clean_status_commit_checkpoint_still_valid( commit_checkpoint_source_matches(checkpoint, lock); } -int clean_status_prepare_commit_checkpoint_restore( +static int attach_commit_checkpoint_history( const struct clean_status_commit_checkpoint *checkpoint, - struct lock_file *lock, const struct index_state *current, - struct index_state *replacement, int fd) + struct index_state *replacement) { struct clean_status_state *state; - struct stat st; - unsigned char hash[GIT_MAX_RAWSZ]; const char *suffix; - if (!current || !replacement || - !clean_status_commit_checkpoint_still_valid(checkpoint, lock) || - current->repo != checkpoint->repo || current != current->repo->index || - current->resolve_undo || - fstat(fd, &st) || !commit_checkpoint_owner_matches(&st) || - clean_status_index_logical_digest(current, hash) || - memcmp(hash, checkpoint->logical_hash, current->repo->hash_algo->rawsz) || - read_index_entries_from_fd(replacement, fd) || - clean_status_index_logical_digest(replacement, hash) || - memcmp(hash, checkpoint->logical_hash, current->repo->hash_algo->rawsz)) - return 0; - replacement->untracked = read_untracked_extension( checkpoint->untracked.buf, checkpoint->untracked.len); if (!replacement->untracked || !replacement->untracked->root || @@ -2403,3 +2394,144 @@ int clean_status_prepare_commit_checkpoint_restore( ~(CE_FSMONITOR_VALID | CE_UPTODATE); return 1; } + +static int read_commit_checkpoint_written( + const struct clean_status_commit_checkpoint *checkpoint, + struct index_state *written) +{ + struct stat before, after; + unsigned char hash[GIT_MAX_RAWSZ]; + + /* + * The child may have replaced the name, leaving this owned descriptor + * unlinked. Its original inode and sealed logical contents still bind + * the baseline; neither the old pathname nor unlink's ctime is authority. + */ + return checkpoint->written.fd >= 0 && + !fstat(checkpoint->written.fd, &before) && + S_ISREG(before.st_mode) && before.st_nlink <= 1 && + commit_checkpoint_owner_matches(&before) && + (uint64_t)before.st_dev == checkpoint->written_dev && + (uint64_t)before.st_ino == checkpoint->written_ino && + !read_index_entries_from_fd(written, checkpoint->written.fd) && + written->version == checkpoint->written.version && + written->cache_nr == checkpoint->written.cache_nr && + oideq(&written->oid, &checkpoint->written.checksum) && + !clean_status_index_logical_digest(written, hash) && + !memcmp(hash, checkpoint->logical_hash, + checkpoint->repo->hash_algo->rawsz) && + !fstat(checkpoint->written.fd, &after) && + path_namespace_stat_equal(&before, &after); +} + +int clean_status_advance_commit_checkpoint( + struct clean_status_commit_checkpoint *checkpoint, + const struct index_state *current, struct lock_file *lock) +{ + struct index_state before = INDEX_STATE_INIT(NULL); + struct index_state after = INDEX_STATE_INIT(NULL); + struct clean_status_index_snapshot selected = { .fd = -1 }; + const struct clean_status_state *state; + const struct git_hash_algo *algo; + struct stat st; + unsigned char current_hash[GIT_MAX_RAWSZ]; + unsigned char selected_hash[GIT_MAX_RAWSZ]; + const unsigned int persistent_flags = + CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; + const uint32_t historical = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + int advanced = 0; + + if (!current || !checkpoint || checkpoint->needs_restore || + !clean_status_commit_checkpoint_still_valid(checkpoint, lock) || + current->repo != checkpoint->repo || current != current->repo->index || + current->resolve_undo) + return 0; + algo = current->repo->hash_algo; + before.repo = after.repo = current->repo; + if (!read_commit_checkpoint_written(checkpoint, &before) || + clean_status_index_snapshot_open_allow_null_checksum( + &selected, get_lock_file_path(lock), algo) || + fstat(selected.fd, &st) || !commit_checkpoint_owner_matches(&st) || + read_index_entries_from_fd(&after, selected.fd) || + after.version != selected.version || + after.cache_nr != selected.cache_nr || + !oideq(&after.oid, &selected.checksum) || + clean_status_index_logical_digest(&after, selected_hash) || + clean_status_index_logical_digest(current, current_hash) || + memcmp(current_hash, selected_hash, algo->rawsz) || + before.cache_nr != after.cache_nr || + !attach_commit_checkpoint_history(checkpoint, &before)) + goto done; + + /* The old manifest is a historical path witness, never a current proof. */ + state = before.clean_status; + if (state->manifest.current_flags != historical || + !state->disk_semantic_valid || !state->disk_tracked_policy_valid || + !state->disk_attr_valid || + memcmp(state->disk_config_hash, checkpoint->config_hash, algo->rawsz) || + memcmp(state->disk_semantic_hash, checkpoint->semantic_hash, + algo->rawsz) || + memcmp(state->disk_tracked_policy_hash, + checkpoint->tracked_policy_hash, algo->rawsz) || + memcmp(state->disk_attr_hash, state->current_attr_hash, algo->rawsz)) + goto done; + for (size_t i = 0; i < before.cache_nr; i++) { + const struct cache_entry *old = before.cache[i]; + const struct cache_entry *new_entry = after.cache[i]; + + if (ce_namelen(old) != ce_namelen(new_entry) || + memcmp(old->name, new_entry->name, ce_namelen(old)) || + old->ce_mode != new_entry->ce_mode || + ((old->ce_flags ^ new_entry->ce_flags) & persistent_flags) || + (!oideq(&old->oid, &new_entry->oid) && + (!S_ISREG(old->ce_mode) || !S_ISREG(new_entry->ce_mode) || + !clean_status_index_entry_is_semantically_safe( + &before, old, new_entry)))) + goto done; + } + if (!clean_status_index_snapshot_still_matches_path( + &selected, get_lock_file_path(lock), algo) || + !commit_checkpoint_source_matches(checkpoint, lock)) + goto done; + + /* Subsequent hooks must leave these selected logical entries unchanged. */ + clean_status_index_snapshot_release(&checkpoint->written); + checkpoint->written = selected; + selected.fd = -1; + checkpoint->written_dev = st.st_dev; + checkpoint->written_ino = st.st_ino; + memcpy(checkpoint->logical_hash, selected_hash, algo->rawsz); + checkpoint->needs_restore = 1; + advanced = 1; + trace2_data_intmax("fsmonitor", current->repo, + "history/commit-backoff-advanced", 1); +done: + clean_status_index_snapshot_release(&selected); + release_index(&before); + release_index(&after); + return advanced; +} + +int clean_status_prepare_commit_checkpoint_restore( + const struct clean_status_commit_checkpoint *checkpoint, + struct lock_file *lock, const struct index_state *current, + struct index_state *replacement, int fd) +{ + struct stat st; + unsigned char hash[GIT_MAX_RAWSZ]; + + if (!current || !replacement || + !clean_status_commit_checkpoint_still_valid(checkpoint, lock) || + current->repo != checkpoint->repo || current != current->repo->index || + current->resolve_undo || + fstat(fd, &st) || !commit_checkpoint_owner_matches(&st) || + clean_status_index_logical_digest(current, hash) || + memcmp(hash, checkpoint->logical_hash, current->repo->hash_algo->rawsz) || + read_index_entries_from_fd(replacement, fd) || + clean_status_index_logical_digest(replacement, hash) || + memcmp(hash, checkpoint->logical_hash, current->repo->hash_algo->rawsz)) + return 0; + + return attach_commit_checkpoint_history(checkpoint, replacement); +} diff --git a/clean-status.h b/clean-status.h index 465971fd75d855..b4421da881b857 100644 --- a/clean-status.h +++ b/clean-status.h @@ -173,6 +173,10 @@ struct clean_status_commit_checkpoint *clean_status_capture_commit_checkpoint( void clean_status_record_commit_checkpoint( struct clean_status_commit_checkpoint *checkpoint, struct index_state *istate, struct lock_file *lock); +/* Seal safe same-path interactive changes before running ordinary hooks. */ +int clean_status_advance_commit_checkpoint( + struct clean_status_commit_checkpoint *checkpoint, + const struct index_state *current, struct lock_file *lock); int clean_status_commit_checkpoint_changed( const struct clean_status_commit_checkpoint *checkpoint, struct lock_file *lock); diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 7b61e2690e38eb..0161ba254c71a5 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -2972,4 +2972,616 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done ' +extract_backoff_interactive_apply_trace () { + perl - "$1" <<-\EOF + use strict; + use warnings; + open my $input, "<", $ARGV[0] or die "cannot read trace: $!\n"; + my @lines = <$input>; + my @sids = map { /"sid":"([^"]+)"/ ? $1 : () } + grep { /"event":"cmd_name"/ && /"name":"apply"/ } @lines; + die "expected exactly one real apply child\n" unless @sids == 1; + print grep { /"sid":"\Q$sids[0]\E"/ } @lines; + EOF +} + +assert_backoff_interactive_index_env () { + perl - "$1" "$2" <<-\EOF + use strict; + use warnings; + my ($trace, $expected) = @ARGV; + open my $input, "<", $trace or die "cannot read trace: $!\n"; + my @matches = grep { + /"event":"def_param"/ && /"param":"GIT_INDEX_FILE"/ && + /"value":"\Q$expected\E"/ + } <$input>; + die "apply did not receive the expected selected index\n" unless @matches == 1; + EOF +} + +check_backoff_interactive () ( + prefix=$1 && + operation=$2 && + action=$3 && + kind=$4 && + common=$5 && + case "$kind" in + main) + other_gitdir=$(git -C "$prefix-linked" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) + ;; + linked) + other_gitdir=$common + ;; + esac && + cd "$prefix-$kind" && + sane_unset GIT_INDEX_FILE GIT_TEST_PRELOAD_INDEX_BULK \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + main_index="$gitdir/index" && + checkpoint=$(cat "$gitdir/checkpoints") && + evidence="$common/interactive-$operation-$action-$kind" && + mkdir "$evidence" && + cp "$main_index" "$evidence/main.seed" && + cp "$checkpoint" "$evidence/checkpoint.before" && + cp "$other_gitdir/index" "$evidence/other-index.before" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.before" && + snapshot_backoff_index_identity "$other_gitdir/index" \ + >"$evidence/other-index.identity.before" && + assert_backoff_full_proof "$evidence/main.seed" && + case "$operation" in + commit-p-mode) chmod +x tracked ;; + commit-p-delete) rm tracked ;; + *) test_write_lines worktree-change >tracked ;; + esac && + cp "$evidence/main.seed" "$evidence/oracle-main.index" && + cp "$evidence/main.seed" "$evidence/oracle-selected.index" && + case "$operation" in + quit-p | quit-i) + : + ;; + add-p | commit-p) + backoff_commit_index "$evidence/oracle-main.index" add -- tracked && + cp "$evidence/oracle-main.index" "$evidence/oracle-selected.index" + ;; + commit-p-mode | commit-p-delete) + case "$operation" in + commit-p-mode) + backoff_commit_index "$evidence/oracle-main.index" \ + update-index --chmod=+x tracked + ;; + commit-p-delete) + backoff_commit_index "$evidence/oracle-main.index" \ + update-index --force-remove tracked + ;; + esac && + cp "$evidence/oracle-main.index" "$evidence/oracle-selected.index" + ;; + private-p) + backoff_commit_index "$evidence/oracle-selected.index" add -- tracked + ;; + *) + return 1 + ;; + esac && + for oracle in main selected + do + backoff_commit_index "$evidence/oracle-$oracle.index" write-tree \ + >"$evidence/expected-$oracle.tree" && + backoff_commit_entries "$evidence/oracle-$oracle.index" \ + >"$evidence/expected-$oracle.entries" || return 1 + done && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.before" && + selected=$main_index && + if test "$operation" = private-p + then + selected="$gitdir/index.alias-copy" && + cp "$evidence/main.seed" "$selected" && + perl "$common/rejected-index.pl" alias copy "$selected" "$main_index" \ + >"$evidence/private.identity.before" + else + : + fi && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + test_path_is_file "$gitdir/fsmonitor--daemon.inotify-limit" && + case "$operation" in + quit-p) answer=q && set -- add -p -- tracked ;; + quit-i) answer=q && set -- add -i -- tracked ;; + add-p | private-p) answer=y && set -- add -p -- tracked ;; + commit-p | commit-p-mode | commit-p-delete) + answer=y && set -- commit -p -qm "accepted tracked change" ;; + esac && + test_write_lines "$answer" >"$evidence/input" && + ( + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=EEEEEEEEEEEEEEEE && + GIT_TRACE2_EVENT="$evidence/interactive.trace" && + GIT_TRACE2_ENV_VARS=GIT_INDEX_FILE && + BACKOFF_HOOK_STYLE=all BACKOFF_HOOK_ACTION=$action && + BACKOFF_HOOK_EVIDENCE=$evidence BACKOFF_HOOK_MAIN_INDEX=$main_index && + BACKOFF_HOOK_IDENTITY_HELPER="$common/hook-index.pl" && + BACKOFF_HOOK_REJECT_HELPER="$common/rejected-index.pl" && + BACKOFF_HOOK_REFRESH_PATH=sibling && + export GIT_TEST_FSMONITOR_INOTIFY_BACKOFF \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TRACE2_EVENT GIT_TRACE2_ENV_VARS \ + BACKOFF_HOOK_STYLE BACKOFF_HOOK_ACTION BACKOFF_HOOK_EVIDENCE \ + BACKOFF_HOOK_MAIN_INDEX BACKOFF_HOOK_IDENTITY_HELPER \ + BACKOFF_HOOK_REJECT_HELPER BACKOFF_HOOK_REFRESH_PATH && + if test "$operation" = private-p + then + GIT_INDEX_FILE=$selected && export GIT_INDEX_FILE + else + sane_unset GIT_INDEX_FILE + fi && + git "$@" <"$evidence/input" >"$evidence/interactive.out" \ + 2>"$evidence/interactive.err" + ) && + # Snapshot the publication before any status or other Git command. + cp "$main_index" "$evidence/index.published" && + cp "$selected" "$evidence/selected.published" && + snapshot_backoff_index_identity "$main_index" \ + >"$evidence/index.published.identity" && + test_path_is_missing "$main_index.lock" && + test_path_is_missing "$selected.lock" && + case "$operation" in + quit-i) test_grep "What now" "$evidence/interactive.out" ;; + commit-p-mode) test_grep "Stage mode change" "$evidence/interactive.out" ;; + commit-p-delete) test_grep "Stage deletion" "$evidence/interactive.out" ;; + *) test_grep "Stage this hunk" "$evidence/interactive.out" ;; + esac && + for view in main selected + do + case "$view" in + main) published="$evidence/index.published" ;; + selected) published="$evidence/selected.published" ;; + esac && + cp "$published" "$evidence/actual-$view.index" && + backoff_commit_index "$evidence/actual-$view.index" write-tree \ + >"$evidence/actual-$view.tree" && + backoff_commit_entries "$published" >"$evidence/actual-$view.entries" && + test_cmp "$evidence/expected-$view.tree" "$evidence/actual-$view.tree" && + test_cmp_bin "$evidence/expected-$view.entries" \ + "$evidence/actual-$view.entries" || return 1 + done && + extract_backoff_root_trace "$evidence/interactive.trace" \ + >"$evidence/interactive.root.trace" && + test_trace2_data fsm_client settings/inotify-watch-limit-backoff 1 \ + <"$evidence/interactive.root.trace" && + ! test_trace2_data fsmonitor token_closure/accepted 1 \ + <"$evidence/interactive.trace" && + case "$operation" in + quit-p | quit-i) + assert_backoff_full_proof "$evidence/index.published" && + test_cmp "$evidence/main.identity.before" "$evidence/index.published.identity" && + assert_backoff_history_unchanged "$gitdir" "$checkpoint" && + assert_backoff_main_index_write "$evidence/interactive.trace" "$main_index" no && + test_grep ! '"event":"child_start".*"apply","--cached"' \ + "$evidence/interactive.trace" + ;; + add-p | commit-p | commit-p-mode | commit-p-delete | private-p) + extract_backoff_interactive_apply_trace "$evidence/interactive.trace" \ + >"$evidence/apply.trace" && + test_grep '"event":"child_start".*"apply","--cached"' \ + "$evidence/interactive.trace" && + ! test_trace2_data fsmonitor history/commit-backoff-advanced 1 \ + <"$evidence/apply.trace" && + ! test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/apply.trace" && + case "$operation" in + add-p) + assert_backoff_interactive_index_env "$evidence/apply.trace" "$main_index" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <"$evidence/apply.trace" && + assert_backoff_pending_proof "$evidence/main.seed" "$evidence/index.published" && + assert_backoff_main_index_write "$evidence/interactive.trace" "$main_index" yes + ;; + commit-p) + # The child still uses an untrusted temporary index. Only its + # owning commit may validate and advance the same-path epoch. + assert_backoff_interactive_index_env "$evidence/apply.trace" "$main_index.lock" && + assert_backoff_rejected_index_trace "$evidence/apply.trace" && + assert_backoff_pending_proof "$evidence/main.seed" "$evidence/index.published" && + test_trace2_data fsmonitor history/commit-backoff-advanced 1 \ + <"$evidence/interactive.root.trace" && + test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/interactive.root.trace" && + assert_backoff_main_index_write "$evidence/interactive.trace" "$main_index" yes + ;; + commit-p-mode | commit-p-delete) + assert_backoff_interactive_index_env "$evidence/apply.trace" "$main_index.lock" && + assert_backoff_rejected_index_trace "$evidence/apply.trace" && + assert_backoff_commit_unbound "$evidence/index.published" \ + "$evidence/transition-unbound" && + ! test_trace2_data fsmonitor history/commit-backoff-advanced 1 \ + <"$evidence/interactive.root.trace" && + ! test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/interactive.root.trace" && + assert_backoff_main_index_write "$evidence/interactive.trace" "$main_index" yes + ;; + private-p) + assert_backoff_interactive_index_env "$evidence/apply.trace" "$selected" && + assert_backoff_rejected_index_trace "$evidence/interactive.trace" && + assert_backoff_commit_unbound "$evidence/selected.published" \ + "$evidence/private-unbound" && + test_cmp "$evidence/main.identity.before" "$evidence/index.published.identity" && + assert_backoff_history_unchanged "$gitdir" "$checkpoint" && + assert_backoff_main_index_write "$evidence/interactive.trace" "$main_index" no + ;; + esac + ;; + esac && + case "$operation" in + commit-p | commit-p-mode | commit-p-delete) + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} \ + >"$evidence/committed.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^ \ + >"$evidence/committed.parent" && + test_cmp "$evidence/expected-main.tree" "$evidence/committed.tree" && + test_cmp "$evidence/head.before" "$evidence/committed.parent" && + if test "$action" != none + then + test_grep "^success$" "$evidence/completed" && + test_cmp_bin "$evidence/main.seed" "$evidence/main.in-hook.before" && + test_cmp_bin "$evidence/main.seed" "$evidence/main.in-hook.after" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.hook-before" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.hook-after" && + assert_backoff_commit_unbound "$evidence/selected.before" "$evidence/hook-before-unbound" && + assert_backoff_commit_unbound "$evidence/selected.after" "$evidence/hook-after-unbound" && + backoff_commit_entries "$evidence/selected.before" >"$evidence/hook-before.entries" && + backoff_commit_entries "$evidence/selected.after" >"$evidence/hook-after.entries" && + test_cmp_bin "$evidence/expected-main.entries" "$evidence/hook-before.entries" && + test_cmp_bin "$evidence/expected-main.entries" "$evidence/hook-after.entries" && + ! test_trace2_data fsmonitor history/commit-backoff-advanced 1 <"$evidence/hook.trace" && + ! test_trace2_data fsmonitor history/commit-backoff-restored 1 <"$evidence/hook.trace" && + if test "$action" = refresh + then + assert_backoff_rejected_index_trace "$evidence/hook.trace" + else + test_cmp_bin "$evidence/selected.before" "$evidence/selected.after" + fi + else + : + fi + ;; + *) + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.after" && + test_cmp "$evidence/head.before" "$evidence/head.after" && + ! test_trace2_data fsmonitor history/commit-backoff-advanced 1 \ + <"$evidence/interactive.trace" && + ! test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/interactive.trace" + ;; + esac && + test_cmp_bin "$evidence/checkpoint.before" "$checkpoint" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.preloadIndex=false -c core.preloadIndexBulk=false \ + --no-optional-locks status --porcelain=v2 >"$evidence/status.expected" && + case "$operation" in + commit-p | commit-p-mode | commit-p-delete) + test_must_be_empty "$evidence/status.expected" + ;; + *) : ;; + esac && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=EEEEEEEEEEEEEEEE \ + GIT_TRACE2_EVENT="$evidence/status.trace" \ + git status --porcelain=v2 >"$evidence/status.actual" && + test_cmp "$evidence/status.expected" "$evidence/status.actual" && + test_cmp_bin "$evidence/index.published" "$main_index" && + snapshot_backoff_index_identity "$main_index" >"$evidence/index.after-status.identity" && + test_cmp "$evidence/index.published.identity" "$evidence/index.after-status.identity" && + assert_backoff_main_index_write "$evidence/status.trace" "$main_index" no && + rm "$gitdir/fsmonitor--daemon.inotify-limit" && + GIT_INDEX_FILE="$main_index" GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$evidence/recovery.trace" \ + git status --porcelain=v2 >"$evidence/recovery.actual" && + test_cmp "$evidence/status.expected" "$evidence/recovery.actual" && + test_trace2_data fsm_client query/trivial-response 1 <"$evidence/recovery.trace" && + test_trace2_data fsmonitor token_closure/accepted 1 <"$evidence/recovery.trace" && + assert_backoff_full_proof "$main_index" && + cp "$main_index" "$evidence/index.recovered" && + cp "$main_index" "$evidence/recovery-oracle.index" && + backoff_commit_index "$evidence/recovery-oracle.index" write-tree \ + >"$evidence/recovered.tree" && + backoff_commit_entries "$evidence/index.recovered" >"$evidence/recovered.entries" && + test_cmp "$evidence/expected-main.tree" "$evidence/recovered.tree" && + test_cmp_bin "$evidence/expected-main.entries" "$evidence/recovered.entries" && + test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" && + snapshot_backoff_index_identity "$other_gitdir/index" \ + >"$evidence/other-index.identity.after" && + test_cmp "$evidence/other-index.identity.before" "$evidence/other-index.identity.after" && + test_grep ! '"event":"child_start".*"fsmonitor--daemon"' \ + "$evidence/interactive.trace" "$evidence/status.trace" "$evidence/recovery.trace" +) + +setup_backoff_interactive_pair () { + setup_backoff_hook_pair "$1" && + common=$(git -C "$1-main" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + write_backoff_hook_identity_helper "$common/hook-index.pl" && + write_backoff_rejected_index_helper "$common/rejected-index.pl" && + if test "$2" != none + then + install_backoff_successful_commit_hook "$1-main" + else + : + fi +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'quitting either interactive add entrypoint preserves the main proof' ' + test_config_global interactive.singleKey false && + test_config_global color.ui false && + for operation in quit-p quit-i + do + prefix="watch-backoff-interactive-$operation" && + setup_backoff_interactive_pair "$prefix" none && + for kind in main linked + do + check_backoff_interactive "$prefix" "$operation" none "$kind" "$common" || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'accepted interactive hunks retain only authenticated pending history' ' + test_config_global interactive.singleKey false && + test_config_global color.ui false && + for mode in add-p commit-p-none commit-p-noop commit-p-refresh + do + case "$mode" in + add-p) operation=add-p action=none ;; + commit-p-*) operation=commit-p action=${mode#commit-p-} ;; + esac && + prefix="watch-backoff-interactive-$mode" && + setup_backoff_interactive_pair "$prefix" "$action" && + for kind in main linked + do + check_backoff_interactive "$prefix" "$operation" "$action" "$kind" "$common" || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'interactive patching cannot authenticate a copied private index' ' + test_config_global interactive.singleKey false && + test_config_global color.ui false && + prefix=watch-backoff-interactive-private && + setup_backoff_interactive_pair "$prefix" none && + for kind in main linked + do + check_backoff_interactive "$prefix" private-p none "$kind" "$common" || + return 1 + done +' + +test_expect_success FILEMODE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'interactive commit cannot advance history across mode or membership changes' ' + test_config_global interactive.singleKey false && + test_config_global color.ui false && + for operation in commit-p-mode commit-p-delete + do + prefix="watch-backoff-interactive-$operation" && + setup_backoff_interactive_pair "$prefix" none && + for kind in main linked + do + check_backoff_interactive "$prefix" "$operation" none "$kind" "$common" || + return 1 + done || return 1 + done +' + +check_backoff_manifest_attribute () { + perl - "$1" "$2" "$3" "$4" "$(test_oid rawsz)" <<-\EOF + use strict; + use warnings; + my ($file, $flags, $name, $hash, $rawsz) = @ARGV; + open my $input, "<", $file or die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my $offset = index($index, "FSCF"); + die "missing FSCF extension\n" if $offset < 0; + my $size = unpack("N", substr($index, $offset + 4, 4)); + my $proof = substr($index, $offset + 8, $size); + my ($version, $magic, $actual_flags, $token_len, $manifest_len) = + unpack("NNNNN", substr($proof, 0, 20)); + die "invalid FSCF header\n" unless length($proof) == $size && + ($version == 1 || $version == 2) && $magic == 0x46534331 && + $actual_flags == $flags; + my $start = 20 + $token_len + (3 + ($version == 2)) * $rawsz; + die "invalid manifest extent\n" + unless $start + $manifest_len + $rawsz == length($proof); + my $manifest = substr($proof, $start, $manifest_len); + my $count = unpack("N", substr($manifest, 0, 4)); + my ($pos, $found) = (4, 0); + for (1 .. $count) { + my $len = unpack("N", substr($manifest, $pos, 4)); + my $source = ord(substr($manifest, $pos + 4, 1)); + my $digest = unpack("H*", substr($manifest, $pos + 8, $rawsz)); + $pos += 8 + $rawsz; + my $path = substr($manifest, $pos, $len); + $pos += $len; + if ($path eq $name) { + die "wrong attribute source or content\n" + unless $source == 1 && $digest eq $hash; + $found++; + } + } + die "invalid or missing attribute manifest entry\n" + unless $pos == length($manifest) && $found == 1; + EOF +} + +check_backoff_worktree_attributes_after_hook () ( + scope=$1 && + test_create_repo "watch-backoff-worktree-attrs-$scope" && + cd "watch-backoff-worktree-attrs-$scope" && + sane_unset GIT_INDEX_FILE GIT_TEST_PRELOAD_INDEX_BULK \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH && + case "$scope" in + root) attrs=.gitattributes target=target visible=visible-untracked ;; + nested) + mkdir nested && + attrs=nested/.gitattributes target=nested/target \ + visible=nested/visible-untracked + ;; + esac && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + main_index="$gitdir/index" && + evidence="$gitdir/attribute-evidence" && + mkdir "$evidence" && + : >"$gitdir/empty-attributes" && + : >"$gitdir/empty-excludes" && + git config core.attributesFile "$gitdir/empty-attributes" && + git config core.excludesFile "$gitdir/empty-excludes" && + git config core.autocrlf false && + git config core.safecrlf false && + git config core.preloadIndex false && + git config core.preloadIndexBulk false && + git config index.version 2 && + git config gc.auto 0 && + git config maintenance.auto false && + printf "%s\n" "target -text" >"$attrs" && + printf "line\r\n" >"$target" && + test_write_lines base >trigger && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + add -- "$attrs" "$target" trigger && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + commit -qm base && + git config core.untrackedCache true && + git config core.fsmonitor true && + test-tool chmtime -120 "$attrs" "$target" trigger && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C git update-index --fsmonitor && + GIT_INDEX_FILE="$main_index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git status --porcelain=v2 >"$evidence/prime" && + test_must_be_empty "$evidence/prime" && + cp "$main_index" "$evidence/index.seed" && + assert_backoff_full_proof "$evidence/index.seed" && + old_hash=$(test-tool "$test_hash_algo" <"$attrs") && + check_backoff_manifest_attribute "$evidence/index.seed" 15 "$attrs" "$old_hash" && + test_write_lines commit-change >trigger && + cp "$main_index" "$evidence/oracle.index" && + backoff_commit_index "$evidence/oracle.index" add -u && + backoff_commit_index "$evidence/oracle.index" write-tree \ + >"$evidence/expected.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/parent" && + test_hook pre-commit <<-\EOF && + set -eu + test "$GIT_INDEX_FILE" = "$BACKOFF_ATTR_MAIN.lock" + cp "$BACKOFF_ATTR_MAIN" "$BACKOFF_ATTR_EVIDENCE/main.before" + cp "$GIT_INDEX_FILE" "$BACKOFF_ATTR_EVIDENCE/selected.before" + GIT_TRACE2_EVENT="$BACKOFF_ATTR_EVIDENCE/hook.trace" \ + git add --refresh -- trigger + cp "$GIT_INDEX_FILE" "$BACKOFF_ATTR_EVIDENCE/selected.after-refresh" + printf "%s\n" "target text" >"$BACKOFF_ATTR_FILE" + cp "$GIT_INDEX_FILE" "$BACKOFF_ATTR_EVIDENCE/selected.after" + cp "$BACKOFF_ATTR_MAIN" "$BACKOFF_ATTR_EVIDENCE/main.after" + printf "%s\n" success >"$BACKOFF_ATTR_EVIDENCE/completed" + EOF + record_authenticated_backoff_marker && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=E \ + GIT_TRACE2_EVENT="$evidence/commit.trace" \ + BACKOFF_ATTR_MAIN="$main_index" BACKOFF_ATTR_FILE="$PWD/$attrs" \ + BACKOFF_ATTR_EVIDENCE="$evidence" \ + git commit -aqm "attribute-only successful hook" && + # Observe publication before any status can repair its optional metadata. + cp "$main_index" "$evidence/index.published" && + test_grep "^success$" "$evidence/completed" && + test_cmp_bin "$evidence/index.seed" "$evidence/main.before" && + test_cmp_bin "$evidence/index.seed" "$evidence/main.after" && + test_cmp_bin "$evidence/selected.after-refresh" "$evidence/selected.after" && + backoff_commit_entries "$evidence/selected.before" >"$evidence/entries.before" && + backoff_commit_entries "$evidence/selected.after" >"$evidence/entries.after" && + test_cmp_bin "$evidence/entries.before" "$evidence/entries.after" && + assert_backoff_commit_unbound "$evidence/selected.after" "$evidence/private" && + if assert_backoff_full_proof "$evidence/index.published" \ + >"$evidence/published-full.out" 2>"$evidence/published-full.err" + then + echo "backoff publication granted current cleanliness" >&2 && + return 1 + fi && + if test_trace2_data fsmonitor history/commit-backoff-restored 1 \ + <"$evidence/commit.trace" + then + check_backoff_manifest_attribute "$evidence/index.published" 9 \ + "$attrs" "$old_hash" + else + : + fi && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} \ + >"$evidence/committed.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^ \ + >"$evidence/committed.parent" && + test_cmp "$evidence/expected.tree" "$evidence/committed.tree" && + test_cmp "$evidence/parent" "$evidence/committed.parent" && + test_write_lines visible >"$visible" && + test-tool chmtime +120 "$target" && + backoff_commit_index "$main_index" hash-object --path="$target" --stdin \ + <"$target" >"$evidence/normalized.oid" && + printf "line\n" >"$evidence/expected-normalized" && + backoff_commit_index "$main_index" hash-object --no-filters --stdin \ + <"$evidence/expected-normalized" >"$evidence/expected-normalized.oid" && + test_cmp "$evidence/expected-normalized.oid" "$evidence/normalized.oid" && + backoff_commit_index "$main_index" rev-parse ":$target" \ + >"$evidence/indexed.oid" && + ! test_cmp "$evidence/indexed.oid" "$evidence/normalized.oid" && + backoff_commit_index "$main_index" status --porcelain=v2 -z \ + --untracked-files=all >"$evidence/status.expected" && + tr "\000" "\n" <"$evidence/status.expected" >"$evidence/status.lines" && + test_grep "^1 \\.M .* $attrs$" "$evidence/status.lines" && + test_grep "^1 \\.M .* $target$" "$evidence/status.lines" && + test_grep "^? $visible$" "$evidence/status.lines" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=E \ + GIT_TRACE2_EVENT="$evidence/backoff.trace" \ + git status --porcelain=v2 -z --untracked-files=all \ + >"$evidence/status.actual" && + test_cmp_bin "$evidence/status.expected" "$evidence/status.actual" && + rm .git/fsmonitor--daemon.inotify-limit && + GIT_INDEX_FILE="$main_index" GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=// \ + GIT_TRACE2_EVENT="$evidence/recovery.trace" \ + git status --porcelain=v2 -z --untracked-files=all \ + >"$evidence/recovery.actual" && + test_cmp_bin "$evidence/status.expected" "$evidence/recovery.actual" && + test_trace2_data fsm_client query/trivial-response 1 <"$evidence/recovery.trace" && + test_trace2_data fsmonitor token_closure/accepted 1 <"$evidence/recovery.trace" && + assert_backoff_full_proof "$main_index" && + new_hash=$(test-tool "$test_hash_algo" <"$attrs") && + test "$old_hash" != "$new_hash" && + check_backoff_manifest_attribute "$main_index" 15 "$attrs" "$new_hash" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git --no-optional-locks status --porcelain=v2 -z --untracked-files=all \ + >"$evidence/warm.actual" && + test_cmp_bin "$evidence/status.expected" "$evidence/warm.actual" && + test_path_is_missing "$main_index.lock" && + test_grep ! "\"event\":\"child_start\".*\"fsmonitor--daemon\"" \ + "$evidence/commit.trace" "$evidence/hook.trace" \ + "$evidence/backoff.trace" "$evidence/recovery.trace" +) + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'historical commit recovery revalidates worktree attributes and untracked files' ' + for scope in root nested + do + check_backoff_worktree_attributes_after_hook "$scope" || return 1 + done +' + test_done From 3e78474afb7a586b9814995e6fb1078f8a2f0c2a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:15:14 -0500 Subject: [PATCH 393/403] read-cache: recover serialized backoff content checks Replacing a regular file while fsmonitor history is suspended poisons its cached stat data and sets CE_CONTENT_CHECK_REQUIRED. The zeroed stat tuple survives an index write, but that flag is intentionally memory-only. A later process can therefore mistake the poisoned tuple for an ordinary stat mismatch. In a scoped stash, add -u stages the correct worktree blob, then apply --index -R rejects that content-equal entry. Teach the shared stat/content-check wrapper to recognize a fully zeroed stat tuple in an authenticated suspended epoch. Use the existing ie_modified() path to verify content and refresh the stat data only after a match. Keep the gitlink bypass and ordinary unmarked zero-stat behavior unchanged. This recovers an obligation to check content, not authority to declare the worktree clean. The scoped-stash regression exercises the process boundary. Existing zero-stat and fsmonitor-content-recovery tests cover the ordinary and explicitly invalidated cases. --- read-cache-ll.h | 5 +++-- read-cache.c | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/read-cache-ll.h b/read-cache-ll.h index 35f1e97ba6f147..16df8c24801d87 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -507,8 +507,9 @@ int has_racy_timestamp(struct index_state *istate); int ie_match_stat(struct index_state *, const struct cache_entry *, struct stat *, unsigned int); int ie_modified(struct index_state *, const struct cache_entry *, struct stat *, unsigned int); /* - * Unlike ie_match_stat(), verify content for marked non-gitlinks. Ordinary - * entries, including unmarked zero-stat entries, retain stat-only matching. + * Unlike ie_match_stat(), verify content for marked non-gitlinks and poisoned + * entries in an authenticated suspended fsmonitor epoch. Ordinary entries, + * including unmarked zero-stat entries, retain stat-only matching. */ int ie_match_stat_with_content_check(struct index_state *, const struct cache_entry *, diff --git a/read-cache.c b/read-cache.c index 9648b1c29d531b..4e06c1fe0a3deb 100644 --- a/read-cache.c +++ b/read-cache.c @@ -574,11 +574,20 @@ int ie_match_stat_with_content_check(struct index_state *istate, const struct cache_entry *ce, struct stat *st, unsigned int options) { + const struct stat_data empty = { 0 }; struct cache_entry *current; int changed, pos; + /* + * A suspended replacement persists its poisoned stat data, but not the + * transient content-check flag. Recover that obligation only from an + * authenticated suspended epoch; ordinary zero-stat entries keep their + * stat-only matching behavior. + */ if (S_ISGITLINK(ce->ce_mode) || - !(ce->ce_flags & CE_CONTENT_CHECK_REQUIRED)) + (!(ce->ce_flags & CE_CONTENT_CHECK_REQUIRED) && + (!clean_status_fsmonitor_backoff_suspended(istate) || + memcmp(&ce->ce_stat_data, &empty, sizeof(empty))))) return ie_match_stat(istate, ce, st, options); changed = ie_modified(istate, ce, st, options); From 537ca5698e3d0d83515c2daafe9bbd08c0fe9289 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:15:27 -0500 Subject: [PATCH 394/403] t7536: respect interactive index spelling and hook scope Interactive add exports the repository's selected index path as-is. In a primary worktree that can be .git/index rather than the absolute path used by the publication oracle. Derive the expected child spelling with rev-parse --git-path index, keeping the physical-index and private-lock assertions unchanged. The worktree-attributes fixture runs in a subshell, where test_hook cannot register its cleanup. Write the hook directly into the disposable repository instead. Both corrections pass against the unchanged candidate binary; neither changes the production behavior or proof assertions. --- t/t7536-fsmonitor-watch-limit-backoff.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 0161ba254c71a5..55f9d26c7e6ded 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -3172,7 +3172,9 @@ check_backoff_interactive () ( <"$evidence/apply.trace" && case "$operation" in add-p) - assert_backoff_interactive_index_env "$evidence/apply.trace" "$main_index" && + index_env=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --git-path index) && + assert_backoff_interactive_index_env "$evidence/apply.trace" "$index_env" && test_trace2_data fsmonitor history/watch-limit-suspended 1 \ <"$evidence/apply.trace" && assert_backoff_pending_proof "$evidence/main.seed" "$evidence/index.published" && @@ -3479,7 +3481,7 @@ check_backoff_worktree_attributes_after_hook () ( >"$evidence/expected.tree" && git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ >"$evidence/parent" && - test_hook pre-commit <<-\EOF && + write_script "$gitdir/hooks/pre-commit" <<-\EOF && set -eu test "$GIT_INDEX_FILE" = "$BACKOFF_ATTR_MAIN.lock" cp "$BACKOFF_ATTR_MAIN" "$BACKOFF_ATTR_EVIDENCE/main.before" From b1e7bfb4b538aa855da0213bb3d0dc76425b641d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:52:41 -0500 Subject: [PATCH 395/403] stash: prepare authenticated history before reading the index 0d1e07f756 (fsmonitor: prepare history before canonical index reads, 2026-08-18) teaches scoped stash pushes to retain suspended fsmonitor history, but limits that setup to an ordinary push. A patch selection also refreshes the real index before prompting. During watch-limit backoff, even quitting the prompt can therefore discard FSMN and FSUC without changing the logical index. Stash apply and pop have the same problem when GIT_INDEX_FILE explicitly names the real main index. Their initial refresh rejects that spelling before any of the requested changes are applied. Use the shared physical-main initializer before either operation reads the index. Keep read admission independent of the eventual stash mode; the existing writers still validate their changes, and whole-worktree operations retain their conservative invalidation. Cover cancelled and accepted patch selections, canonical apply and pop, and private-index rejection. --- builtin/stash.c | 22 +- t/t7536-fsmonitor-watch-limit-backoff.sh | 482 +++++++++++++++++++++++ 2 files changed, 488 insertions(+), 16 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 38fc0a32368106..f20d335b8a20ed 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -682,11 +682,7 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, struct tree *head, *merge, *merge_base; struct lock_file lock = LOCK_INIT; - if (!getenv(INDEX_ENVIRONMENT)) { - clean_status_enable_external_history(the_repository); - clean_status_set_config_digest(the_repository, - &stash_clean_digest); - } + clean_status_prepare_main_index_history(the_repository); repo_read_index_preload(the_repository, NULL, 0); if (repo_refresh_and_write_index(the_repository, REFRESH_QUIET, 0, 0, @@ -1753,18 +1749,12 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q } /* - * Keep authenticated history bound while inspecting the worktree. - * Whole-worktree changes still invalidate their proof below. A scoped - * regular-file replacement keeps it only when each writer proves that - * its provider, semantic inputs, and untracked cache remain paired. + * Even a cancelled patch selection can refresh the real index. Attach + * authenticated history before that first read, independently of the + * eventual stash operation. Whole-worktree changes still invalidate + * their proof below, and each writer must validate its own changes. */ - if (preserve_clean_history) { - clean_status_enable_external_history(the_repository); - clean_status_set_config_digest(the_repository, - &stash_clean_digest); - } else if (ps->nr && !include_untracked && !patch_mode && - !only_staged && keep_index != 1) - clean_status_prepare_main_index_history(the_repository); + clean_status_prepare_main_index_history(the_repository); repo_read_index_preload(the_repository, NULL, 0); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 55f9d26c7e6ded..89c9452179c19c 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -3586,4 +3586,486 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done ' +assert_backoff_stash_patch_history () { + assert_backoff_full_proof "$1" && + if assert_backoff_full_proof "$2" >"$3.out" 2>"$3.err" + then + # A retained FULL proof must still name the original provider token. + perl - "$1" "$2" <<-\EOF + use strict; + use warnings; + my @tokens; + for my $path (@ARGV) { + open my $input, "<", $path or die "cannot read index: $!\n"; + binmode $input; + local $/; + my $index = <$input>; + my $offset = index($index, "FSMN"); + die "missing FSMN\n" if $offset < 0; + my $start = $offset + 12; + my $end = index($index, "\0", $start); + die "invalid FSMN token\n" if $end < 0; + push @tokens, substr($index, $start, $end - $start); + } + die "stash advanced a suspended provider token\n" + unless $tokens[0] eq $tokens[1]; + EOF + else + assert_backoff_pending_proof "$1" "$2" + fi +} + +check_backoff_stash_patch () ( + prefix=$1 && action=$2 && kind=$3 && common=$4 && + case "$kind" in + main) + other_gitdir=$(git -C "$prefix-linked" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) + ;; + linked) other_gitdir=$common ;; + esac && + cd "$prefix-$kind" && + sane_unset GIT_INDEX_FILE GIT_TEST_PRELOAD_INDEX_BULK \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + main_index="$gitdir/index" && + checkpoint=$(cat "$gitdir/checkpoints") && + evidence="$common/stash-patch-$action-$kind" && + mkdir "$evidence" && + cp "$main_index" "$evidence/index.seed" && + cp "$other_gitdir/index" "$evidence/other-index.before" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.before" && + snapshot_backoff_index_identity "$other_gitdir/index" \ + >"$evidence/other-index.identity.before" && + assert_backoff_full_proof "$evidence/index.seed" && + backoff_scoped_index_tree "$evidence/index.seed" "$evidence/expected-main" && + backoff_commit_entries "$evidence/index.seed" >"$evidence/expected.entries" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.before" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" >"$evidence/refs.before" && + git -c core.fsmonitor=false show HEAD:tracked >"$evidence/head-tracked" && + test_write_lines stash-selected >tracked && + cp tracked "$evidence/worktree-tracked.before" && + cp sibling "$evidence/sibling.before" && + test_write_lines untracked-visible >visible && + cp visible "$evidence/visible.before" && + backoff_commit_index "$main_index" status --porcelain=v2 >"$evidence/status.before" && + selected=$main_index && + case "$action" in + accept) + cp "$evidence/index.seed" "$evidence/worktree-oracle.index" && + backoff_commit_index "$evidence/worktree-oracle.index" read-tree HEAD && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ + <"$evidence/worktree-tracked.before") && + backoff_commit_index "$evidence/worktree-oracle.index" \ + update-index --cacheinfo "100644,$oid,tracked" && + backoff_commit_index "$evidence/worktree-oracle.index" write-tree \ + >"$evidence/expected-stash-worktree.tree" && + answer=y + ;; + private-quit) + selected="$gitdir/index.alias-copy" && + cp "$evidence/index.seed" "$selected" && + perl "$common/rejected-index.pl" alias copy "$selected" "$main_index" \ + >"$evidence/private.identity.before" && + answer=q + ;; + quit) answer=q ;; + *) return 1 ;; + esac && + test_cmp_bin "$evidence/index.seed" "$main_index" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + test_path_is_file "$gitdir/fsmonitor--daemon.inotify-limit" && + test_write_lines "$answer" >"$evidence/input" && + ( + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=EEEEEEEEEEEEEEEE && + GIT_TRACE2_EVENT="$evidence/stash.trace" && + GIT_TRACE2_ENV_VARS=GIT_INDEX_FILE && + export GIT_TEST_FSMONITOR_INOTIFY_BACKOFF \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TRACE2_EVENT GIT_TRACE2_ENV_VARS && + if test "$action" = private-quit + then + GIT_INDEX_FILE=$selected && export GIT_INDEX_FILE + else + sane_unset GIT_INDEX_FILE + fi && + if test "$action" = accept + then + git stash push -p -- tracked + else + test_expect_code 1 git stash push -p -- tracked + fi <"$evidence/input" >"$evidence/stash.out" 2>"$evidence/stash.err" + ) && + # Retain the publication before any status or other Git command. + cp "$main_index" "$evidence/index.published" && + cp "$selected" "$evidence/selected.published" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after" && + test_grep "Stash this hunk" "$evidence/stash.out" && + test_path_is_missing "$main_index.lock" && + test_path_is_missing "$selected.lock" && + backoff_scoped_index_tree "$evidence/index.published" "$evidence/published" && + backoff_commit_entries "$evidence/index.published" >"$evidence/published.entries" && + backoff_commit_entries "$evidence/selected.published" >"$evidence/selected.entries" && + test_cmp "$evidence/expected-main.tree" "$evidence/published.tree" && + test_cmp_bin "$evidence/expected.entries" "$evidence/published.entries" && + test_cmp_bin "$evidence/expected.entries" "$evidence/selected.entries" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ + >"$evidence/head.after" && + test_cmp "$evidence/head.before" "$evidence/head.after" && + test_cmp "$evidence/sibling.before" sibling && + test_cmp "$evidence/visible.before" visible && + extract_backoff_root_trace "$evidence/stash.trace" >"$evidence/stash.root.trace" && + ! test_trace2_data fsmonitor token_closure/accepted 1 <"$evidence/stash.trace" && + case "$action" in + quit | private-quit) + test_grep "^No changes selected$" "$evidence/stash.err" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(refname) %(objectname)" >"$evidence/refs.after" && + test_cmp "$evidence/refs.before" "$evidence/refs.after" && + test_cmp "$evidence/worktree-tracked.before" tracked && + backoff_commit_index "$main_index" status --porcelain=v2 >"$evidence/status.after" && + test_cmp "$evidence/status.before" "$evidence/status.after" + ;; + accept) + git -c core.fsmonitor=false --no-optional-locks rev-parse stash^{tree} \ + >"$evidence/stash-worktree.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse stash^2^{tree} \ + >"$evidence/stash-index.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse stash^1 \ + >"$evidence/stash-parent" && + test_cmp "$evidence/expected-stash-worktree.tree" "$evidence/stash-worktree.tree" && + test_cmp "$evidence/expected-main.tree" "$evidence/stash-index.tree" && + test_cmp "$evidence/head.before" "$evidence/stash-parent" && + test_cmp "$evidence/head-tracked" tracked + ;; + esac && + if test "$action" = private-quit + then + assert_backoff_rejected_index_trace "$evidence/stash.root.trace" && + test_cmp_bin "$evidence/index.seed" "$evidence/index.published" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.after" && + # An untouched copied proof is not an authenticated private proof. + if cmp "$evidence/index.seed" "$evidence/selected.published" + then + : + else + assert_backoff_commit_unbound "$evidence/selected.published" \ + "$evidence/private-proof" + fi + else + assert_backoff_stash_patch_history "$evidence/index.seed" \ + "$evidence/index.published" "$evidence/published-proof" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 \ + <"$evidence/stash.root.trace" + fi && + assert_backoff_checkpoint_unchanged "$gitdir" "$checkpoint" && + test_cmp_bin "$evidence/index.published" "$main_index" && + test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" && + snapshot_backoff_index_identity "$other_gitdir/index" \ + >"$evidence/other-index.identity.after" && + test_cmp "$evidence/other-index.identity.before" "$evidence/other-index.identity.after" && + test_grep ! '"event":"child_start".*"fsmonitor--daemon"' "$evidence/stash.trace" && + backoff_scoped_recover "$gitdir" "$evidence" "$evidence/expected-main.tree" && + test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" +) + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'patch stash preserves suspended history before and after selection' ' + test_config_global interactive.singleKey false && + test_config_global color.ui false && + for action in quit accept + do + prefix="watch-backoff-stash-patch-$action" && + setup_backoff_interactive_pair "$prefix" none && + for kind in main linked + do + check_backoff_stash_patch "$prefix" "$action" "$kind" "$common" || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'patch stash cannot authenticate a copied private index' ' + test_config_global interactive.singleKey false && + test_config_global color.ui false && + prefix=watch-backoff-stash-patch-private && + setup_backoff_interactive_pair "$prefix" none && + for kind in main linked + do + check_backoff_stash_patch "$prefix" private-quit "$kind" "$common" || + return 1 + done +' + +setup_backoff_stash_apply_pair () { + test_create_repo "$1-main" && + test_when_finished "git -C \"$1-main\" -c core.fsmonitor=false \ + worktree remove --force \"../$1-linked\" >/dev/null 2>&1 || :" && + ( + cd "$1-main" && + test_commit base tracked && + test_commit sibling sibling && + git -c core.fsmonitor=false worktree add --detach "../$1-linked" HEAD && + git config core.autocrlf false && + git config core.preloadIndex false && + git config core.untrackedCache true && + git config core.fsmonitor true && + for worktree in "$PWD" "$PWD/../$1-linked" + do + gitdir=$(git -C "$worktree" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + test-tool chmtime -120 "$worktree/tracked" "$worktree/sibling" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" update-index --refresh && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$worktree" update-index --fsmonitor && + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status --porcelain=v2 >"$gitdir/prime" && + test_must_be_empty "$gitdir/prime" && + assert_backoff_full_proof "$gitdir/index" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ + git -C "$worktree" status --short >"$gitdir/checkpoint.status" && + test_trace2_data fsmonitor history/external-stored 1 <"$gitdir/checkpoint.trace" && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$worktree" status >"$gitdir/sidecar.status" && + find "$gitdir" -maxdepth 1 -type f -name "index.csh1.*" >"$gitdir/checkpoints" && + test_line_count = 1 "$gitdir/checkpoints" && + checkpoint=$(cat "$gitdir/checkpoints") && + cp "$gitdir/index" "$gitdir/index.before-backoff" && + cp "$checkpoint" "$gitdir/checkpoint.before-backoff" && + if test -f "$gitdir/index.csts" + then + cp "$gitdir/index.csts" "$gitdir/sidecar.before-backoff" + else + : + fi || return 1 + done && + make_backoff_patch_series same-path + ) && + common=$(git -C "$1-main" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + write_backoff_hook_identity_helper "$common/hook-index.pl" && + write_backoff_rejected_index_helper "$common/rejected-index.pl" && + install_backoff_stash_apply_capture "$1-main" +} + +install_backoff_stash_apply_capture () { + test_hook -C "$1" post-index-change <<-\EOF + set -eu + test -n "${BACKOFF_STASH_CAPTURE-}" || exit 0 + first=$BACKOFF_STASH_CAPTURE/first-write + if test -d "$first" + then + exit 0 + fi + mkdir "$first" + test "${GIT_INDEX_FILE-}" = "$BACKOFF_STASH_INDEX_ENV" + perl "$BACKOFF_STASH_IDENTITY" identity "$BACKOFF_STASH_MAIN" \ + >"$first/main.identity" + perl "$BACKOFF_STASH_IDENTITY" identity "$BACKOFF_STASH_SELECTED" \ + >"$first/selected.identity" + cp "$BACKOFF_STASH_MAIN" "$first/main.index" + cp "$BACKOFF_STASH_SELECTED" "$first/selected.index" + printf "%s\n" "${GIT_INDEX_FILE-}" >"$first/index.env" + printf "%s\n" "$@" >"$first/hook.args" + cp "$GIT_TRACE2_EVENT" "$first/trace" + printf "%s\n" complete >"$first/completed" + EOF +} + +check_backoff_stash_apply () ( + prefix=$1 && style=$2 && operation=$3 && kind=$4 && common=$5 && + case "$kind" in + main) + other_gitdir=$(git -C "$prefix-linked" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) + ;; + linked) other_gitdir=$common ;; + esac && + cd "$prefix-$kind" && + sane_unset GIT_INDEX_FILE GIT_TEST_PRELOAD_INDEX_BULK \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH \ + BACKOFF_STASH_CAPTURE BACKOFF_STASH_MAIN BACKOFF_STASH_SELECTED \ + BACKOFF_STASH_INDEX_ENV BACKOFF_STASH_IDENTITY && + gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ + rev-parse --absolute-git-dir) && + main_index="$gitdir/index" && + evidence="$common/stash-$operation-$style-$kind" && + mkdir "$evidence" && + cp "$main_index" "$evidence/index.seed" && + cp "$other_gitdir/index" "$evidence/other-index.before" && + snapshot_backoff_index_identity "$other_gitdir/index" \ + >"$evidence/other-index.identity.before" && + assert_backoff_full_proof "$evidence/index.seed" && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + test-tool fsmonitor-client record-watch-limit && + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + GIT_TRACE2_EVENT="$evidence/am.trace" \ + git am -q "$common/patch-first.mbox" >"$evidence/am.out" && + cp "$main_index" "$evidence/index.pending" && + assert_backoff_pending_proof "$evidence/index.seed" "$evidence/index.pending" && + backoff_scoped_index_tree "$evidence/index.pending" "$evidence/before" && + test_cmp "$common/patch-first.tree" "$evidence/before.tree" && + backoff_commit_entries "$evidence/index.pending" >"$evidence/before.entries" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.before" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD >"$evidence/head.before" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} >"$evidence/head.tree" && + test_cmp "$common/patch-first.tree" "$evidence/head.tree" && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(objectname)" refs/stash >"$evidence/stash.prior" && + donor="$TRASH_DIRECTORY/$prefix-$kind-donor" && + git -c core.fsmonitor=false -c core.untrackedCache=false \ + worktree add --detach "$donor" "$(cat "$evidence/head.before")" && + cp "$common/patch-second.contents" "$donor/sibling" && + git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false add sibling && + git -C "$donor" -c core.fsmonitor=false write-tree >"$evidence/donor.tree" && + test_cmp "$common/patch-second.tree" "$evidence/donor.tree" && + donor_gitdir=$(git -C "$donor" -c core.fsmonitor=false \ + --no-optional-locks rev-parse --absolute-git-dir) && + cp "$donor_gitdir/index" "$evidence/donor.index" && + backoff_commit_entries "$evidence/donor.index" >"$evidence/donor.entries" && + git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false \ + stash push --quiet -- sibling && + git -c core.fsmonitor=false --no-optional-locks rev-parse refs/stash >"$evidence/stash.created" && + stash=$(cat "$evidence/stash.created") && + git -c core.fsmonitor=false --no-optional-locks rev-parse "$stash^1" >"$evidence/stash.parent" && + git -c core.fsmonitor=false --no-optional-locks rev-parse "$stash^{tree}" >"$evidence/stash.tree" && + git -c core.fsmonitor=false --no-optional-locks rev-parse "$stash^2^{tree}" >"$evidence/stash-index.tree" && + test_cmp "$evidence/head.before" "$evidence/stash.parent" && + test_cmp "$evidence/donor.tree" "$evidence/stash.tree" && + test_cmp "$evidence/donor.tree" "$evidence/stash-index.tree" && + git -c core.fsmonitor=false worktree remove --force "$donor" && + test_cmp_bin "$evidence/index.pending" "$main_index" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after-donor" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.after-donor" && + test_write_lines visible >visible && + selected=$main_index && selected_env= && + case "$style" in + implicit) : ;; + canonical) selected_env=$main_index ;; + alternate) + selected="$gitdir/index.alias-copy" && selected_env=$selected && + cp "$evidence/index.pending" "$selected" && + perl "$common/rejected-index.pl" alias copy "$selected" "$main_index" \ + >"$evidence/private.identity.before" + ;; + *) return 1 ;; + esac && + test_write_lines "$selected_env" >"$evidence/expected-index.env" && + ( + GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=EEEEEEEEEEEEEEEE && + GIT_TRACE2_EVENT="$evidence/stash.trace" && + GIT_TRACE2_ENV_VARS=GIT_INDEX_FILE && + BACKOFF_STASH_CAPTURE=$evidence BACKOFF_STASH_MAIN=$main_index && + BACKOFF_STASH_SELECTED=$selected BACKOFF_STASH_INDEX_ENV=$selected_env && + BACKOFF_STASH_IDENTITY="$common/hook-index.pl" && + export GIT_TEST_FSMONITOR_INOTIFY_BACKOFF \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TRACE2_EVENT GIT_TRACE2_ENV_VARS \ + BACKOFF_STASH_CAPTURE BACKOFF_STASH_MAIN BACKOFF_STASH_SELECTED \ + BACKOFF_STASH_INDEX_ENV BACKOFF_STASH_IDENTITY && + if test "$style" = implicit + then + sane_unset GIT_INDEX_FILE + else + GIT_INDEX_FILE=$selected && export GIT_INDEX_FILE + fi && + git stash "$operation" --index >"$evidence/stash.out" 2>"$evidence/stash.err" + ) && + # Later merge/reset writes may revoke metadata. Capture before any repair. + cp "$main_index" "$evidence/main.published" && + cp "$selected" "$evidence/selected.published" && + snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after" && + first="$evidence/first-write" && + test_grep "^complete$" "$first/completed" && + test_cmp "$evidence/expected-index.env" "$first/index.env" && + backoff_scoped_index_tree "$first/selected.index" "$evidence/first" && + backoff_commit_entries "$first/selected.index" >"$evidence/first.entries" && + test_cmp "$evidence/before.tree" "$evidence/first.tree" && + test_cmp_bin "$evidence/before.entries" "$evidence/first.entries" && + assert_backoff_main_index_write "$first/trace" "$selected" yes && + extract_backoff_root_trace "$first/trace" >"$evidence/first.root.trace" && + if test "$style" = alternate + then + assert_backoff_commit_unbound "$first/selected.index" "$evidence/private-first-proof" && + assert_backoff_rejected_index_trace "$evidence/first.root.trace" && + test_cmp_bin "$evidence/index.pending" "$first/main.index" && + test_cmp_bin "$evidence/index.pending" "$evidence/main.published" && + test_cmp "$evidence/main.identity.before" "$evidence/main.identity.after" && + expected_main="$evidence/before.tree" + else + assert_backoff_pending_proof "$evidence/index.seed" "$first/selected.index" && + test_trace2_data fsmonitor history/watch-limit-suspended 1 <"$evidence/first.root.trace" && + expected_main="$evidence/donor.tree" + fi && + backoff_scoped_index_tree "$evidence/main.published" "$evidence/main-final" && + backoff_scoped_index_tree "$evidence/selected.published" "$evidence/selected-final" && + backoff_commit_entries "$evidence/selected.published" >"$evidence/selected-final.entries" && + test_cmp "$expected_main" "$evidence/main-final.tree" && + test_cmp "$evidence/donor.tree" "$evidence/selected-final.tree" && + test_cmp_bin "$evidence/donor.entries" "$evidence/selected-final.entries" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD >"$evidence/head.after" && + test_cmp "$evidence/head.before" "$evidence/head.after" && + test_cmp "$common/patch-first.contents" tracked && + test_cmp "$common/patch-second.contents" sibling && + test_grep "^visible$" visible && + git -c core.fsmonitor=false --no-optional-locks \ + for-each-ref --format="%(objectname)" refs/stash >"$evidence/stash.after" && + if test "$operation" = apply + then + test_cmp "$evidence/stash.created" "$evidence/stash.after" + else + test_cmp "$evidence/stash.prior" "$evidence/stash.after" + fi && + test_cmp_bin "$evidence/main.published" "$main_index" && + test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" && + snapshot_backoff_index_identity "$other_gitdir/index" >"$evidence/other-index.identity.after" && + test_cmp "$evidence/other-index.identity.before" "$evidence/other-index.identity.after" && + test_grep ! '"event":"child_start".*"fsmonitor--daemon"' "$evidence/am.trace" "$evidence/stash.trace" && + backoff_scoped_recover "$gitdir" "$evidence" "$expected_main" && + test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" && + if test "$style" = alternate + then + test_cmp_bin "$evidence/selected.published" "$selected" + else + : + fi +) + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'canonical and implicit stash apply preserve the initial backoff refresh' ' + for style in implicit canonical + do + prefix="watch-backoff-stash-apply-$style" && + setup_backoff_stash_apply_pair "$prefix" && + for kind in main linked + do + check_backoff_stash_apply "$prefix" "$style" apply "$kind" "$common" || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'stash pop shares canonical admission and private stash apply stays rejected' ' + for mode in canonical-pop alternate-apply + do + case "$mode" in + canonical-pop) style=canonical operation=pop ;; + alternate-apply) style=alternate operation=apply ;; + esac && + prefix="watch-backoff-stash-$mode" && + setup_backoff_stash_apply_pair "$prefix" && + check_backoff_stash_apply "$prefix" "$style" "$operation" main "$common" || + return 1 + done +' test_done From 44e7848d3e3d333da0979e820fa614440e060077 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 14:16:29 -0500 Subject: [PATCH 396/403] exclude: use the empty blob for an empty captured source 217941b343 (dir: capture ignore sources beneath anchored parents, 2026-07-21) records a present but empty ignore file with a NULL buffer and zero length. The proof-capture interface accepts that representation but passes it to hash_object_file() after checking the source metadata. The block SHA-256 implementation then passes NULL to memcpy() while finishing the buffered blob header. UBSan rejects that call even though there are no content bytes to copy. Use the repository hash algorithm's empty-blob object ID when the captured size is zero. Keep the descriptor and namespace checks, the distinction between an empty file and a missing file, and the rejection of NULL with a nonzero size. Exercise those cases with both SHA-1 and SHA-256. --- exclude-source-proof.c | 7 ++- t/unit-tests/u-exclude-source-proof.c | 67 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/exclude-source-proof.c b/exclude-source-proof.c index c46452b35c209a..266e5f57b95267 100644 --- a/exclude-source-proof.c +++ b/exclude-source-proof.c @@ -364,8 +364,11 @@ void exclude_source_capture_record( proof->invalid = 1; return; } - hash_object_file(proof->istate->repo->hash_algo, buf, size, - OBJ_BLOB, &oid); + if (!size) + oidcpy(&oid, proof->istate->repo->hash_algo->empty_blob); + else + hash_object_file(proof->istate->repo->hash_algo, buf, size, + OBJ_BLOB, &oid); record_observation(capture, 1, size, &oid); } diff --git a/t/unit-tests/u-exclude-source-proof.c b/t/unit-tests/u-exclude-source-proof.c index d081549ab5fd63..b822fc088fbc9b 100644 --- a/t/unit-tests/u-exclude-source-proof.c +++ b/t/unit-tests/u-exclude-source-proof.c @@ -165,6 +165,72 @@ void test_exclude_source_proof__cleanup(void) FREE_AND_NULL(trash); } +static void check_empty_source(const struct git_hash_algo *algo) +{ + struct repository test_repo = { .hash_algo = algo }; + struct index_state test_istate = { .repo = &test_repo }; + struct exclude_source_proof *proof; + struct exclude_source_capture *capture; + struct object_id empty, missing, nonempty; + struct stat st; + char *source = make_path(algo->name); + int fd; + + write_file_buf(source, "", 0); + proof = exclude_source_proof_create(&test_istate, NULL, open_parent, 0); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + cl_assert(S_ISREG(st.st_mode) && !st.st_size); + /* The pattern reader represents a present empty source as NULL/0. */ + exclude_source_capture_record(capture, fd, &st, NULL, 0); + exclude_source_capture_release(capture); + cl_must_pass(close(fd)); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(exclude_source_proof_digest(proof, algo, &empty)); + + cl_must_pass(unlink(source)); + cl_assert(!exclude_source_proof_validate(proof)); + exclude_source_proof_release(proof); + proof = exclude_source_proof_create(&test_istate, NULL, open_parent, 0); + record_absence(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(exclude_source_proof_digest(proof, algo, &missing)); + cl_assert(!oideq(&empty, &missing)); + + write_file_buf(source, "content", 7); + cl_assert(!exclude_source_proof_validate(proof)); + exclude_source_proof_release(proof); + proof = exclude_source_proof_create(&test_istate, NULL, open_parent, 0); + record_file(proof, source); + cl_assert(exclude_source_proof_validate(proof)); + cl_must_pass(exclude_source_proof_digest(proof, algo, &nonempty)); + cl_assert(!oideq(&empty, &nonempty)); + cl_assert(!oideq(&missing, &nonempty)); + exclude_source_proof_release(proof); + + proof = exclude_source_proof_create(&test_istate, NULL, open_parent, 0); + capture = exclude_source_capture_begin(proof, source, 0); + cl_assert(capture != NULL); + fd = exclude_source_capture_open(capture); + cl_must_pass(fd); + cl_must_pass(fstat(fd, &st)); + exclude_source_capture_record(capture, fd, &st, NULL, 7); + cl_assert(!exclude_source_proof_validate(proof)); + cl_must_pass(close(fd)); + exclude_source_capture_release(capture); + exclude_source_proof_release(proof); + free(source); +} + +void test_exclude_source_proof__distinguishes_empty_missing_and_nonempty_sources(void) +{ + check_empty_source(&hash_algos[GIT_HASH_SHA1]); + check_empty_source(&hash_algos[GIT_HASH_SHA256]); +} + void test_exclude_source_proof__accepts_same_content_replacement(void) { struct exclude_source_proof *proof = new_proof(); @@ -778,6 +844,7 @@ void test_exclude_source_proof__captures_fifo_without_blocking(void) EMPTY_TEST(test_exclude_source_proof__initialize) EMPTY_TEST(test_exclude_source_proof__cleanup) +SKIP_TEST(test_exclude_source_proof__distinguishes_empty_missing_and_nonempty_sources) SKIP_TEST(test_exclude_source_proof__accepts_same_content_replacement) SKIP_TEST(test_exclude_source_proof__accepts_sibling_churn_during_regular_capture) SKIP_TEST(test_exclude_source_proof__accepts_sibling_churn_during_regular_validation) From e122952437558094362719be77f44c7290f7fde3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 14:23:57 -0500 Subject: [PATCH 397/403] stash: defer history invalidation until selection succeeds 60077b92ac (status: preserve semantic history across scoped and index changes, 2026-08-11) invalidates the current clean-status proof before saving a whole-worktree stash. That is too early for operations which may select nothing. Quitting an unscoped stash -p, or using --staged when only unstaged changes exist, drops the pending untracked history without changing the index or worktree. Keep the initial refresh authenticated, and defer this invalidation for patch and staged-only stashes until a nonempty selection succeeds. A successful patch selection leaves its private index in memory, so lock and reread the original selected index before invalidating and writing it. Real whole-worktree mutations retain their conservative invalidation before the stash is published or the worktree is changed. Cover both no-op paths, accepted selections with an independently staged sibling, and copied private indexes in primary and linked worktrees. --- builtin/stash.c | 29 ++- t/t7536-fsmonitor-watch-limit-backoff.sh | 216 ++++++++++++++++++----- 2 files changed, 197 insertions(+), 48 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index f20d335b8a20ed..493ad9f0650a75 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1788,7 +1788,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q printf_ln(_("No local changes to save")); goto done; } - if (preserve_clean_history) { + if (preserve_clean_history && !(patch_mode || only_staged)) { clean_status_invalidate_current_proof(the_repository->index); if (clean_status_should_write_fsmonitor_config( the_repository->index)) @@ -1814,6 +1814,33 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q ret = -1; goto done; } + if (preserve_clean_history && (patch_mode || only_staged)) { + /* + * A cancelled selection has not changed the worktree. Invalidate + * only after it succeeds, but before publishing the stash. Patch + * selection may have loaded its private index into repo->index, + * so reread the original selected index under a fresh lock. + */ + if (repo_hold_locked_index(the_repository, &index_lock, + LOCK_REPORT_ON_ERROR) < 0) { + ret = error(_("could not write index")); + goto done; + } + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) { + ret = error(_("could not read index")); + goto done; + } + clean_status_invalidate_current_proof(the_repository->index); + if (clean_status_should_write_fsmonitor_config( + the_repository->index)) + the_repository->index->cache_changed |= FSMONITOR_CHANGED; + if (write_locked_index(the_repository->index, &index_lock, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) { + ret = error(_("could not write index")); + goto done; + } + } if (do_store_stash(&info.w_commit, stash_msg_buf.buf, 1)) { ret = -1; diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 89c9452179c19c..68e0f60eba4ff8 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -1245,6 +1245,10 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ' setup_backoff_hook_pair () { + case "${2-staged}" in + staged | clean) : ;; + *) return 1 ;; + esac && test_create_repo "$1-main" && test_when_finished "git -C \"$1-main\" -c core.fsmonitor=false \ worktree remove --force \"../$1-linked\" >/dev/null 2>&1 || :" && @@ -1261,8 +1265,13 @@ setup_backoff_hook_pair () { do gitdir=$(git -C "$worktree" -c core.fsmonitor=false \ --no-optional-locks rev-parse --absolute-git-dir) && - test_write_lines staged-before >"$worktree/sibling" && - git -C "$worktree" -c core.fsmonitor=false add sibling && + if test "${2-staged}" = staged + then + test_write_lines staged-before >"$worktree/sibling" && + git -C "$worktree" -c core.fsmonitor=false add sibling + else + : + fi && git -C "$worktree" -c core.fsmonitor=false write-tree \ >"$gitdir/hook.expected-index-tree" && test-tool chmtime -120 "$worktree/tracked" "$worktree/sibling" && @@ -1277,7 +1286,12 @@ setup_backoff_hook_pair () { -c core.untrackedCache=false --no-optional-locks \ status --porcelain=v2 >"$gitdir/prime.expect" && test_cmp "$gitdir/prime.expect" "$gitdir/prime" && - test_grep "^1 M\\. .* sibling$" "$gitdir/prime" && + if test "${2-staged}" = clean + then + test_must_be_empty "$gitdir/prime" + else + test_grep "^1 M\\. .* sibling$" "$gitdir/prime" + fi && assert_backoff_full_proof "$gitdir/index" && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ @@ -3303,7 +3317,7 @@ check_backoff_interactive () ( ) setup_backoff_interactive_pair () { - setup_backoff_hook_pair "$1" && + setup_backoff_hook_pair "$1" "${3-staged}" && common=$(git -C "$1-main" -c core.fsmonitor=false \ --no-optional-locks rev-parse --absolute-git-dir) && write_backoff_hook_identity_helper "$common/hook-index.pl" && @@ -3615,8 +3629,14 @@ assert_backoff_stash_patch_history () { fi } -check_backoff_stash_patch () ( +check_backoff_stash_selection () ( prefix=$1 && action=$2 && kind=$3 && common=$4 && + scope=$5 && operation=$6 && + case "$operation:$action" in + patch:quit | patch:accept | patch:private-quit | \ + staged:empty | staged:accept | staged:private-empty) : ;; + *) return 1 ;; + esac && case "$kind" in main) other_gitdir=$(git -C "$prefix-linked" -c core.fsmonitor=false \ @@ -3631,7 +3651,7 @@ check_backoff_stash_patch () ( rev-parse --absolute-git-dir) && main_index="$gitdir/index" && checkpoint=$(cat "$gitdir/checkpoints") && - evidence="$common/stash-patch-$action-$kind" && + evidence="$common/stash-$operation-$scope-$action-$kind" && mkdir "$evidence" && cp "$main_index" "$evidence/index.seed" && cp "$other_gitdir/index" "$evidence/other-index.before" && @@ -3640,12 +3660,16 @@ check_backoff_stash_patch () ( >"$evidence/other-index.identity.before" && assert_backoff_full_proof "$evidence/index.seed" && backoff_scoped_index_tree "$evidence/index.seed" "$evidence/expected-main" && + cp "$evidence/expected-main.tree" "$evidence/expected-stash-index.tree" && backoff_commit_entries "$evidence/index.seed" >"$evidence/expected.entries" && git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ >"$evidence/head.before" && + git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD^{tree} \ + >"$evidence/head.tree" && git -c core.fsmonitor=false --no-optional-locks \ for-each-ref --format="%(refname) %(objectname)" >"$evidence/refs.before" && git -c core.fsmonitor=false show HEAD:tracked >"$evidence/head-tracked" && + git -c core.fsmonitor=false show HEAD:sibling >"$evidence/head-sibling" && test_write_lines stash-selected >tracked && cp tracked "$evidence/worktree-tracked.before" && cp sibling "$evidence/sibling.before" && @@ -3653,8 +3677,8 @@ check_backoff_stash_patch () ( cp visible "$evidence/visible.before" && backoff_commit_index "$main_index" status --porcelain=v2 >"$evidence/status.before" && selected=$main_index && - case "$action" in - accept) + case "$operation:$action" in + patch:accept) cp "$evidence/index.seed" "$evidence/worktree-oracle.index" && backoff_commit_index "$evidence/worktree-oracle.index" read-tree HEAD && oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ @@ -3662,24 +3686,55 @@ check_backoff_stash_patch () ( backoff_commit_index "$evidence/worktree-oracle.index" \ update-index --cacheinfo "100644,$oid,tracked" && backoff_commit_index "$evidence/worktree-oracle.index" write-tree \ - >"$evidence/expected-stash-worktree.tree" && - answer=y + >"$evidence/expected-stash-worktree.tree" + ;; + staged:accept) + # Only sibling is staged. The stash records that original index, + # while the final index returns to HEAD and tracked stays dirty. + test "$(cat "$evidence/expected-stash-index.tree")" != \ + "$(cat "$evidence/head.tree")" && + cp "$evidence/expected-stash-index.tree" \ + "$evidence/expected-stash-worktree.tree" && + backoff_commit_index "$evidence/expected-main.index" read-tree HEAD && + backoff_commit_index "$evidence/expected-main.index" write-tree \ + >"$evidence/expected-main.tree" && + backoff_commit_entries "$evidence/expected-main.index" \ + >"$evidence/expected.entries" + ;; + staged:empty | staged:private-empty) + # Establish the absence of staged changes independently of stash. + test_cmp "$evidence/head.tree" "$evidence/expected-main.tree" ;; - private-quit) + *) : ;; + esac && + case "$action" in + private-*) selected="$gitdir/index.alias-copy" && cp "$evidence/index.seed" "$selected" && perl "$common/rejected-index.pl" alias copy "$selected" "$main_index" \ - >"$evidence/private.identity.before" && - answer=q + >"$evidence/private.identity.before" ;; - quit) answer=q ;; + *) : ;; + esac && + case "$operation:$scope" in + patch:scoped) set -- git stash push -p -- tracked ;; + patch:unscoped) set -- git stash -p ;; + staged:unscoped) set -- git stash --staged ;; *) return 1 ;; esac && + case "$operation:$scope:$action" in + patch:unscoped:accept) + # sibling sorts first: reject its staged hunk, then select tracked. + test_write_lines n y >"$evidence/input" + ;; + patch:*:accept) test_write_lines y >"$evidence/input" ;; + patch:*) test_write_lines q >"$evidence/input" ;; + staged:*) >"$evidence/input" ;; + esac && test_cmp_bin "$evidence/index.seed" "$main_index" && GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 \ test-tool fsmonitor-client record-watch-limit && test_path_is_file "$gitdir/fsmonitor--daemon.inotify-limit" && - test_write_lines "$answer" >"$evidence/input" && ( GIT_TEST_FSMONITOR_INOTIFY_BACKOFF=1 && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=EEEEEEEEEEEEEEEE && @@ -3687,24 +3742,27 @@ check_backoff_stash_patch () ( GIT_TRACE2_ENV_VARS=GIT_INDEX_FILE && export GIT_TEST_FSMONITOR_INOTIFY_BACKOFF \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TRACE2_EVENT GIT_TRACE2_ENV_VARS && - if test "$action" = private-quit - then - GIT_INDEX_FILE=$selected && export GIT_INDEX_FILE - else - sane_unset GIT_INDEX_FILE - fi && + case "$action" in + private-*) GIT_INDEX_FILE=$selected && export GIT_INDEX_FILE ;; + *) sane_unset GIT_INDEX_FILE ;; + esac && if test "$action" = accept then - git stash push -p -- tracked + "$@" else - test_expect_code 1 git stash push -p -- tracked + test_expect_code 1 "$@" fi <"$evidence/input" >"$evidence/stash.out" 2>"$evidence/stash.err" ) && # Retain the publication before any status or other Git command. cp "$main_index" "$evidence/index.published" && cp "$selected" "$evidence/selected.published" && snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after" && - test_grep "Stash this hunk" "$evidence/stash.out" && + if test "$operation" = patch + then + test_grep "Stash this hunk" "$evidence/stash.out" + else + test_grep ! "Stash this hunk" "$evidence/stash.out" + fi && test_path_is_missing "$main_index.lock" && test_path_is_missing "$selected.lock" && backoff_scoped_index_tree "$evidence/index.published" "$evidence/published" && @@ -3716,17 +3774,22 @@ check_backoff_stash_patch () ( git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD \ >"$evidence/head.after" && test_cmp "$evidence/head.before" "$evidence/head.after" && - test_cmp "$evidence/sibling.before" sibling && test_cmp "$evidence/visible.before" visible && extract_backoff_root_trace "$evidence/stash.trace" >"$evidence/stash.root.trace" && ! test_trace2_data fsmonitor token_closure/accepted 1 <"$evidence/stash.trace" && case "$action" in - quit | private-quit) - test_grep "^No changes selected$" "$evidence/stash.err" && + quit | private-quit | empty | private-empty) + if test "$operation" = patch + then + test_grep "^No changes selected$" "$evidence/stash.err" + else + test_grep "^No staged changes$" "$evidence/stash.err" + fi && git -c core.fsmonitor=false --no-optional-locks \ for-each-ref --format="%(refname) %(objectname)" >"$evidence/refs.after" && test_cmp "$evidence/refs.before" "$evidence/refs.after" && test_cmp "$evidence/worktree-tracked.before" tracked && + test_cmp "$evidence/sibling.before" sibling && backoff_commit_index "$main_index" status --porcelain=v2 >"$evidence/status.after" && test_cmp "$evidence/status.before" "$evidence/status.after" ;; @@ -3738,13 +3801,20 @@ check_backoff_stash_patch () ( git -c core.fsmonitor=false --no-optional-locks rev-parse stash^1 \ >"$evidence/stash-parent" && test_cmp "$evidence/expected-stash-worktree.tree" "$evidence/stash-worktree.tree" && - test_cmp "$evidence/expected-main.tree" "$evidence/stash-index.tree" && + test_cmp "$evidence/expected-stash-index.tree" "$evidence/stash-index.tree" && test_cmp "$evidence/head.before" "$evidence/stash-parent" && - test_cmp "$evidence/head-tracked" tracked + if test "$operation" = patch + then + test_cmp "$evidence/head-tracked" tracked && + test_cmp "$evidence/sibling.before" sibling + else + test_cmp "$evidence/worktree-tracked.before" tracked && + test_cmp "$evidence/head-sibling" sibling + fi ;; esac && - if test "$action" = private-quit - then + case "$action" in + private-*) assert_backoff_rejected_index_trace "$evidence/stash.root.trace" && test_cmp_bin "$evidence/index.seed" "$evidence/index.published" && test_cmp "$evidence/main.identity.before" "$evidence/main.identity.after" && @@ -3756,12 +3826,21 @@ check_backoff_stash_patch () ( assert_backoff_commit_unbound "$evidence/selected.published" \ "$evidence/private-proof" fi - else - assert_backoff_stash_patch_history "$evidence/index.seed" \ - "$evidence/index.published" "$evidence/published-proof" && + ;; + *) + if test "$scope:$action" = unscoped:accept + then + # A real whole-worktree mutation must invalidate current proof. + # Historical FSCF/FSMN may remain, but FSUC may not. + test_grep ! FSUC "$evidence/index.published" + else + assert_backoff_stash_patch_history "$evidence/index.seed" \ + "$evidence/index.published" "$evidence/published-proof" + fi && test_trace2_data fsmonitor history/watch-limit-suspended 1 \ <"$evidence/stash.root.trace" - fi && + ;; + esac && assert_backoff_checkpoint_unchanged "$gitdir" "$checkpoint" && test_cmp_bin "$evidence/index.published" "$main_index" && test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" && @@ -3773,18 +3852,29 @@ check_backoff_stash_patch () ( test_cmp_bin "$evidence/other-index.before" "$other_gitdir/index" ) +check_backoff_stash_patch () { + check_backoff_stash_selection "$1" "$2" "$3" "$4" "$5" patch +} + +check_backoff_stash_staged () { + check_backoff_stash_selection "$1" "$2" "$3" "$4" unscoped staged +} + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ - 'patch stash preserves suspended history before and after selection' ' + 'patch stash preserves suspended history until selection succeeds' ' test_config_global interactive.singleKey false && test_config_global color.ui false && - for action in quit accept + for scope in scoped unscoped do - prefix="watch-backoff-stash-patch-$action" && - setup_backoff_interactive_pair "$prefix" none && - for kind in main linked + for action in quit accept do - check_backoff_stash_patch "$prefix" "$action" "$kind" "$common" || - return 1 + prefix="watch-backoff-stash-patch-$scope-$action" && + setup_backoff_interactive_pair "$prefix" none && + for kind in main linked + do + check_backoff_stash_patch "$prefix" "$action" "$kind" "$common" "$scope" || + return 1 + done || return 1 done || return 1 done ' @@ -3793,12 +3883,15 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP 'patch stash cannot authenticate a copied private index' ' test_config_global interactive.singleKey false && test_config_global color.ui false && - prefix=watch-backoff-stash-patch-private && - setup_backoff_interactive_pair "$prefix" none && - for kind in main linked + for scope in scoped unscoped do - check_backoff_stash_patch "$prefix" private-quit "$kind" "$common" || - return 1 + prefix="watch-backoff-stash-patch-$scope-private" && + setup_backoff_interactive_pair "$prefix" none && + for kind in main linked + do + check_backoff_stash_patch "$prefix" private-quit "$kind" "$common" "$scope" || + return 1 + done || return 1 done ' @@ -4068,4 +4161,33 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP return 1 done ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'staged-only stash preserves history only when there is no selection' ' + for action in empty accept + do + case "$action" in + empty) seed=clean ;; + accept) seed=staged ;; + esac && + prefix="watch-backoff-stash-staged-$action" && + setup_backoff_interactive_pair "$prefix" none "$seed" && + for kind in main linked + do + check_backoff_stash_staged "$prefix" "$action" "$kind" "$common" || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'an empty staged-only stash cannot authenticate a copied private index' ' + prefix=watch-backoff-stash-staged-private && + setup_backoff_interactive_pair "$prefix" none clean && + for kind in main linked + do + check_backoff_stash_staged "$prefix" private-empty "$kind" "$common" || + return 1 + done +' + test_done From bf9f887a4d785ac6d36ba93bd6c139f8bcc096ed Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 14:50:18 -0500 Subject: [PATCH 398/403] unpack-trees: preserve suspended history across safe replacements 3d7c419293 (status: preserve semantic history across scoped and index changes, 2026-08-11) taught unpack_trees() to transfer a current clean proof to a semantically equivalent index. During a watch-limit backoff, the index has only pending historical state, so that transfer rejects it. A clean, same-path stash merge then drops the untracked history even though its logical entries and subsequent recovery are correct. Capture the authenticated main-index source before unpacking and allow an explicit historical-only transfer after moving its extensions. Keep the original pending untracked cache only when the complete result has the same names, modes, stages, and persistent flags. Changed object IDs must also pass the existing attribute and filter checks. Any unsafe mutation permanently abandons the capture. The result remains all-dirty and cannot claim a current tracked or untracked proof. A checksum-disabled stash merge also replaces the source inode before its final index-only reset. Reread that committed index through the normal admission path instead of weakening the source-identity check. Cover canonical and implicit apply/pop, unsafe replacements, and successive zero-checksum publications with both object formats. --- builtin/stash.c | 7 + clean-status-history.c | 286 +++++++++++++++++++++++ clean-status.h | 18 ++ merge-ort.c | 3 + t/t7536-fsmonitor-watch-limit-backoff.sh | 233 ++++++++++++++++-- unpack-trees.c | 28 +++ unpack-trees.h | 5 +- 7 files changed, 560 insertions(+), 20 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 493ad9f0650a75..53b9082258d28f 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -378,6 +378,9 @@ static int reset_tree(struct object_id *i_tree, int update, int reset, clean_status_has_persistent_fsmonitor_semantic_history( the_repository->index) && clean_status_revalidated_token_matches(the_repository->index); + opts.preserve_backoff_history = preserve_semantic_history && + !update && !reset && + clean_status_fsmonitor_backoff_suspended(the_repository->index); opts.merge = 1; opts.reset = reset ? UNPACK_RESET_PROTECT_UNTRACKED : 0; opts.update = update; @@ -769,6 +772,10 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, } if (has_index) { + /* The preceding publication replaced a null-checksum source inode. */ + if (is_null_oid(&the_repository->index->oid) && + clean_status_fsmonitor_backoff_suspended(the_repository->index)) + discard_index(the_repository->index); if (reset_tree(&index_tree, 0, 0, 1)) ret = -1; } else { diff --git a/clean-status-history.c b/clean-status-history.c index ffe09585623e7d..97a501424c0f58 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -2133,6 +2133,292 @@ static int commit_checkpoint_owner_matches(const struct stat *st) #endif } +struct clean_status_backoff_transfer { + struct index_state *src; + struct clean_status_state *state; + struct untracked_cache *untracked; + struct clean_status_index_snapshot source; + struct attr_source_snapshot *attrs; + char *main_path; + char *token; + unsigned char config_hash[GIT_MAX_RAWSZ]; + unsigned char semantic_hash[GIT_MAX_RAWSZ]; + unsigned char tracked_policy_hash[GIT_MAX_RAWSZ]; +}; + +static int backoff_transfer_state_is_eligible( + const struct index_state *src, const struct untracked_cache *uc) +{ + const struct clean_status_state *state = src ? src->clean_status : NULL; + const struct git_hash_algo *algo; + const char *suffix, *pending; + const uint32_t historical = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + if (!state || !src->repo || src != src->repo->index || + !clean_status_fsmonitor_backoff_suspended(src) || + !fstat_is_reliable() || get_alternate_index_output() || + getenv(GIT_WORK_TREE_ENVIRONMENT) || + getenv(GIT_COMMON_DIR_ENVIRONMENT) || getenv(DB_ENVIRONMENT) || + getenv(ALTERNATE_DB_ENVIRONMENT) || + src->split_index || src->sparse_index != INDEX_EXPANDED || + src->resolve_undo || + (src->cache_changed & + (CE_ENTRY_ADDED | CE_ENTRY_REMOVED | RESOLVE_UNDO_CHANGED)) || + repo_config_values(src->repo)->apply_sparse_checkout || + !src->repo->config_values_private_.trust_ctime || + !src->repo->config_values_private_.check_stat || + !state->config_enforced || !state->current_config_valid || + !state->current_semantic_valid || !state->current_attr_valid || + !state->current_tracked_policy_valid || + state->external_history_restored || !state->disk_config_valid || + state->disk_config_invalid || !state->disk_config_raw.len || + !state->disk_semantic_valid || !state->disk_attr_valid || + !state->disk_tracked_policy_valid || !state->manifest.disk_valid || + !state->manifest.current_valid || !state->manifest.checked || + state->manifest.current_invalidated || state->manifest.global_fallback || + state->manifest.current_flags != historical || + !state->semantic_baseline_pending || + !src->fsmonitor_extension_seen || !src->fsmonitor_token_valid || + src->fsmonitor_last_update_pending || + src->fsmonitor_pending_token_from_provider || + src->fsmonitor_untracked_valid || + src->fsmonitor_untracked_revalidation_authenticated || + src->fsmonitor_legacy_untracked_fallback || + !src->fsmonitor_untracked_extension_seen || + src->fsmonitor_untracked_extension_invalid || + !src->fsmonitor_untracked_token || !state->disk_config_token || + strcmp(state->disk_config_token, src->fsmonitor_last_update) || + !skip_prefix(src->fsmonitor_last_update, "builtin:", &suffix) || + !*suffix || !strcmp(suffix, "fake") || + !uc || !uc->root || !uc->root->valid || + uc->root->valid_recursive || uc->use_fsmonitor || + !uc->fsmonitor_revalidation || uc->fsmonitor_dirty_paths.len) + return 0; + if (strcmp(src->fsmonitor_last_update, src->fsmonitor_untracked_token) && + (!skip_prefix(src->fsmonitor_untracked_token, "pending:", &pending) || + strcmp(suffix, pending))) + return 0; + algo = src->repo->hash_algo; + return !memcmp(state->disk_config_hash, state->current_config_hash, + algo->rawsz) && + !memcmp(state->disk_semantic_hash, state->current_semantic_hash, + algo->rawsz) && + !memcmp(state->disk_attr_hash, state->current_attr_hash, algo->rawsz) && + !memcmp(state->disk_tracked_policy_hash, + state->current_tracked_policy_hash, algo->rawsz); +} + +static int backoff_transfer_source_matches( + const struct clean_status_backoff_transfer *transfer, + const struct untracked_cache *uc) +{ + const struct index_state *src = transfer->src; + const struct clean_status_state *state = src->clean_status; + struct clean_status_config_digest digest; + const struct git_hash_algo *algo = src->repo->hash_algo; + + return state == transfer->state && uc == transfer->untracked && + backoff_transfer_state_is_eligible(src, uc) && + !strcmp(transfer->token, src->fsmonitor_last_update) && + !memcmp(state->current_config_hash, transfer->config_hash, algo->rawsz) && + !memcmp(state->current_semantic_hash, transfer->semantic_hash, + algo->rawsz) && + !memcmp(state->current_tracked_policy_hash, + transfer->tracked_policy_hash, algo->rawsz) && + clean_status_index_path_is_main(src->repo, transfer->main_path) && + !repo_has_replace_refs_uncached(src->repo) && + clean_status_index_snapshot_still_matches_proof_epoch( + &transfer->source, src) && + clean_status_index_snapshot_still_matches_path( + &transfer->source, transfer->main_path, algo) && + attr_source_snapshot_matches_repository(src->repo, transfer->attrs) && + !clean_status_config_read_repository(src->repo, &digest) && + digest.finalized && + !memcmp(digest.hash, transfer->config_hash, algo->rawsz) && + !memcmp(digest.semantic_hash, transfer->semantic_hash, algo->rawsz) && + !memcmp(digest.tracked_policy_hash, + transfer->tracked_policy_hash, algo->rawsz); +} + +void clean_status_release_backoff_transfer( + struct clean_status_backoff_transfer *transfer) +{ + if (!transfer) + return; + clean_status_index_snapshot_release(&transfer->source); + attr_source_snapshot_free(transfer->attrs); + free(transfer->main_path); + free(transfer->token); + free(transfer); +} + +struct clean_status_backoff_transfer *clean_status_capture_backoff_transfer( + struct index_state *src) +{ + struct clean_status_backoff_transfer *transfer; + const struct clean_status_state *state; + const struct attr_fingerprint *attrs; + struct stat st; + const unsigned int unsafe_flags = CE_STAGEMASK | CE_VALID | + CE_EXTENDED_FLAGS | CE_UPDATE | CE_REMOVE | CE_ADDED | + CE_WT_REMOVE | CE_CONFLICTED | CE_UNPACKED | + CE_NEW_SKIP_WORKTREE | CE_STRIP_NAME; + + if (!src || !backoff_transfer_state_is_eligible(src, src->untracked) || + !clean_status_index_path_is_main(src->repo, src->repo->index_file)) + return NULL; + for (size_t i = 0; i < src->cache_nr; i++) + if (src->cache[i]->ce_flags & unsafe_flags) + return NULL; + state = src->clean_status; + CALLOC_ARRAY(transfer, 1); + transfer->src = src; + transfer->state = src->clean_status; + transfer->untracked = src->untracked; + transfer->source.fd = -1; + transfer->main_path = xstrfmt("%s/index", repo_get_git_dir(src->repo)); + transfer->token = xstrdup(src->fsmonitor_last_update); + memcpy(transfer->config_hash, state->current_config_hash, + src->repo->hash_algo->rawsz); + memcpy(transfer->semantic_hash, state->current_semantic_hash, + src->repo->hash_algo->rawsz); + memcpy(transfer->tracked_policy_hash, state->current_tracked_policy_hash, + src->repo->hash_algo->rawsz); + if (clean_status_index_snapshot_pin_proof_epoch(&transfer->source, src) || + fstat(transfer->source.fd, &st) || + !commit_checkpoint_owner_matches(&st) || + attr_source_snapshot_repository(src->repo, &transfer->attrs)) + goto fail; + attrs = attr_source_snapshot_fingerprint(transfer->attrs); + if (!attrs || + attrs->sources_present != state->current_attr_sources_present || + memcmp(attrs->content_hash, state->current_attr_hash, + src->repo->hash_algo->rawsz) || + !backoff_transfer_source_matches(transfer, src->untracked)) + goto fail; + return transfer; +fail: + clean_status_release_backoff_transfer(transfer); + return NULL; +} + +static int backoff_transfer_entries_match( + const struct cache_entry *old, const struct cache_entry *new_entry) +{ + const unsigned int persistent_flags = + CE_STAGEMASK | CE_EXTENDED | CE_VALID | CE_EXTENDED_FLAGS; + const unsigned int unsafe_flags = CE_STAGEMASK | CE_VALID | + CE_EXTENDED_FLAGS | CE_REMOVE | CE_ADDED | CE_WT_REMOVE | + CE_CONFLICTED | CE_NEW_SKIP_WORKTREE | CE_STRIP_NAME; + + return old && new_entry && + !((old->ce_flags | new_entry->ce_flags) & unsafe_flags) && + ce_namelen(old) == ce_namelen(new_entry) && + !memcmp(old->name, new_entry->name, ce_namelen(old) + 1) && + old->ce_mode == new_entry->ce_mode && + !((old->ce_flags ^ new_entry->ce_flags) & persistent_flags); +} + +int clean_status_backoff_transfer_entry_is_safe( + const struct clean_status_backoff_transfer *transfer, + const struct cache_entry *old, const struct cache_entry *new_entry) +{ + return transfer && transfer->src->clean_status == transfer->state && + transfer->src->untracked == transfer->untracked && + backoff_transfer_state_is_eligible(transfer->src, transfer->untracked) && + backoff_transfer_entries_match(old, new_entry) && + S_ISREG(old->ce_mode) && S_ISREG(new_entry->ce_mode) && + clean_status_index_entry_is_semantically_safe( + transfer->src, old, new_entry); +} + +int clean_status_transfer_backoff_history( + struct clean_status_backoff_transfer *transfer, + struct index_state *dst, struct index_state *src) +{ + struct clean_status_state *state; + const char *suffix; + int changed = 0; + + /* move_index_extensions() must already have moved this exact pending UC. */ + if (!transfer || src != transfer->src || src == dst || + src->repo != dst->repo || src->untracked || + dst->untracked != transfer->untracked || + dst->split_index || dst->sparse_index != INDEX_EXPANDED || + dst->resolve_undo || (dst->cache_changed & RESOLVE_UNDO_CHANGED) || + src->cache_nr != dst->cache_nr || + !dst->fsmonitor_last_update || + strcmp(transfer->token, dst->fsmonitor_last_update) || + !backoff_transfer_source_matches(transfer, dst->untracked)) + return 0; + for (size_t i = 0; i < src->cache_nr; i++) { + const struct cache_entry *old = src->cache[i]; + const struct cache_entry *new_entry = dst->cache[i]; + + if (!backoff_transfer_entries_match(old, new_entry)) + return 0; + if (!oideq(&old->oid, &new_entry->oid)) { + if (!S_ISREG(old->ce_mode) || !S_ISREG(new_entry->ce_mode) || + !clean_status_index_entry_is_semantically_safe( + src, old, new_entry)) + return 0; + changed = 1; + } + } + if (!backoff_transfer_source_matches(transfer, dst->untracked) || + !skip_prefix(transfer->token, "builtin:", &suffix)) + return 0; + + /* + * Keep the source's owned descriptor and identity together. The generic + * extension copier intentionally carries no suspended authority; only + * this same-repository, source-bound ownership move may retain it. + */ + clean_status_release(dst); + state = dst->clean_status = src->clean_status; + src->clean_status = NULL; + if (changed) + state->source_logical_hash_valid = 0; + clean_status_clear_authenticated_new_directories(dst); + state->authenticated_bootstrap_manifest = 0; + state->config_revalidated = 0; + state->initial_coherent = 0; + state->filter_scope_valid = 0; + state->config_mismatch = 1; + FREE_AND_NULL(state->config_revalidated_token); + dst->fsmonitor_extension_seen = 1; + dst->fsmonitor_token_valid = 1; + dst->fsmonitor_untracked_valid = 0; + dst->fsmonitor_untracked_revalidation_authenticated = 0; + dst->fsmonitor_untracked_extension_seen = 1; + dst->fsmonitor_untracked_extension_invalid = 0; + dst->fsmonitor_legacy_untracked_fallback = 0; + dst->fsmonitor_pending_token_from_provider = 0; + FREE_AND_NULL(dst->fsmonitor_last_update_pending); + ewah_free(dst->fsmonitor_dirty); + dst->fsmonitor_dirty = NULL; + FREE_AND_NULL(dst->fsmonitor_untracked_token); + dst->fsmonitor_untracked_token = xstrfmt("pending:%s", suffix); + dst->untracked->use_fsmonitor = 0; + dst->untracked->fsmonitor_revalidation = 1; + for (size_t i = 0; i < dst->cache_nr; i++) { + struct cache_entry *ce = dst->cache[i]; + + if (!oideq(&src->cache[i]->oid, &ce->oid)) + fsmonitor_invalidate_cache_entry(ce); + ce->ce_flags &= ~(CE_FSMONITOR_VALID | CE_UPTODATE); + } + /* Only the exhaustive membership proof cancels builder bookkeeping. */ + dst->cache_changed &= ~(CE_ENTRY_ADDED | CE_ENTRY_REMOVED); + dst->cache_changed |= FSMONITOR_CHANGED | UNTRACKED_CHANGED; + if (changed) + dst->cache_changed |= CE_ENTRY_CHANGED; + trace2_data_intmax("fsmonitor", dst->repo, + "history/backoff-transferred", 1); + return 1; +} + static int commit_checkpoint_source_matches( const struct clean_status_commit_checkpoint *checkpoint, struct lock_file *lock) diff --git a/clean-status.h b/clean-status.h index b4421da881b857..1d17885cbc6d19 100644 --- a/clean-status.h +++ b/clean-status.h @@ -10,6 +10,7 @@ struct clean_status_progress; struct clean_status_proof_epoch; struct clean_status_index_snapshot; struct clean_status_commit_checkpoint; +struct clean_status_backoff_transfer; struct lock_file; struct repository; struct stat; @@ -162,6 +163,23 @@ int clean_status_transfer_current_proof_if_same_index( int clean_status_transfer_current_proof_if_semantically_same_index( struct index_state *dst, const struct index_state *src); +/* + * A canonical main-index source may lend suspended historical state to an + * in-process replacement. The caller must abandon the capture on any unsafe + * mutation, move the original extensions, and transfer before discarding src. + * This never grants a current tracked or untracked proof. + */ +struct clean_status_backoff_transfer *clean_status_capture_backoff_transfer( + struct index_state *src); +int clean_status_backoff_transfer_entry_is_safe( + const struct clean_status_backoff_transfer *transfer, + const struct cache_entry *old, const struct cache_entry *new_entry); +int clean_status_transfer_backoff_history( + struct clean_status_backoff_transfer *transfer, + struct index_state *dst, struct index_state *src); +void clean_status_release_backoff_transfer( + struct clean_status_backoff_transfer *transfer); + /* * Historical-only state for a parent-owned, uncommitted main-index write. * Capture before the first write; record its closed output before hooks. diff --git a/merge-ort.c b/merge-ort.c index a090759bc4b0ff..88157406d1750e 100644 --- a/merge-ort.c +++ b/merge-ort.c @@ -4634,6 +4634,9 @@ static int checkout(struct merge_options *opt, unpack_opts.preserve_semantic_history = preserve_semantic_history && clean_status_revalidated_token_matches(opt->repo->index); + unpack_opts.preserve_backoff_history = + preserve_semantic_history && + clean_status_fsmonitor_backoff_suspended(opt->repo->index); unpack_opts.quiet = 0; /* FIXME: sequencer might want quiet? */ unpack_opts.verbose_update = (opt->verbosity > 2); unpack_opts.fn = twoway_merge; diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index 68e0f60eba4ff8..ee9ae2e72f4747 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -3908,6 +3908,11 @@ setup_backoff_stash_apply_pair () { git config core.preloadIndex false && git config core.untrackedCache true && git config core.fsmonitor true && + case "${2-default}" in + default) : ;; + skip-hash) git config index.skipHash true ;; + *) return 1 ;; + esac && for worktree in "$PWD" "$PWD/../$1-linked" do gitdir=$(git -C "$worktree" -c core.fsmonitor=false \ @@ -3922,6 +3927,12 @@ setup_backoff_stash_apply_pair () { git -C "$worktree" status --porcelain=v2 >"$gitdir/prime" && test_must_be_empty "$gitdir/prime" && assert_backoff_full_proof "$gitdir/index" && + if test "${2-default}" = skip-hash + then + test "$(test_trailing_hash "$gitdir/index")" = "$(test_oid zero)" + else + : + fi && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ git -C "$worktree" status --short >"$gitdir/checkpoint.status" && @@ -3954,27 +3965,104 @@ install_backoff_stash_apply_capture () { set -eu test -n "${BACKOFF_STASH_CAPTURE-}" || exit 0 first=$BACKOFF_STASH_CAPTURE/first-write - if test -d "$first" + if test "${BACKOFF_STASH_CAPTURE_ALL-0}" = 1 then - exit 0 + number=1 + while test -d "$BACKOFF_STASH_CAPTURE/writes/$(printf "%03d" "$number")" + do + number=$((number + 1)) + done + test "$number" -le 32 + write=$BACKOFF_STASH_CAPTURE/writes/$(printf "%03d" "$number") + else + test ! -d "$first" || exit 0 + write=$first fi - mkdir "$first" + mkdir "$write" test "${GIT_INDEX_FILE-}" = "$BACKOFF_STASH_INDEX_ENV" perl "$BACKOFF_STASH_IDENTITY" identity "$BACKOFF_STASH_MAIN" \ - >"$first/main.identity" + >"$write/main.identity" perl "$BACKOFF_STASH_IDENTITY" identity "$BACKOFF_STASH_SELECTED" \ - >"$first/selected.identity" - cp "$BACKOFF_STASH_MAIN" "$first/main.index" - cp "$BACKOFF_STASH_SELECTED" "$first/selected.index" - printf "%s\n" "${GIT_INDEX_FILE-}" >"$first/index.env" - printf "%s\n" "$@" >"$first/hook.args" - cp "$GIT_TRACE2_EVENT" "$first/trace" - printf "%s\n" complete >"$first/completed" + >"$write/selected.identity" + cp "$BACKOFF_STASH_MAIN" "$write/main.index" + cp "$BACKOFF_STASH_SELECTED" "$write/selected.index" + printf "%s\n" "${GIT_INDEX_FILE-}" >"$write/index.env" + printf "%s\n" "$@" >"$write/hook.args" + cp "$GIT_TRACE2_EVENT" "$write/trace" + printf "%s\n" complete >"$write/completed" + if test "$write" != "$first" && test ! -d "$first" + then + cp -R "$write" "$first" + fi + if test -n "${BACKOFF_STASH_ATTRIBUTE_PATH-}" && + test ! -e "$BACKOFF_STASH_CAPTURE/attributes.changed" + then + # Change semantics only after the genuine initial publication. + test ! -e "$BACKOFF_STASH_ATTRIBUTE_PATH" + cp "$BACKOFF_STASH_ATTRIBUTE_CONTENTS" "$BACKOFF_STASH_ATTRIBUTE_PATH" + printf "%s\n" changed >"$BACKOFF_STASH_CAPTURE/attributes.changed" + fi EOF } +check_backoff_stash_apply_publications () ( + evidence=$1 && seed=$2 && selected=$3 && style=$4 && shape=$5 && + test_path_is_dir "$evidence/writes/001" && + extract_backoff_root_trace "$evidence/stash.trace" >"$evidence/stash.root.trace" && + if test "$style" = alternate || test "$shape" != safe + then + ! test_trace2_data fsmonitor history/backoff-transferred 1 \ + <"$evidence/stash.trace" && + assert_backoff_commit_unbound "$evidence/selected.published" \ + "$evidence/final-rejected-proof" + else + assert_backoff_pending_proof "$seed" "$evidence/selected.published" && + test_trace2_data fsmonitor history/backoff-transferred 1 \ + <"$evidence/stash.root.trace" + fi && + merge_seen=no && + for write in "$evidence"/writes/[0-9][0-9][0-9] + do + test_grep "^complete$" "$write/completed" && + test_cmp "$evidence/expected-index.env" "$write/index.env" && + assert_backoff_main_index_write "$write/trace" "$selected" yes && + if test "$style" = alternate + then + test_cmp_bin "$evidence/index.pending" "$write/main.index" && + test_cmp "$evidence/main.identity.before" "$write/main.identity" + elif test "$shape" = safe + then + assert_backoff_pending_proof "$seed" "$write/selected.index" && + extract_backoff_root_trace "$write/trace" >"$write/root.trace" && + if test_trace2_data fsmonitor history/backoff-transferred 1 \ + <"$write/root.trace" + then + # The first root transfer precedes the clean-merge write. + # Later reset writes must preserve the same logical result. + backoff_scoped_index_tree "$write/selected.index" "$write/oracle" && + backoff_commit_entries "$write/selected.index" >"$write/entries" && + test_cmp "$evidence/donor.tree" "$write/oracle.tree" && + test_cmp_bin "$evidence/donor.entries" "$write/entries" && + merge_seen=yes + else + : + fi + else + : + fi || return 1 + done && + if test "$style" != alternate && test "$shape" = safe + then + test "$merge_seen" = yes + else + : + fi +) + check_backoff_stash_apply () ( prefix=$1 && style=$2 && operation=$3 && kind=$4 && common=$5 && + shape=${6-safe} && + case "$shape" in safe | add | mode | attributes) : ;; *) return 1 ;; esac && case "$kind" in main) other_gitdir=$(git -C "$prefix-linked" -c core.fsmonitor=false \ @@ -3986,12 +4074,14 @@ check_backoff_stash_apply () ( sane_unset GIT_INDEX_FILE GIT_TEST_PRELOAD_INDEX_BULK \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TEST_FSMONITOR_QUERY_PATH \ BACKOFF_STASH_CAPTURE BACKOFF_STASH_MAIN BACKOFF_STASH_SELECTED \ - BACKOFF_STASH_INDEX_ENV BACKOFF_STASH_IDENTITY && + BACKOFF_STASH_INDEX_ENV BACKOFF_STASH_IDENTITY BACKOFF_STASH_CAPTURE_ALL \ + BACKOFF_STASH_ATTRIBUTE_PATH BACKOFF_STASH_ATTRIBUTE_CONTENTS && gitdir=$(git -c core.fsmonitor=false --no-optional-locks \ rev-parse --absolute-git-dir) && main_index="$gitdir/index" && - evidence="$common/stash-$operation-$style-$kind" && + evidence="$common/stash-$operation-$style-$kind-$shape" && mkdir "$evidence" && + mkdir "$evidence/writes" && cp "$main_index" "$evidence/index.seed" && cp "$other_gitdir/index" "$evidence/other-index.before" && snapshot_backoff_index_identity "$other_gitdir/index" \ @@ -4014,19 +4104,46 @@ check_backoff_stash_apply () ( test_cmp "$common/patch-first.tree" "$evidence/head.tree" && git -c core.fsmonitor=false --no-optional-locks \ for-each-ref --format="%(objectname)" refs/stash >"$evidence/stash.prior" && + cp "$evidence/index.pending" "$evidence/donor-oracle.index" && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ + <"$common/patch-second.contents") && + case "$shape" in mode) mode=100755 ;; *) mode=100644 ;; esac && + backoff_commit_index "$evidence/donor-oracle.index" \ + update-index --cacheinfo "$mode,$oid,sibling" && + if test "$shape" = add + then + test_write_lines created-by-stash >"$evidence/created.contents" && + oid=$(git -c core.fsmonitor=false hash-object -w --stdin \ + <"$evidence/created.contents") && + backoff_commit_index "$evidence/donor-oracle.index" \ + update-index --add --cacheinfo "100644,$oid,zz-created" + else + : + fi && + backoff_commit_index "$evidence/donor-oracle.index" write-tree \ + >"$evidence/expected-donor.tree" && donor="$TRASH_DIRECTORY/$prefix-$kind-donor" && git -c core.fsmonitor=false -c core.untrackedCache=false \ worktree add --detach "$donor" "$(cat "$evidence/head.before")" && cp "$common/patch-second.contents" "$donor/sibling" && - git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false add sibling && + case "$shape" in + add) + cp "$evidence/created.contents" "$donor/zz-created" && + set -- sibling zz-created + ;; + mode) chmod +x "$donor/sibling" && set -- sibling ;; + *) set -- sibling ;; + esac && + git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.filemode=true add "$@" && git -C "$donor" -c core.fsmonitor=false write-tree >"$evidence/donor.tree" && - test_cmp "$common/patch-second.tree" "$evidence/donor.tree" && + test_cmp "$evidence/expected-donor.tree" "$evidence/donor.tree" && donor_gitdir=$(git -C "$donor" -c core.fsmonitor=false \ --no-optional-locks rev-parse --absolute-git-dir) && cp "$donor_gitdir/index" "$evidence/donor.index" && backoff_commit_entries "$evidence/donor.index" >"$evidence/donor.entries" && git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false \ - stash push --quiet -- sibling && + -c core.filemode=true stash push --quiet -- "$@" && git -c core.fsmonitor=false --no-optional-locks rev-parse refs/stash >"$evidence/stash.created" && stash=$(cat "$evidence/stash.created") && git -c core.fsmonitor=false --no-optional-locks rev-parse "$stash^1" >"$evidence/stash.parent" && @@ -4040,6 +4157,14 @@ check_backoff_stash_apply () ( snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after-donor" && test_cmp "$evidence/main.identity.before" "$evidence/main.identity.after-donor" && test_write_lines visible >visible && + test_path_is_missing zz-created && + if test "$shape" = attributes + then + test_path_is_missing "$common/info/attributes" && + test_write_lines "sibling -text" >"$evidence/attributes.expected" + else + : + fi && selected=$main_index && selected_env= && case "$style" in implicit) : ;; @@ -4060,11 +4185,19 @@ check_backoff_stash_apply () ( GIT_TRACE2_ENV_VARS=GIT_INDEX_FILE && BACKOFF_STASH_CAPTURE=$evidence BACKOFF_STASH_MAIN=$main_index && BACKOFF_STASH_SELECTED=$selected BACKOFF_STASH_INDEX_ENV=$selected_env && - BACKOFF_STASH_IDENTITY="$common/hook-index.pl" && + BACKOFF_STASH_IDENTITY="$common/hook-index.pl" BACKOFF_STASH_CAPTURE_ALL=1 && export GIT_TEST_FSMONITOR_INOTIFY_BACKOFF \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE GIT_TRACE2_EVENT GIT_TRACE2_ENV_VARS \ BACKOFF_STASH_CAPTURE BACKOFF_STASH_MAIN BACKOFF_STASH_SELECTED \ - BACKOFF_STASH_INDEX_ENV BACKOFF_STASH_IDENTITY && + BACKOFF_STASH_INDEX_ENV BACKOFF_STASH_IDENTITY BACKOFF_STASH_CAPTURE_ALL && + if test "$shape" = attributes + then + BACKOFF_STASH_ATTRIBUTE_PATH="$common/info/attributes" && + BACKOFF_STASH_ATTRIBUTE_CONTENTS="$evidence/attributes.expected" && + export BACKOFF_STASH_ATTRIBUTE_PATH BACKOFF_STASH_ATTRIBUTE_CONTENTS + else + : + fi && if test "$style" = implicit then sane_unset GIT_INDEX_FILE @@ -4073,7 +4206,7 @@ check_backoff_stash_apply () ( fi && git stash "$operation" --index >"$evidence/stash.out" 2>"$evidence/stash.err" ) && - # Later merge/reset writes may revoke metadata. Capture before any repair. + # Retain the final merge/reset publication before any repairing command. cp "$main_index" "$evidence/main.published" && cp "$selected" "$evidence/selected.published" && snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after" && @@ -4099,6 +4232,8 @@ check_backoff_stash_apply () ( test_trace2_data fsmonitor history/watch-limit-suspended 1 <"$evidence/first.root.trace" && expected_main="$evidence/donor.tree" fi && + check_backoff_stash_apply_publications "$evidence" "$evidence/index.seed" \ + "$selected" "$style" "$shape" && backoff_scoped_index_tree "$evidence/main.published" "$evidence/main-final" && backoff_scoped_index_tree "$evidence/selected.published" "$evidence/selected-final" && backoff_commit_entries "$evidence/selected.published" >"$evidence/selected-final.entries" && @@ -4109,6 +4244,17 @@ check_backoff_stash_apply () ( test_cmp "$evidence/head.before" "$evidence/head.after" && test_cmp "$common/patch-first.contents" tracked && test_cmp "$common/patch-second.contents" sibling && + case "$shape" in + add) test_cmp "$evidence/created.contents" zz-created ;; + mode) test -x sibling ;; + attributes) + test_grep "^changed$" "$evidence/attributes.changed" && + test_cmp "$evidence/attributes.expected" "$common/info/attributes" && + # Reestablish the original semantic source before honest recovery. + rm "$common/info/attributes" + ;; + *) : ;; + esac && test_grep "^visible$" visible && git -c core.fsmonitor=false --no-optional-locks \ for-each-ref --format="%(objectname)" refs/stash >"$evidence/stash.after" && @@ -4161,6 +4307,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP return 1 done ' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ 'staged-only stash preserves history only when there is no selection' ' for action in empty accept @@ -4190,4 +4337,52 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'stash apply cannot transfer history across membership or attribute changes' ' + for shape in add attributes + do + prefix="watch-backoff-stash-transfer-$shape" && + setup_backoff_stash_apply_pair "$prefix" && + for kind in main linked + do + check_backoff_stash_apply "$prefix" canonical apply "$kind" "$common" "$shape" || + return 1 + done || return 1 + done +' + +test_expect_success FILEMODE,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'stash apply cannot transfer history across a mode change' ' + prefix=watch-backoff-stash-transfer-mode && + setup_backoff_stash_apply_pair "$prefix" && + for kind in main linked + do + check_backoff_stash_apply "$prefix" canonical apply "$kind" "$common" mode || + return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'skip-hash stash apply preserves pending history across every publication' ' + for style in implicit canonical + do + prefix="watch-backoff-stash-skip-hash-$style" && + setup_backoff_stash_apply_pair "$prefix" skip-hash && + test_write_lines "$(test_oid zero)" >"$common/skip-hash.expect" && + for kind in main linked + do + check_backoff_stash_apply "$prefix" "$style" apply "$kind" "$common" && + evidence="$common/stash-apply-$style-$kind-safe" && + for index in "$evidence/index.seed" "$evidence/index.pending" \ + "$evidence/selected.published" "$evidence/index.recovered" \ + "$evidence"/writes/[0-9][0-9][0-9]/selected.index + do + test_trailing_hash "$index" >"$index.trailing-hash" && + test_cmp "$common/skip-hash.expect" "$index.trailing-hash" || + return 1 + done || return 1 + done || return 1 + done +' + test_done diff --git a/unpack-trees.c b/unpack-trees.c index eb90eaa40e5d19..5079079129af9c 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -1939,6 +1939,8 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options BUG("o->internal.dir is for internal use only"); if (o->internal.pl) BUG("o->internal.pl is for internal use only"); + if (o->internal.backoff_transfer) + BUG("o->internal.backoff_transfer is for internal use only"); if (o->df_conflict_entry) BUG("o->df_conflict_entry is an output only field"); @@ -1951,6 +1953,10 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options if (o->dst_index) ensure_full_index(o->dst_index); } + if (o->preserve_backoff_history && + o->src_index == o->dst_index && !o->prefix && !o->dry_run) + o->internal.backoff_transfer = + clean_status_capture_backoff_transfer(o->src_index); if (o->reset == UNPACK_RESET_OVERWRITE_UNTRACKED && o->preserve_ignored) @@ -2131,6 +2137,10 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options o->src_index, &o->internal.result); } move_index_extensions(&o->internal.result, o->src_index); + if (!ret && o->internal.backoff_transfer) + clean_status_transfer_backoff_history( + o->internal.backoff_transfer, + &o->internal.result, o->src_index); if (!ret && o->preserve_semantic_history && history_transferred && !new_indexed_directory && !o->src_index->sparse_index && @@ -2189,6 +2199,8 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options o->src_index = NULL; done: + clean_status_release_backoff_transfer(o->internal.backoff_transfer); + o->internal.backoff_transfer = NULL; if (free_pattern_list) clear_pattern_list(&pl); if (o->internal.dir) { @@ -2388,6 +2400,9 @@ static void invalidate_ce_path(const struct cache_entry *ce, { if (!ce) return; + /* Once rooted, even a later identical result cannot revive history. */ + clean_status_release_backoff_transfer(o->internal.backoff_transfer); + o->internal.backoff_transfer = NULL; cache_tree_invalidate_path(o->src_index, ce->name); untracked_cache_invalidate_path(o->src_index, ce->name, 1); } @@ -2400,6 +2415,16 @@ static void invalidate_replaced_ce_path(const struct cache_entry *old, CE_NEW_SKIP_WORKTREE | CE_INTENT_TO_ADD | CE_CONFLICTED; const char *basename; + if (o->internal.backoff_transfer && + clean_status_backoff_transfer_entry_is_safe( + o->internal.backoff_transfer, old, new)) { + /* Pending lists are candidates, not a live targeted-event cache. */ + cache_tree_invalidate_path(o->src_index, old->name); + return; + } + clean_status_release_backoff_transfer(o->internal.backoff_transfer); + o->internal.backoff_transfer = NULL; + if (!o->preserve_semantic_history || o->src_index->sparse_index || o->src_index->split_index || !o->src_index->fsmonitor_untracked_valid || @@ -2743,6 +2768,9 @@ static int merged_entry(const struct cache_entry *ce, } /* Migrate old flags over */ update |= old->ce_flags & (CE_SKIP_WORKTREE | CE_NEW_SKIP_WORKTREE); + /* do_add_entry() publishes this tree entry at stage zero. */ + if (o->internal.backoff_transfer) + merge->ce_flags &= ~CE_STAGEMASK; invalidate_replaced_ce_path(old, merge, o); } diff --git a/unpack-trees.h b/unpack-trees.h index b09b7e38dce988..105896d0e4c853 100644 --- a/unpack-trees.h +++ b/unpack-trees.h @@ -10,6 +10,7 @@ #define MAX_UNPACK_TREES 8 struct cache_entry; +struct clean_status_backoff_transfer; struct unpack_trees_options; struct pattern_list; @@ -71,7 +72,8 @@ struct unpack_trees_options { exiting_early, dry_run, skip_cache_tree_update, - preserve_semantic_history; + preserve_semantic_history, + preserve_backoff_history; enum unpack_trees_reset_type reset; const char *prefix; const char *super_prefix; @@ -105,6 +107,7 @@ struct unpack_trees_options { struct string_list unpack_rejects[NB_UNPACK_TREES_WARNING_TYPES]; struct index_state result; + struct clean_status_backoff_transfer *backoff_transfer; struct pattern_list *pl; struct dir_struct *dir; From 00debd28499ba7964386721632de6c2458460d7d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 15:27:58 -0500 Subject: [PATCH 399/403] stash: reread zero-checksum history before the default merge bf9f887a4d (unpack-trees: preserve suspended history across safe replacements, 2026-08-18) lets a clean stash merge retain suspended fsmonitor history. Its source must still identify the canonical index. With index.skipHash enabled, stash's initial refresh can replace that source inode. Restoring a distinct staged snapshot already rereads the index after reset_head(), but ordinary apply/pop and --index without a distinct snapshot skip that path. The merge then rejects the stale descriptor and drops pending untracked-cache history, even though the logical result is correct. Reread the suspended zero-checksum index before merging when the staged snapshot path has not already done so. Keep the existing post-merge reread and physical-source checks unchanged. Nonzero-checksum indexes, live fsmonitor operation, and the already-rebound staged-snapshot path do not incur another index read. Cover default apply/pop and the empty staged-snapshot path in both worktree layouts, including zero-trailer publication checks and copied private-index rejection. --- builtin/stash.c | 9 ++ t/t7536-fsmonitor-watch-limit-backoff.sh | 181 ++++++++++++++++++++--- 2 files changed, 167 insertions(+), 23 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 53b9082258d28f..60a63ef004435a 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -727,6 +727,15 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, } } + /* The distinct-index path already reread after reset_head(). */ + if (!has_index && is_null_oid(&the_repository->index->oid) && + clean_status_fsmonitor_backoff_suspended(the_repository->index)) { + /* The initial refresh may have replaced our source inode. */ + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) + return error(_("could not read index")); + } + init_ui_merge_options(&o, the_repository); o.branch1 = label_ours ? label_ours : "Updated upstream"; diff --git a/t/t7536-fsmonitor-watch-limit-backoff.sh b/t/t7536-fsmonitor-watch-limit-backoff.sh index ee9ae2e72f4747..f097faa047474f 100755 --- a/t/t7536-fsmonitor-watch-limit-backoff.sh +++ b/t/t7536-fsmonitor-watch-limit-backoff.sh @@ -3910,6 +3910,7 @@ setup_backoff_stash_apply_pair () { git config core.fsmonitor true && case "${2-default}" in default) : ;; + with-hash) git config index.skipHash false ;; skip-hash) git config index.skipHash true ;; *) return 1 ;; esac && @@ -3927,12 +3928,15 @@ setup_backoff_stash_apply_pair () { git -C "$worktree" status --porcelain=v2 >"$gitdir/prime" && test_must_be_empty "$gitdir/prime" && assert_backoff_full_proof "$gitdir/index" && - if test "${2-default}" = skip-hash - then + case "${2-default}" in + skip-hash) test "$(test_trailing_hash "$gitdir/index")" = "$(test_oid zero)" - else - : - fi && + ;; + with-hash) + test "$(test_trailing_hash "$gitdir/index")" != "$(test_oid zero)" + ;; + *) : ;; + esac && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ GIT_TRACE2_EVENT="$gitdir/checkpoint.trace" \ git -C "$worktree" status --short >"$gitdir/checkpoint.status" && @@ -4007,6 +4011,7 @@ install_backoff_stash_apply_capture () { check_backoff_stash_apply_publications () ( evidence=$1 && seed=$2 && selected=$3 && style=$4 && shape=$5 && + donor_stage=${6-staged} && test_path_is_dir "$evidence/writes/001" && extract_backoff_root_trace "$evidence/stash.trace" >"$evidence/stash.root.trace" && if test "$style" = alternate || test "$shape" != safe @@ -4020,7 +4025,7 @@ check_backoff_stash_apply_publications () ( test_trace2_data fsmonitor history/backoff-transferred 1 \ <"$evidence/stash.root.trace" fi && - merge_seen=no && + merge_seen=no && reset_seen=no && for write in "$evidence"/writes/[0-9][0-9][0-9] do test_grep "^complete$" "$write/completed" && @@ -4037,13 +4042,28 @@ check_backoff_stash_apply_publications () ( if test_trace2_data fsmonitor history/backoff-transferred 1 \ <"$write/root.trace" then - # The first root transfer precedes the clean-merge write. - # Later reset writes must preserve the same logical result. + # The first transfer publishes the clean merge. A stash + # without a distinct staged snapshot then unstages it. + backoff_scoped_index_tree "$write/selected.index" "$write/oracle" && + backoff_commit_entries "$write/selected.index" >"$write/entries" && + if test "$merge_seen" = no || + test "$(cat "$write/oracle.tree")" = "$(cat "$evidence/donor.tree")" + then + test "$reset_seen" = no && + test_cmp "$evidence/donor.tree" "$write/oracle.tree" && + test_cmp_bin "$evidence/donor.entries" "$write/entries" && + merge_seen=yes + else + test_cmp "$evidence/expected-selected.tree" "$write/oracle.tree" && + test_cmp_bin "$evidence/expected-selected.entries" "$write/entries" && + reset_seen=yes + fi + elif test "$donor_stage" = unstaged + then backoff_scoped_index_tree "$write/selected.index" "$write/oracle" && backoff_commit_entries "$write/selected.index" >"$write/entries" && - test_cmp "$evidence/donor.tree" "$write/oracle.tree" && - test_cmp_bin "$evidence/donor.entries" "$write/entries" && - merge_seen=yes + test_cmp "$evidence/before.tree" "$write/oracle.tree" && + test_cmp_bin "$evidence/before.entries" "$write/entries" else : fi @@ -4061,8 +4081,18 @@ check_backoff_stash_apply_publications () ( check_backoff_stash_apply () ( prefix=$1 && style=$2 && operation=$3 && kind=$4 && common=$5 && - shape=${6-safe} && + shape=${6-safe} && selection=${7-index} && donor_stage=${8-staged} && case "$shape" in safe | add | mode | attributes) : ;; *) return 1 ;; esac && + case "$selection:$donor_stage" in + index:staged | index:unstaged | default:unstaged) : ;; + *) return 1 ;; + esac && + if test "$shape" != safe + then + test "$selection:$donor_stage" = index:staged + else + : + fi && case "$kind" in main) other_gitdir=$(git -C "$prefix-linked" -c core.fsmonitor=false \ @@ -4080,6 +4110,12 @@ check_backoff_stash_apply () ( rev-parse --absolute-git-dir) && main_index="$gitdir/index" && evidence="$common/stash-$operation-$style-$kind-$shape" && + if test "$selection:$donor_stage" != index:staged + then + evidence="$evidence-$selection-$donor_stage" + else + : + fi && mkdir "$evidence" && mkdir "$evidence/writes" && cp "$main_index" "$evidence/index.seed" && @@ -4134,14 +4170,35 @@ check_backoff_stash_apply () ( mode) chmod +x "$donor/sibling" && set -- sibling ;; *) set -- sibling ;; esac && - git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false \ - -c core.filemode=true add "$@" && - git -C "$donor" -c core.fsmonitor=false write-tree >"$evidence/donor.tree" && - test_cmp "$evidence/expected-donor.tree" "$evidence/donor.tree" && donor_gitdir=$(git -C "$donor" -c core.fsmonitor=false \ --no-optional-locks rev-parse --absolute-git-dir) && - cp "$donor_gitdir/index" "$evidence/donor.index" && + if test "$donor_stage" = staged + then + git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false \ + -c core.filemode=true add "$@" && + git -C "$donor" -c core.fsmonitor=false write-tree >"$evidence/donor.tree" && + cp "$donor_gitdir/index" "$evidence/donor.index" && + expected_stash_index="$evidence/donor.tree" + else + # Leave the real donor index at HEAD. The independently built + # content oracle describes only its unstaged worktree change. + git -C "$donor" -c core.fsmonitor=false write-tree >"$evidence/donor-index.tree" && + test_cmp "$evidence/head.tree" "$evidence/donor-index.tree" && + cp "$evidence/donor-oracle.index" "$evidence/donor.index" && + backoff_commit_index "$evidence/donor.index" write-tree >"$evidence/donor.tree" && + expected_stash_index="$evidence/head.tree" + fi && + test_cmp "$evidence/expected-donor.tree" "$evidence/donor.tree" && backoff_commit_entries "$evidence/donor.index" >"$evidence/donor.entries" && + if test "$selection:$donor_stage" = index:staged + then + cp "$evidence/donor.tree" "$evidence/expected-selected.tree" && + cp "$evidence/donor.entries" "$evidence/expected-selected.entries" + else + cp "$evidence/before.tree" "$evidence/expected-selected.tree" && + cp "$evidence/before.entries" "$evidence/expected-selected.entries" && + ! test_cmp "$evidence/donor.tree" "$evidence/expected-selected.tree" + fi && git -C "$donor" -c core.fsmonitor=false -c core.untrackedCache=false \ -c core.filemode=true stash push --quiet -- "$@" && git -c core.fsmonitor=false --no-optional-locks rev-parse refs/stash >"$evidence/stash.created" && @@ -4151,7 +4208,7 @@ check_backoff_stash_apply () ( git -c core.fsmonitor=false --no-optional-locks rev-parse "$stash^2^{tree}" >"$evidence/stash-index.tree" && test_cmp "$evidence/head.before" "$evidence/stash.parent" && test_cmp "$evidence/donor.tree" "$evidence/stash.tree" && - test_cmp "$evidence/donor.tree" "$evidence/stash-index.tree" && + test_cmp "$expected_stash_index" "$evidence/stash-index.tree" && git -c core.fsmonitor=false worktree remove --force "$donor" && test_cmp_bin "$evidence/index.pending" "$main_index" && snapshot_backoff_index_identity "$main_index" >"$evidence/main.identity.after-donor" && @@ -4204,7 +4261,11 @@ check_backoff_stash_apply () ( else GIT_INDEX_FILE=$selected && export GIT_INDEX_FILE fi && - git stash "$operation" --index >"$evidence/stash.out" 2>"$evidence/stash.err" + case "$selection" in + index) set -- --index ;; + default) set -- ;; + esac && + git stash "$operation" "$@" >"$evidence/stash.out" 2>"$evidence/stash.err" ) && # Retain the final merge/reset publication before any repairing command. cp "$main_index" "$evidence/main.published" && @@ -4230,16 +4291,16 @@ check_backoff_stash_apply () ( else assert_backoff_pending_proof "$evidence/index.seed" "$first/selected.index" && test_trace2_data fsmonitor history/watch-limit-suspended 1 <"$evidence/first.root.trace" && - expected_main="$evidence/donor.tree" + expected_main="$evidence/expected-selected.tree" fi && check_backoff_stash_apply_publications "$evidence" "$evidence/index.seed" \ - "$selected" "$style" "$shape" && + "$selected" "$style" "$shape" "$donor_stage" && backoff_scoped_index_tree "$evidence/main.published" "$evidence/main-final" && backoff_scoped_index_tree "$evidence/selected.published" "$evidence/selected-final" && backoff_commit_entries "$evidence/selected.published" >"$evidence/selected-final.entries" && test_cmp "$expected_main" "$evidence/main-final.tree" && - test_cmp "$evidence/donor.tree" "$evidence/selected-final.tree" && - test_cmp_bin "$evidence/donor.entries" "$evidence/selected-final.entries" && + test_cmp "$evidence/expected-selected.tree" "$evidence/selected-final.tree" && + test_cmp_bin "$evidence/expected-selected.entries" "$evidence/selected-final.entries" && git -c core.fsmonitor=false --no-optional-locks rev-parse HEAD >"$evidence/head.after" && test_cmp "$evidence/head.before" "$evidence/head.after" && test_cmp "$common/patch-first.contents" tracked && @@ -4385,4 +4446,78 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP done ' +# Exercise the !has_index branch with a real stash whose index tree is HEAD. +# Its clean merge temporarily stages the donor tree before unstaging it again. +check_backoff_stash_apply_trailers () ( + evidence=$1 && hash_mode=$2 && + for index in "$evidence/index.seed" "$evidence/index.pending" \ + "$evidence/main.published" "$evidence/selected.published" \ + "$evidence/index.recovered" \ + "$evidence"/writes/[0-9][0-9][0-9]/main.index \ + "$evidence"/writes/[0-9][0-9][0-9]/selected.index + do + actual=$(test_trailing_hash "$index") && + case "$hash_mode" in + skip-hash) test "$actual" = "$(test_oid zero)" ;; + with-hash) test "$actual" != "$(test_oid zero)" ;; + *) return 1 ;; + esac || return 1 + done +) + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'default and unstaged-index stash restores preserve skip-hash history' ' + for mode in implicit-default-apply canonical-default-pop \ + implicit-index-apply canonical-index-pop + do + case "$mode" in + implicit-default-apply) style=implicit selection=default operation=apply ;; + canonical-default-pop) style=canonical selection=default operation=pop ;; + implicit-index-apply) style=implicit selection=index operation=apply ;; + canonical-index-pop) style=canonical selection=index operation=pop ;; + esac && + prefix="watch-backoff-stash-unstaged-$mode" && + setup_backoff_stash_apply_pair "$prefix" skip-hash && + for kind in main linked + do + check_backoff_stash_apply "$prefix" "$style" "$operation" \ + "$kind" "$common" safe "$selection" unstaged && + evidence="$common/stash-$operation-$style-$kind-safe-$selection-unstaged" && + check_backoff_stash_apply_trailers "$evidence" skip-hash || + return 1 + done || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'default stash history transfer retains hashed and private-index controls' ' + for mode in hashed-apply hashed-pop private-default private-index + do + case "$mode" in + hashed-apply) + hash_mode=with-hash style=implicit selection=default operation=apply + ;; + hashed-pop) + hash_mode=with-hash style=canonical selection=default operation=pop + ;; + private-default) + hash_mode=skip-hash style=alternate selection=default operation=apply + ;; + private-index) + hash_mode=skip-hash style=alternate selection=index operation=apply + ;; + esac && + prefix="watch-backoff-stash-unstaged-$mode" && + setup_backoff_stash_apply_pair "$prefix" "$hash_mode" && + for kind in main linked + do + check_backoff_stash_apply "$prefix" "$style" "$operation" \ + "$kind" "$common" safe "$selection" unstaged && + evidence="$common/stash-$operation-$style-$kind-safe-$selection-unstaged" && + check_backoff_stash_apply_trailers "$evidence" "$hash_mode" || + return 1 + done || return 1 + done +' + test_done From 5890aef322980569fc0c1377667589b6eacb1602 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 17:10:06 -0500 Subject: [PATCH 400/403] status: reissue clean proofs after config changes 3c98588208 (status: reissue clean proofs after repository inputs change, 2026-08-13) lets ordinary status replace an otherwise valid sidecar when its repository fingerprint changes. A mismatch in the separate configuration digest returns earlier without requesting a replacement. A persistent nonsemantic change such as status.relativePaths=false can therefore leave a physically current but unusable sidecar in place. An ordinary status may validate the worktree and save resumable history without rewriting the index or issuing a new sidecar. Later invocations then repeat the index-reading fallback. Carry a configuration mismatch through the existing reissue signal. The old proof still fails validation. Issuance still requires an ordinary writable clean query and all existing proof checks. Do not treat the mismatch as a provider reset. Cover the transition with a zero-checksum index and scripted provider responses. Check that read-only status leaves both files alone, writable status changes only the sidecar, and the next identical command hits it without reading or writing the index. --- clean-status-fast.c | 1 + t/t7530-status-clean-sidecar.sh | 110 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/clean-status-fast.c b/clean-status-fast.c index 0ec70fc62f92e2..57b6d19d4b0179 100644 --- a/clean-status-fast.c +++ b/clean-status-fast.c @@ -240,6 +240,7 @@ int clean_status_try_sidecar( } if (memcmp(config->hash, record.sidecar.proof.config_hash, repo->hash_algo->rawsz)) { + *repository_inputs_changed = 1; trace_miss(repo, "fast-config-changed"); goto done; } diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 55a7dd7a3f38e6..2ef3ce4c9fd727 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3194,4 +3194,114 @@ test_expect_success PERL_TEST_HELPERS \ ) ' +test_expect_success PERL_TEST_HELPERS \ + 'a plain clean status reissues a proof after nonsemantic config changes' ' + test_create_repo sidecar-config-reissue && + ( + cd sidecar-config-reissue && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test-tool chmtime =-180 tracked && + git -c core.fsmonitor=false update-index --refresh && + git config index.version 4 && + git config index.skipHash true && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + test_env GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issued && + test_must_be_empty .git/issued && + test_path_is_file .git/index.csts && + rawsz=$(test_oid rawsz) && + dd if=/dev/zero of=.git/zero-trailer \ + bs="$rawsz" count=1 2>/dev/null && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + cp .git/index .git/index.before && + cp .git/index.csts .git/sidecar.before && + before_inode=$(/usr/bin/stat -f %i .git/index) && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/baseline.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/baseline && + test_trace2_data status clean-proof/hit 1 \ + <.git/baseline.trace && + test_region ! index do_read_index .git/baseline.trace && + test_region ! index do_write_index .git/baseline.trace && + test_cmp_bin .git/index.before .git/index && + test_cmp_bin .git/sidecar.before .git/index.csts && + test "$before_inode" = "$(/usr/bin/stat -f %i .git/index)" && + + git config status.relativePaths false && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + status >.git/expected && + test_cmp .git/baseline .git/expected && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/readonly.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/readonly && + test_cmp .git/expected .git/readonly && + test_trace2_data status clean-proof/miss \ + fast-config-changed <.git/readonly.trace && + ! test_trace2_data status clean-proof/sidecar 1 \ + <.git/readonly.trace && + test_region ! index do_write_index .git/readonly.trace && + test_cmp_bin .git/index.before .git/index && + test_cmp_bin .git/sidecar.before .git/index.csts && + test "$before_inode" = "$(/usr/bin/stat -f %i .git/index)" && + + GIT_OPTIONAL_LOCKS=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/reissue.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/actual && + cp .git/index .git/index.after && + cp .git/index.csts .git/sidecar.after && + after_inode=$(/usr/bin/stat -f %i .git/index) && + test_cmp .git/expected .git/actual && + test_trace2_data status clean-proof/miss \ + fast-config-changed <.git/reissue.trace && + test_trace2_data status clean-proof/sidecar 1 \ + <.git/reissue.trace && + ! test_trace2_data status clean-proof/provider-reset-carried 1 \ + <.git/reissue.trace && + test_region ! index do_write_index .git/reissue.trace && + test_cmp_bin .git/index.before .git/index.after && + test "$before_inode" = "$after_inode" && + ! test_cmp_bin .git/sidecar.before .git/sidecar.after && + + GIT_OPTIONAL_LOCKS=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/follower.trace" \ + git -c core.preloadIndex=true \ + -c core.preloadIndexBulk=true \ + status >.git/follower && + test_cmp .git/expected .git/follower && + test_trace2_data status clean-proof/hit 1 \ + <.git/follower.trace && + test_region ! index do_read_index .git/follower.trace && + test_region ! index do_write_index .git/follower.trace && + test_cmp_bin .git/index.after .git/index && + test_cmp_bin .git/sidecar.after .git/index.csts && + test "$after_inode" = "$(/usr/bin/stat -f %i .git/index)" + ) +' + test_done From 44402f3a8e75474ffeda9a23b6fb1d1aa331f773 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 18:36:40 -0500 Subject: [PATCH 401/403] status: ignore command-scoped relativePaths in clean proofs 5890aef322 (status: reissue clean proofs after config changes, 2026-08-18) lets ordinary status replace a sidecar whose configuration digest is stale. A temporary status.relativePaths override changes that digest too, so alternating ordinary status with "git -c status.relativePaths=false status" replaces the same sidecar on each transition. On a 1,160,465-entry checkout, the final ordinary status went from a 74 ms cache hit to a 341 ms index-reading fallback without changing the output or index. Exclude only command-scoped status.relativePaths from the proof digest. This is a presentation choice: fast status builds a fresh wt_status and prints with the current prefix instead of reusing cached output. Keep persistent configuration, missing scope metadata, other keys, and the legacy config-epoch admission unchanged. Flush pending filter settings before omitting the key so separated fragments cannot become a complete disabled-filter override. Cover the scope and filter boundaries with both hash algorithms. Retain the persistent-config repair test, require temporary A/B/A invocations to hit without index I/O or sidecar replacement, and compare dirty subdirectory output with independent status oracles. --- clean-status-config.c | 9 ++ t/t7530-status-clean-sidecar.sh | 149 +++++++++++++++++++++++ t/unit-tests/u-clean-status-config.c | 172 +++++++++++++++++++++++++++ 3 files changed, 330 insertions(+) diff --git a/clean-status-config.c b/clean-status-config.c index f927baa8ca4023..a929c46dc51eca 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -107,6 +107,14 @@ static int config_is_command_acceleration(const char *key, !strcmp(key, "core.preloadindexbulk")); } +/* Clean proofs cache no output; the current status printer uses this choice. */ +static int config_is_command_relative_paths(const char *key, + const struct config_context *ctx) +{ + return ctx && ctx->kvi && ctx->kvi->scope == CONFIG_SCOPE_COMMAND && + !strcmp(key, "status.relativepaths"); +} + static int config_is_command_empty_attributes(const char *key, const char *value, const struct config_context *ctx, @@ -320,6 +328,7 @@ void clean_status_config_add(struct clean_status_config_digest *digest, /* Independent attribute fingerprints guard empty source overrides. */ if (config_is_command_transport(key, ctx) || config_is_command_acceleration(key, ctx) || + config_is_command_relative_paths(key, ctx) || config_is_command_empty_attributes(key, value, ctx, digest) || config_is_command_status_guard(key, value, ctx, digest)) return; diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 2ef3ce4c9fd727..32be0aa5ca2683 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3304,4 +3304,153 @@ test_expect_success PERL_TEST_HELPERS \ ) ' +sidecar_aba_capture () { + sidecar_aba_label=$1 && + sidecar_aba_mode=$2 && + shift 2 && + case "$sidecar_aba_mode" in + clean) + sidecar_aba_locks=1 && + sidecar_aba_sequence=CCCCCCCC + ;; + dirty) + sidecar_aba_locks=0 && + sidecar_aba_sequence=DDCCCCCCCC + ;; + *) return 1 ;; + esac && + GIT_OPTIONAL_LOCKS=$sidecar_aba_locks \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=$sidecar_aba_sequence \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT_NESTING=100 \ + GIT_TRACE2_EVENT="$PWD/.git/$sidecar_aba_label.trace" \ + git "$@" >".git/$sidecar_aba_label.actual" && + cp .git/index ".git/$sidecar_aba_label.index" && + cp .git/index.csts ".git/$sidecar_aba_label.csts" && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index >".git/$sidecar_aba_label.index.stat" && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index.csts >".git/$sidecar_aba_label.csts.stat" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT_NESTING=100 \ + GIT_TRACE2_EVENT="$PWD/.git/$sidecar_aba_label.oracle.trace" \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + -c core.trustctime=true \ + -c core.checkStat=default \ + "$@" >".git/$sidecar_aba_label.expect" && + cp .git/index ".git/$sidecar_aba_label.oracle.index" && + cp .git/index.csts ".git/$sidecar_aba_label.oracle.csts" && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index >".git/$sidecar_aba_label.oracle.index.stat" && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index.csts >".git/$sidecar_aba_label.oracle.csts.stat" +} + +test_expect_success PERL_TEST_HELPERS \ + 'a temporary status relativePaths setting preserves the original clean proof' ' + test_create_repo sidecar-command-config-aba && + ( + cd sidecar-command-config-aba && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + mkdir subdir && + test-tool chmtime =-180 tracked && + git -c core.fsmonitor=false update-index --refresh && + git config index.version 4 && + git config index.skipHash true && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + test_must_fail git config --get status.relativePaths \ + >.git/relativepaths.absent && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + test_env GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issued && + test_must_be_empty .git/issued && + test_path_is_file .git/index.csts && + rawsz=$(test_oid rawsz) && + dd if=/dev/zero of=.git/zero-trailer \ + bs="$rawsz" count=1 2>/dev/null && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + cp .git/config .git/config.before && + cp .git/index .git/index.before && + cp .git/index.csts .git/sidecar.before && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index >.git/index.before.stat && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index.csts >.git/sidecar.before.stat && + + # Retain every pair before testing the fast-path behavior. + sidecar_aba_capture a0 clean status && + sidecar_aba_capture b clean -c status.relativePaths=false status && + sidecar_aba_capture a1 clean status && + sidecar_aba_capture subdir clean \ + -C subdir -c status.relativePaths=false status && + test_write_lines changed >tracked && + sidecar_aba_capture dirty-relative dirty -C subdir status && + sidecar_aba_capture dirty-root dirty \ + -C subdir -c status.relativePaths=false status && + + for sidecar_aba_label in a0 b a1 subdir dirty-relative dirty-root + do + test_cmp ".git/$sidecar_aba_label.expect" \ + ".git/$sidecar_aba_label.actual" && + test_cmp_bin .git/index.before \ + ".git/$sidecar_aba_label.index" && + test_cmp .git/index.before.stat \ + ".git/$sidecar_aba_label.index.stat" && + test_cmp_bin ".git/$sidecar_aba_label.index" \ + ".git/$sidecar_aba_label.oracle.index" && + test_cmp ".git/$sidecar_aba_label.index.stat" \ + ".git/$sidecar_aba_label.oracle.index.stat" && + test_cmp_bin .git/sidecar.before \ + ".git/$sidecar_aba_label.csts" && + test_cmp .git/sidecar.before.stat \ + ".git/$sidecar_aba_label.csts.stat" && + test_cmp_bin ".git/$sidecar_aba_label.csts" \ + ".git/$sidecar_aba_label.oracle.csts" && + test_cmp ".git/$sidecar_aba_label.csts.stat" \ + ".git/$sidecar_aba_label.oracle.csts.stat" && + test_region ! index do_write_index \ + ".git/$sidecar_aba_label.trace" && + test_region ! index do_write_index \ + ".git/$sidecar_aba_label.oracle.trace" || + exit 1 + done && + for sidecar_aba_label in a0 b a1 subdir + do + test_trace2_data status clean-proof/hit 1 \ + <".git/$sidecar_aba_label.trace" && + test_region ! index do_read_index \ + ".git/$sidecar_aba_label.trace" && + ! test_trace2_data status clean-proof/sidecar 1 \ + <".git/$sidecar_aba_label.trace" || + exit 1 + done && + for sidecar_aba_label in dirty-relative dirty-root + do + test_trace2_data status clean-proof/miss fast-provider-changed \ + <".git/$sidecar_aba_label.trace" && + ! test_trace2_data status clean-proof/hit 1 \ + <".git/$sidecar_aba_label.trace" || + exit 1 + done && + test_grep "modified: \.\./tracked$" .git/dirty-relative.actual && + test_grep "modified: tracked$" .git/dirty-root.actual && + test_grep ! "modified: \.\./tracked$" .git/dirty-root.actual && + test_cmp_bin .git/config.before .git/config && + test_cmp_bin .git/index.before .git/index && + test_cmp_bin .git/sidecar.before .git/index.csts + ) +' + test_done diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 6d19a46d079995..97b618bc1cc364 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -187,6 +187,178 @@ void test_clean_status_config__command_preload_config_does_not_change_proof(void } } +void test_clean_status_config__only_command_relative_paths_is_normalized(void) +{ + static const char key[] = "status.relativepaths"; + static const char *const values[] = { NULL, "", "true", "false" }; + static const enum config_scope retained_scopes[] = { + CONFIG_SCOPE_SYSTEM, + CONFIG_SCOPE_GLOBAL, + CONFIG_SCOPE_LOCAL, + CONFIG_SCOPE_WORKTREE, + CONFIG_SCOPE_SUBMODULE, + CONFIG_SCOPE_UNKNOWN, + }; + static const char *const retained_keys[] = { + "status.showuntrackedfiles", "status.relativepaths.extra", + "core.filemode", "core.autocrlf", + }; + static const char *const retained_values[] = { + "all", "false", "true", "true", + }; + static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + struct config_context missing = CONFIG_CONTEXT_INIT; + + for (size_t a = 0; a < ARRAY_SIZE(algorithms); a++) { + const struct git_hash_algo *algo = &hash_algos[algorithms[a]]; + struct clean_status_config_digest baseline, persistent, digest; + + clean_status_config_init(&baseline, algo); + clean_status_config_final(&baseline); + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + kvi.filename = NULL; + for (size_t value = 0; value < ARRAY_SIZE(values); value++) { + kvi.scope = CONFIG_SCOPE_COMMAND; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, key, + values[value], &ctx); + clean_status_config_final(&digest); + cl_assert(hasheq(digest.hash, baseline.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + cl_assert(!digest.filter_configured); + cl_assert(!digest.semantic_config_explicit); + + for (size_t scope = 0; + scope < ARRAY_SIZE(retained_scopes); scope++) { + kvi.scope = retained_scopes[scope]; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, key, + values[value], &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, + baseline.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + } + + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, key, + values[value], &missing); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, key, + values[value], NULL); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + } + + /* Included files retain their command-line include's scope. */ + kvi.scope = CONFIG_SCOPE_COMMAND; + kvi.origin_type = CONFIG_ORIGIN_FILE; + kvi.filename = "/command-config"; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, key, "false", &ctx); + clean_status_config_final(&digest); + cl_assert(hasheq(digest.hash, baseline.hash, algo)); + + /* Omitting an override must not omit its persistent source. */ + kvi.scope = CONFIG_SCOPE_LOCAL; + kvi.filename = "/local-config"; + clean_status_config_init(&persistent, algo); + clean_status_config_add(&persistent, key, "true", &ctx); + clean_status_config_final(&persistent); + cl_assert(!hasheq(persistent.hash, baseline.hash, algo)); + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, key, "true", &ctx); + kvi.scope = CONFIG_SCOPE_COMMAND; + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + kvi.filename = NULL; + clean_status_config_add(&digest, key, "false", &ctx); + clean_status_config_final(&digest); + cl_assert(hasheq(digest.hash, persistent.hash, algo)); + cl_assert(hasheq(digest.semantic_hash, + persistent.semantic_hash, algo)); + cl_assert(hasheq(digest.tracked_policy_hash, + persistent.tracked_policy_hash, algo)); + + for (size_t i = 0; i < ARRAY_SIZE(retained_keys); i++) { + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, retained_keys[i], + retained_values[i], &ctx); + clean_status_config_final(&digest); + cl_assert(!hasheq(digest.hash, baseline.hash, algo)); + if (!strcmp(retained_keys[i], "core.filemode")) + cl_assert(!hasheq( + digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + if (!strcmp(retained_keys[i], "core.autocrlf")) + cl_assert(!hasheq(digest.semantic_hash, + baseline.semantic_hash, algo)); + } + } +} + +void test_clean_status_config__relative_paths_does_not_join_filter_parts(void) +{ + static const char *const keys[] = { + "filter.demo.clean", "filter.demo.smudge", + "filter.demo.process", "filter.demo.required", + }; + static const char *const values[] = { "", "", "", "false" }; + static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; + struct key_value_info kvi = KVI_INIT; + struct config_context ctx = { .kvi = &kvi }; + + for (size_t a = 0; a < ARRAY_SIZE(algorithms); a++) { + const struct git_hash_algo *algo = &hash_algos[algorithms[a]]; + struct clean_status_config_digest baseline, digest; + + kvi.scope = CONFIG_SCOPE_LOCAL; + kvi.origin_type = CONFIG_ORIGIN_FILE; + kvi.filename = "/local-config"; + clean_status_config_init(&baseline, algo); + clean_status_config_add(&baseline, keys[0], "configured", &ctx); + clean_status_config_final(&baseline); + for (unsigned separated = 0; separated < 2; separated++) { + kvi.scope = CONFIG_SCOPE_LOCAL; + kvi.origin_type = CONFIG_ORIGIN_FILE; + kvi.filename = "/local-config"; + clean_status_config_init(&digest, algo); + clean_status_config_add(&digest, keys[0], "configured", &ctx); + kvi.scope = CONFIG_SCOPE_COMMAND; + kvi.origin_type = CONFIG_ORIGIN_CMDLINE; + kvi.filename = NULL; + for (size_t part = 0; part < ARRAY_SIZE(keys); part++) { + if (separated && part == 2) + clean_status_config_add(&digest, + "status.relativepaths", + "false", &ctx); + clean_status_config_add(&digest, keys[part], + values[part], &ctx); + } + clean_status_config_final(&digest); + cl_assert_equal_i(hasheq(digest.hash, baseline.hash, algo), + !separated); + cl_assert_equal_i( + hasheq(digest.semantic_hash, + baseline.semantic_hash, algo), + !separated); + cl_assert(hasheq(digest.tracked_policy_hash, + baseline.tracked_policy_hash, algo)); + cl_assert_equal_i(digest.normalized_filter_disable, + !separated); + } + } +} + void test_clean_status_config__only_complete_disabled_filters_are_normalized(void) { static const char *const keys[] = { From 2c6c2841ed26c8c16c22aaecc31498373146a158 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 19 Aug 2026 01:04:40 -0500 Subject: [PATCH 402/403] status: retain clean proofs across presentation overrides 5890aef322 (status: reissue clean proofs after config changes, 2026-08-18) lets a writable status replace a proof after persistent configuration changes. A temporary color.ui or core.quotePath setting also changes the digest, so it replaces the default proof. The next default status then reads the index again and replaces that proof in turn. Before the reissue change, the temporary command missed but left the default proof usable. Extend the command-scoped presentation exemption from 44402f3a8e (status: ignore command-scoped relativePaths in clean proofs, 2026-08-18) to these two exact keys. A clean proof stores no rendered output. The ordinary configuration parser and current status printer still apply each setting. Keep persistent and unknown-scope entries in the digest, flush incomplete filter overrides before the exemption, and leave legacy tracked-policy admission unchanged. Cover writable default/override/default sequences without index I/O or sidecar replacement, and compare actual color and pathname quoting against the ordinary status path. Retain the persistent-config reissue and filter-boundary controls. --- clean-status-config.c | 12 +- t/t7530-status-clean-sidecar.sh | 218 +++++++++++++++++++-------- t/unit-tests/u-clean-status-config.c | 31 +++- 3 files changed, 187 insertions(+), 74 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index a929c46dc51eca..e6523e193ccaaa 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -107,12 +107,14 @@ static int config_is_command_acceleration(const char *key, !strcmp(key, "core.preloadindexbulk")); } -/* Clean proofs cache no output; the current status printer uses this choice. */ -static int config_is_command_relative_paths(const char *key, - const struct config_context *ctx) +/* Clean proofs cache no output; the status printer uses these choices. */ +static int config_is_command_presentation(const char *key, + const struct config_context *ctx) { return ctx && ctx->kvi && ctx->kvi->scope == CONFIG_SCOPE_COMMAND && - !strcmp(key, "status.relativepaths"); + (!strcmp(key, "status.relativepaths") || + !strcmp(key, "color.ui") || + !strcmp(key, "core.quotepath")); } static int config_is_command_empty_attributes(const char *key, @@ -328,7 +330,7 @@ void clean_status_config_add(struct clean_status_config_digest *digest, /* Independent attribute fingerprints guard empty source overrides. */ if (config_is_command_transport(key, ctx) || config_is_command_acceleration(key, ctx) || - config_is_command_relative_paths(key, ctx) || + config_is_command_presentation(key, ctx) || config_is_command_empty_attributes(key, value, ctx, digest) || config_is_command_status_guard(key, value, ctx, digest)) return; diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 32be0aa5ca2683..6cf806542ac49d 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3304,6 +3304,43 @@ test_expect_success PERL_TEST_HELPERS \ ) ' +sidecar_aba_setup () { + sidecar_aba_path=$1 && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base "$sidecar_aba_path" && + test-tool chmtime =-180 "$sidecar_aba_path" && + git -c core.fsmonitor=false update-index --refresh && + git config index.version 4 && + git config index.skipHash true && + git config core.autocrlf false && + git config core.untrackedCache true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git update-index --fsmonitor && + test_env GIT_INDEX_FILE="$PWD/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + test_grep FSUC .git/index && + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + bulk_status status --porcelain=v2 >.git/issued && + test_must_be_empty .git/issued && + test_path_is_file .git/index.csts && + rawsz=$(test_oid rawsz) && + dd if=/dev/zero of=.git/zero-trailer \ + bs="$rawsz" count=1 2>/dev/null && + tail -c "$rawsz" .git/index >.git/trailer && + test_cmp_bin .git/zero-trailer .git/trailer && + cp .git/config .git/config.before && + cp .git/index .git/index.before && + cp .git/index.csts .git/sidecar.before && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index >.git/index.before.stat && + /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ + .git/index.csts >.git/sidecar.before.stat +} + sidecar_aba_capture () { sidecar_aba_label=$1 && sidecar_aba_mode=$2 && @@ -3313,6 +3350,10 @@ sidecar_aba_capture () { sidecar_aba_locks=1 && sidecar_aba_sequence=CCCCCCCC ;; + readonly) + sidecar_aba_locks=0 && + sidecar_aba_sequence=CCCCCCCC + ;; dirty) sidecar_aba_locks=0 && sidecar_aba_sequence=DDCCCCCCCC @@ -3321,7 +3362,7 @@ sidecar_aba_capture () { esac && GIT_OPTIONAL_LOCKS=$sidecar_aba_locks \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=$sidecar_aba_sequence \ - GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TEST_FSMONITOR_QUERY_PATH="$sidecar_aba_path" \ GIT_TRACE2_EVENT_NESTING=100 \ GIT_TRACE2_EVENT="$PWD/.git/$sidecar_aba_label.trace" \ git "$@" >".git/$sidecar_aba_label.actual" && @@ -3347,52 +3388,58 @@ sidecar_aba_capture () { .git/index.csts >".git/$sidecar_aba_label.oracle.csts.stat" } +sidecar_aba_assert_unchanged () { + for sidecar_aba_label in "$@" + do + test_cmp ".git/$sidecar_aba_label.expect" \ + ".git/$sidecar_aba_label.actual" && + test_cmp_bin .git/index.before \ + ".git/$sidecar_aba_label.index" && + test_cmp .git/index.before.stat \ + ".git/$sidecar_aba_label.index.stat" && + test_cmp_bin ".git/$sidecar_aba_label.index" \ + ".git/$sidecar_aba_label.oracle.index" && + test_cmp ".git/$sidecar_aba_label.index.stat" \ + ".git/$sidecar_aba_label.oracle.index.stat" && + test_cmp_bin .git/sidecar.before \ + ".git/$sidecar_aba_label.csts" && + test_cmp .git/sidecar.before.stat \ + ".git/$sidecar_aba_label.csts.stat" && + test_cmp_bin ".git/$sidecar_aba_label.csts" \ + ".git/$sidecar_aba_label.oracle.csts" && + test_cmp ".git/$sidecar_aba_label.csts.stat" \ + ".git/$sidecar_aba_label.oracle.csts.stat" && + test_region ! index do_write_index \ + ".git/$sidecar_aba_label.trace" && + test_region ! index do_write_index \ + ".git/$sidecar_aba_label.oracle.trace" || + return 1 + done +} + test_expect_success PERL_TEST_HELPERS \ - 'a temporary status relativePaths setting preserves the original clean proof' ' + 'temporary status presentation settings preserve the original clean proof' ' test_create_repo sidecar-command-config-aba && ( cd sidecar-command-config-aba && - sane_unset GIT_TEST_SPLIT_INDEX && - test_commit base tracked && mkdir subdir && - test-tool chmtime =-180 tracked && - git -c core.fsmonitor=false update-index --refresh && - git config index.version 4 && - git config index.skipHash true && - git config core.autocrlf false && - git config core.untrackedCache true && - git config core.fsmonitor true && + git config color.status.branch red && + sidecar_aba_setup tracked && test_must_fail git config --get status.relativePaths \ >.git/relativepaths.absent && - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - git update-index --fsmonitor && - test_env GIT_INDEX_FILE="$PWD/.git/index" \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - bulk_status status --porcelain=v2 >.git/prime && - test_must_be_empty .git/prime && - test_grep FSCF .git/index && - test_grep FSUC .git/index && - test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ - bulk_status status --porcelain=v2 >.git/issued && - test_must_be_empty .git/issued && - test_path_is_file .git/index.csts && - rawsz=$(test_oid rawsz) && - dd if=/dev/zero of=.git/zero-trailer \ - bs="$rawsz" count=1 2>/dev/null && - tail -c "$rawsz" .git/index >.git/trailer && - test_cmp_bin .git/zero-trailer .git/trailer && - cp .git/config .git/config.before && - cp .git/index .git/index.before && - cp .git/index.csts .git/sidecar.before && - /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ - .git/index >.git/index.before.stat && - /usr/bin/stat -f "%d %i %l %z %p %u %g %m %c %B" \ - .git/index.csts >.git/sidecar.before.stat && + test_must_fail git config --get color.ui >.git/color.absent && + test_must_fail git config --get core.quotePath >.git/quotepath.absent && # Retain every pair before testing the fast-path behavior. sidecar_aba_capture a0 clean status && sidecar_aba_capture b clean -c status.relativePaths=false status && sidecar_aba_capture a1 clean status && + sidecar_aba_capture color-off clean -c color.ui=false status && + sidecar_aba_capture color-a clean status && + sidecar_aba_capture quote-off clean -c core.quotePath=false status && + sidecar_aba_capture quote-a clean status && + sidecar_aba_capture color-on clean -c color.ui=always status && + sidecar_aba_capture color-final-a clean status && sidecar_aba_capture subdir clean \ -C subdir -c status.relativePaths=false status && test_write_lines changed >tracked && @@ -3400,33 +3447,11 @@ test_expect_success PERL_TEST_HELPERS \ sidecar_aba_capture dirty-root dirty \ -C subdir -c status.relativePaths=false status && - for sidecar_aba_label in a0 b a1 subdir dirty-relative dirty-root - do - test_cmp ".git/$sidecar_aba_label.expect" \ - ".git/$sidecar_aba_label.actual" && - test_cmp_bin .git/index.before \ - ".git/$sidecar_aba_label.index" && - test_cmp .git/index.before.stat \ - ".git/$sidecar_aba_label.index.stat" && - test_cmp_bin ".git/$sidecar_aba_label.index" \ - ".git/$sidecar_aba_label.oracle.index" && - test_cmp ".git/$sidecar_aba_label.index.stat" \ - ".git/$sidecar_aba_label.oracle.index.stat" && - test_cmp_bin .git/sidecar.before \ - ".git/$sidecar_aba_label.csts" && - test_cmp .git/sidecar.before.stat \ - ".git/$sidecar_aba_label.csts.stat" && - test_cmp_bin ".git/$sidecar_aba_label.csts" \ - ".git/$sidecar_aba_label.oracle.csts" && - test_cmp ".git/$sidecar_aba_label.csts.stat" \ - ".git/$sidecar_aba_label.oracle.csts.stat" && - test_region ! index do_write_index \ - ".git/$sidecar_aba_label.trace" && - test_region ! index do_write_index \ - ".git/$sidecar_aba_label.oracle.trace" || - exit 1 - done && - for sidecar_aba_label in a0 b a1 subdir + sidecar_aba_assert_unchanged \ + a0 b a1 color-off color-a quote-off quote-a \ + color-on color-final-a subdir dirty-relative dirty-root && + for sidecar_aba_label in a0 b a1 color-off color-a \ + quote-off quote-a color-on color-final-a subdir do test_trace2_data status clean-proof/hit 1 \ <".git/$sidecar_aba_label.trace" && @@ -3436,6 +3461,12 @@ test_expect_success PERL_TEST_HELPERS \ <".git/$sidecar_aba_label.trace" || exit 1 done && + test_decode_color <.git/color-on.actual >.git/color-on.decoded && + test_grep "^On branch .*$" .git/color-on.decoded && + test_decode_color <.git/color-off.actual >.git/color-off.decoded && + test_grep ! "" .git/color-off.decoded && + test_cmp .git/a0.actual .git/color-off.actual && + ! test_cmp_bin .git/color-off.actual .git/color-on.actual && for sidecar_aba_label in dirty-relative dirty-root do test_trace2_data status clean-proof/miss fast-provider-changed \ @@ -3453,4 +3484,67 @@ test_expect_success PERL_TEST_HELPERS \ ) ' +test_expect_success PERL_TEST_HELPERS \ + 'the current quotePath setting still controls dirty status output' ' + test_create_repo sidecar-command-quotepath && + ( + cd sidecar-command-quotepath && + quoted_path=$(printf "tracked-\303\270") && + sidecar_aba_setup "$quoted_path" && + test_write_lines changed >"$quoted_path" && + sidecar_aba_capture quoted dirty -c core.quotePath=true status && + sidecar_aba_capture unquoted dirty -c core.quotePath=false status && + sidecar_aba_assert_unchanged quoted unquoted && + for sidecar_aba_label in quoted unquoted + do + test_trace2_data status clean-proof/miss fast-provider-changed \ + <".git/$sidecar_aba_label.trace" && + ! test_trace2_data status clean-proof/hit 1 \ + <".git/$sidecar_aba_label.trace" || + exit 1 + done && + test_grep -F "modified: \"tracked-\\303\\270\"" .git/quoted.actual && + test_grep -F "modified: $quoted_path" .git/unquoted.actual && + ! test_cmp_bin .git/quoted.actual .git/unquoted.actual && + test_cmp_bin .git/config.before .git/config + ) +' + +test_expect_success PERL_TEST_HELPERS \ + 'presentation config normalization does not suppress parse errors or persistent changes' ' + test_create_repo sidecar-presentation-config && + ( + cd sidecar-presentation-config && + sidecar_aba_setup tracked && + for key in color.ui core.quotePath + do + GIT_OPTIONAL_LOCKS=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/$key.invalid.trace" \ + test_must_fail git -c "$key=invalid" status \ + >".git/$key.invalid.out" 2>".git/$key.invalid.err" && + test_must_be_empty ".git/$key.invalid.out" && + test_grep "bad boolean config value" ".git/$key.invalid.err" && + test_region ! index do_read_index ".git/$key.invalid.trace" && + test_region ! index do_write_index ".git/$key.invalid.trace" && + test_cmp_bin .git/index.before .git/index && + test_cmp_bin .git/sidecar.before .git/index.csts || + exit 1 + done && + for key in color.ui core.quotePath + do + git config "$key" false && + sidecar_aba_capture "$key" readonly status && + sidecar_aba_assert_unchanged "$key" && + test_trace2_data status clean-proof/miss fast-config-changed \ + <".git/$key.trace" && + ! test_trace2_data status clean-proof/sidecar 1 \ + <".git/$key.trace" && + cp .git/config.before .git/config || + exit 1 + done && + test_cmp_bin .git/config.before .git/config + ) +' + test_done diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 97b618bc1cc364..58270e6250c732 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -187,10 +187,11 @@ void test_clean_status_config__command_preload_config_does_not_change_proof(void } } -void test_clean_status_config__only_command_relative_paths_is_normalized(void) +static void check_command_presentation_key(const char *key) { - static const char key[] = "status.relativepaths"; - static const char *const values[] = { NULL, "", "true", "false" }; + static const char *const values[] = { + NULL, "", "true", "false", "auto", "always", "never", + }; static const enum config_scope retained_scopes[] = { CONFIG_SCOPE_SYSTEM, CONFIG_SCOPE_GLOBAL, @@ -201,10 +202,11 @@ void test_clean_status_config__only_command_relative_paths_is_normalized(void) }; static const char *const retained_keys[] = { "status.showuntrackedfiles", "status.relativepaths.extra", + "color.ui.extra", "color.status", "core.quotepath.extra", "core.filemode", "core.autocrlf", }; static const char *const retained_values[] = { - "all", "false", "true", "true", + "all", "false", "false", "always", "false", "true", "true", }; static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; struct key_value_info kvi = KVI_INIT; @@ -306,13 +308,26 @@ void test_clean_status_config__only_command_relative_paths_is_normalized(void) } } -void test_clean_status_config__relative_paths_does_not_join_filter_parts(void) +void test_clean_status_config__only_command_presentation_is_normalized(void) +{ + static const char *const keys[] = { + "status.relativepaths", "color.ui", "core.quotepath", + }; + + for (size_t i = 0; i < ARRAY_SIZE(keys); i++) + check_command_presentation_key(keys[i]); +} + +void test_clean_status_config__presentation_does_not_join_filter_parts(void) { static const char *const keys[] = { "filter.demo.clean", "filter.demo.smudge", "filter.demo.process", "filter.demo.required", }; static const char *const values[] = { "", "", "", "false" }; + static const char *const separators[] = { + NULL, "status.relativepaths", "color.ui", "core.quotepath", + }; static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; struct key_value_info kvi = KVI_INIT; struct config_context ctx = { .kvi = &kvi }; @@ -327,7 +342,9 @@ void test_clean_status_config__relative_paths_does_not_join_filter_parts(void) clean_status_config_init(&baseline, algo); clean_status_config_add(&baseline, keys[0], "configured", &ctx); clean_status_config_final(&baseline); - for (unsigned separated = 0; separated < 2; separated++) { + for (size_t i = 0; i < ARRAY_SIZE(separators); i++) { + int separated = !!separators[i]; + kvi.scope = CONFIG_SCOPE_LOCAL; kvi.origin_type = CONFIG_ORIGIN_FILE; kvi.filename = "/local-config"; @@ -339,7 +356,7 @@ void test_clean_status_config__relative_paths_does_not_join_filter_parts(void) for (size_t part = 0; part < ARRAY_SIZE(keys); part++) { if (separated && part == 2) clean_status_config_add(&digest, - "status.relativepaths", + separators[i], "false", &ctx); clean_status_config_add(&digest, keys[part], values[part], &ctx); From 88fab89c5ecd0a6f75a77616ebada157396dd475 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 19 Aug 2026 01:20:30 -0500 Subject: [PATCH 403/403] t7530: use test_env with test_must_fail 2c6c2841ed (status: retain clean proofs across presentation overrides, 2026-08-19) puts environment assignments directly before test_must_fail. That form is not portable for shell functions, so test-lint rejects the new invalid-configuration cases. Use test_env to export the settings in a subshell. Keep the expected parser failures, empty output, and unchanged index and sidecar checks. --- t/t7530-status-clean-sidecar.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 6cf806542ac49d..03fbcdc68a2246 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3518,7 +3518,7 @@ test_expect_success PERL_TEST_HELPERS \ sidecar_aba_setup tracked && for key in color.ui core.quotePath do - GIT_OPTIONAL_LOCKS=1 \ + test_env GIT_OPTIONAL_LOCKS=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ GIT_TRACE2_EVENT="$PWD/.git/$key.invalid.trace" \ test_must_fail git -c "$key=invalid" status \