From 4cf5c1ef879216af829bb9c28d5576905d7a9b1b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 001/432] 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 d4b775953d3842..a57a2a1559ed61 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 d86f2acd2b2a46..47df40f5e4133f 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 181d61a8a0bd18..f02350d848d697 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 09b136f2c58432caac5acf26355e39e2282a9ae1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:03:23 -0700 Subject: [PATCH 002/432] 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 a22c28b15e621a4d27af61aad1bfa41057cacb25 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:04:03 -0700 Subject: [PATCH 003/432] 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 ad7da8417a504bfe6bd4292832e49cb562ac300a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:05:22 -0700 Subject: [PATCH 004/432] 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 600d9dcb581413c02c00de42c122c8ccabf396fc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:06:00 -0700 Subject: [PATCH 005/432] 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 7bd319b89f282a6a0b0a67b4bb1d88fa9c56f6c0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:07:06 -0700 Subject: [PATCH 006/432] 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 94d0c192efbea9a9bb0fb2870fc08bcf8a29eae2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:56:51 -0500 Subject: [PATCH 007/432] 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 191ccaf2fe1f1c1c8a3060e9f84a7d385ee68db4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:08:32 -0500 Subject: [PATCH 008/432] 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 086476bd77c76a..caf11759379b74 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 8eb266cfd13308..77fabb8b908b79 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) @@ -434,6 +440,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 c0769848587b1a..36b8a8c9a0b8ef 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 93a4fa28e2fb2e3e03a4aac9cba26467ed7250d2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:48:16 -0500 Subject: [PATCH 009/432] 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 3fbcff4e5747f2210cb90c23740f807c3e6e8ed6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 11:30:07 -0700 Subject: [PATCH 010/432] 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 939cbf092a6b411a007adfa5b80665df311bab38 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:04 -0500 Subject: [PATCH 011/432] 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 2288f926fe8982b22726fdd75b7b1f57aa2d4ff8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:41:23 -0700 Subject: [PATCH 012/432] 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 caf11759379b74..0e74f201e928ea 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 36b8a8c9a0b8ef..b6fbb268fe896b 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); } } @@ -1455,7 +1456,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 0c4475dbc9f17ae9b6edd700ab9b50b86c577c4b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:14 -0500 Subject: [PATCH 013/432] 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 96df15b5cde1ed..f5767ccf77047f 100644 --- a/git.c +++ b/git.c @@ -933,6 +933,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 ee96bcda14b33c4a7d82963e28e7e14f8d4b7b22 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 014/432] 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 02d861b567198299c0d47cdbbba3c217be6d9909 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:42:25 -0700 Subject: [PATCH 015/432] 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 bc06d35b13377b834a3c99bd216200c58e44460d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:41:39 -0500 Subject: [PATCH 016/432] 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 b959335cbcb0a485fd8416820cabf0146629c799 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:35:52 -0500 Subject: [PATCH 017/432] 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 5faf38355aba3272806024ceedd5dae42dcaf44f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:28 -0500 Subject: [PATCH 018/432] 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 d4b775953d3842..f61aa0701e964a 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 181d61a8a0bd18..f40be7f4576867 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 f4f60c2e160551e63541b56363b2e06a0401faa1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:19:27 -0700 Subject: [PATCH 019/432] 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 8099ad0511107bfb7705e01ffdf4b23aab370cfd Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:37:23 -0500 Subject: [PATCH 020/432] 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 2572649678c0ee76ac686b603c67806a20f85739 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:58 -0500 Subject: [PATCH 021/432] 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 ade4e4c81e31da1f92a2bc0b45e32e80827a8283 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:11:47 -0700 Subject: [PATCH 022/432] 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 f21c4d69f4a5b5..a152f90d9d800a 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 47df40f5e4133f..e0d8e056e1494e 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 0160eb56b51a0d5ceb4303cc4dd657dc12613c3f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:12:41 -0700 Subject: [PATCH 023/432] 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 a152f90d9d800a..fd0daf95886d34 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 e0d8e056e1494e..b4f0ed3bcbc251 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 8de818d039ccc2ec553d629890aefbc9471699c8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:45:06 -0500 Subject: [PATCH 024/432] 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 3029ea61f107042ea1c1cd83efd626b6ff371874 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:13:45 -0700 Subject: [PATCH 025/432] 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 fd0daf95886d34..0f078d83d2eff9 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 b4f0ed3bcbc251..2fe2c4e13883f5 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 78c1820c6ffb0d853838b430dd53b61a68ceeb77 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:47:44 -0500 Subject: [PATCH 026/432] 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 a57a2a1559ed61..47e4469e625f29 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 95ef6e64dcabff..89fd7bfce90f40 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 8f56203f34d9bc..64f2321921dbf8 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 @@ -668,6 +669,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 47df40f5e4133f..1f670615ec07a6 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 fcccef25163d75cc8927aaec26f345c56b048a23 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:45:25 -0700 Subject: [PATCH 027/432] 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 358177be17b37cf9490ca1df02d496efecc74120 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:48:20 -0500 Subject: [PATCH 028/432] 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 47e4469e625f29..7e30fb9021337b 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 64f2321921dbf8..373b6ee36950d8 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -671,6 +671,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 1f670615ec07a6..ac313b8b326fbd 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 0b9d94a7befc9e3e8a533215f31a6788230ca4d5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:15:13 -0700 Subject: [PATCH 029/432] 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 0f078d83d2eff9..79527e33c7ffd2 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 2fe2c4e13883f5..bbc30dea7a802f 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 b067e279ef9abb89846b367110e9c8cacf3fa50b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 07:26:38 -0500 Subject: [PATCH 030/432] 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 89fd7bfce90f40..f647b3e9a9ecfc 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 373b6ee36950d8..614f070a66d968 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 ac313b8b326fbd..66c063619b3056 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 dcc096bf02d8b53aedd5058a6655f84553b77e20 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:00 -0700 Subject: [PATCH 031/432] 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 79527e33c7ffd2..73529fc0c375a9 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 bbc30dea7a802f..b91d70668bc75c 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 e6dc3cfa3be952..1bc16d910c4268 100644 --- a/t/meson.build +++ b/t/meson.build @@ -947,6 +947,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 31aa24c7f25bda88f6e5681a794c8b5ee648b521 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 08:30:18 -0500 Subject: [PATCH 032/432] 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 ce7ab15ee328bce04954cbc88e4fc81004be20b5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:40 -0700 Subject: [PATCH 033/432] 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 6f24e021151b174cf3efbf34776da529f745646c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:44:02 -0700 Subject: [PATCH 034/432] 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 77fabb8b908b79..9926858eeefdcd 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 204ff2da949fefc1f06cc02e1f62d704ddb7e50b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:15 -0500 Subject: [PATCH 035/432] 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 7e30fb9021337b..40870c2f9ca700 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 614f070a66d968..9e267ab9d364d2 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 66c063619b3056..e695099174968e 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 f02350d848d697..3410f2752e0d2b 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 21c1021931a0737859093a7124f66af35b9c0193 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:24:52 -0700 Subject: [PATCH 036/432] 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 493200de3d5d16544f1ec5b480743fc293c164ee Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:46:00 -0700 Subject: [PATCH 037/432] 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 9926858eeefdcd..f4fff9a26703dd 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 b6fbb268fe896b..b9a1103f8ae345 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" */ @@ -1791,6 +1792,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() */ @@ -2480,6 +2484,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 7d4f008bb87a2e9471b79273364111ea43d15860 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:16 -0500 Subject: [PATCH 038/432] 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 9e267ab9d364d2..4d4c9cde2f0aa1 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 fbdeabb60190fec6322a25c673eb64c7da1ff7c7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:29:12 -0700 Subject: [PATCH 039/432] 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 73529fc0c375a9..287543e8798dee 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 b91d70668bc75c..ddb5a2d864024d 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 1bc16d910c4268..4e5a69cd2d0809 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 318e7bf6c73beca548940f6234121782772e6ed5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:47:29 -0700 Subject: [PATCH 040/432] 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 f4fff9a26703dd..960021037d12b2 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 b9a1103f8ae345..4f1aaad523e5ca 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1996,6 +1996,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); } @@ -3091,6 +3092,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 221c80a3643aeb5fe64544c91ca9f6d86d9a8b92 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:06:20 -0500 Subject: [PATCH 041/432] 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 a8c86fff9b009bf81d3c8e21146f7f39e13060fb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:30:22 -0700 Subject: [PATCH 042/432] 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 287543e8798dee..db27b53d6284f1 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 ddb5a2d864024d..f5a06cb8c65af4 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 4e5a69cd2d0809..4320cbf0b835ae 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 fc3d8c044d169b3086a4e8a38b3c34a014aaaed9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:08:32 -0700 Subject: [PATCH 043/432] 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 f21c4d69f4a5b5..57f2228ff10d21 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 e6dc3cfa3be952..de7817c3c76097 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 40af25b13a55bb5e9a456f97c68ec170a3bb7989 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 10:30:31 -0500 Subject: [PATCH 044/432] 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 40870c2f9ca700..e4e3800ece6553 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 4d4c9cde2f0aa1..1e643c50a12ec0 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 @@ -677,7 +678,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 e695099174968e..27470fbb0984b5 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 9a9daaf2afe5e2..0849ced1b4cd19 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 3410f2752e0d2b..88cf54dc570e55 100644 --- a/t/meson.build +++ b/t/meson.build @@ -947,6 +947,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 3b5434881dfe09550f5dab67a25084bf4e07fe50 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:01 -0700 Subject: [PATCH 045/432] 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 ce7d5c9788986012cbea713f79a4762eaaf6c3d1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:49 -0500 Subject: [PATCH 046/432] 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 353b757b0df43902abb8462ea5b99c8159b2799a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 10:31:40 -0500 Subject: [PATCH 047/432] 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 0849ced1b4cd19..6934d75bd07b8d 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 74db45ef70ab7d9ab3bfbd6bc7fe3d0d660d0b69 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:54 -0700 Subject: [PATCH 048/432] 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 015cf5819e839568ccd635332e248ad1be604791 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:57 -0500 Subject: [PATCH 049/432] 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 eda5d3af9cb54ba7f6b72b67eaacd42a64d9b48b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:33:17 -0700 Subject: [PATCH 050/432] 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 db27b53d6284f1..16c958a5d439cd 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 f5a06cb8c65af4..3157c34006d245 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 4320cbf0b835ae..efa8c53da961df 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 c86d9add9ccc59a0dd8a2b55325c33e6e9c225f7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:48:46 -0700 Subject: [PATCH 051/432] 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 2dbe7d2195cec0f2d60c3ee4d7f45c6c1b54d200 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:54:41 -0500 Subject: [PATCH 052/432] 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 47d16cdbe9215dd125ef96d44aac8847140fcd86 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 10:50:14 -0700 Subject: [PATCH 053/432] 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 c0769848587b1a..40d01bdc772035 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2013,27 +2013,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 30a778c34de4cd1421215c7122b7fc4d21c314c4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:34:40 -0700 Subject: [PATCH 054/432] 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 16c958a5d439cd..88226f8b445322 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 3157c34006d245..7e494d672c7050 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 f9d7984a41664b1acbf808e6dc8a408a7a713c0c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:52:33 -0700 Subject: [PATCH 055/432] 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 9e2f8a11a2dae527288284986e572be67c5c18a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:55:11 -0500 Subject: [PATCH 056/432] 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 59a764f7a21beaab770ad518faf8674c9ce27dcb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:33 -0500 Subject: [PATCH 057/432] 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 785c74e6407bc581f33d73705453ea1aa4413c72 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:48:56 -0500 Subject: [PATCH 058/432] 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 732c70079a8b99..5d59356ffeb908 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) { @@ -1472,19 +1489,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 d660714ab991e35739f70cc6bfbb28cf20a8a191 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:40:39 -0700 Subject: [PATCH 059/432] 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 a556042517d9af6c3a7c68fecadb6fde1ab341b0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:50:12 -0500 Subject: [PATCH 060/432] 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 960021037d12b2..cc6d932800ebd6 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 4f1aaad523e5ca..3029c83a1f88fd 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2485,6 +2485,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 721bad5c71e3dc35ec1c5baef0d2718568058ebc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 15:24:55 -0500 Subject: [PATCH 061/432] 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 705389455114694f50dbef4e39f8969c24a95152 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:55:56 -0500 Subject: [PATCH 062/432] 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 cc6d932800ebd6..a1a9fce438f4c8 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; @@ -477,6 +479,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 3029c83a1f88fd..732c70079a8b99 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1564,7 +1564,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++) { @@ -1608,6 +1608,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); @@ -2487,6 +2501,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 ce3230d028b6d80a3e9768ba7cfaa7e5fd160c56 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:53 -0500 Subject: [PATCH 063/432] 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 40d01bdc772035..2e04c2d38a1862 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2008,23 +2008,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) { @@ -2035,20 +2116,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; } @@ -2057,11 +2137,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; } @@ -2293,6 +2384,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); @@ -2314,13 +2407,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 edd3f0b00776f17ed029834af31517313f44f463 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:51:15 -0500 Subject: [PATCH 064/432] 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 0e74f201e928ea..1487199e231578 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 a1a9fce438f4c8..c9f3c6cb7e9646 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -500,6 +500,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 5d59356ffeb908..307bd5366c379c 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 cdb592494c44fab8eddc3f379a7b5d79f6a364ea Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:30:48 -0700 Subject: [PATCH 065/432] 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 88226f8b445322..788dc66567b13b 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 7e494d672c7050..73b2c700863ccb 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 efa8c53da961df..ad25820c8281e6 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 0b2924edf8cfcadce08c697ed41908d38b156269 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:42:56 -0700 Subject: [PATCH 066/432] 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 788dc66567b13b..10232268cc84be 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 73b2c700863ccb..3e81af2d0eb3f4 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 ad25820c8281e6..5886a55cbcaf67 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 ac09dd2281399ddd9a5ebdcd88d79a0d2aa78128 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 10:32:32 -0700 Subject: [PATCH 067/432] 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 1c47490fc8ea2e05dbe32a4c57ed58b9baeb8544 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:47:10 -0700 Subject: [PATCH 068/432] 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 10232268cc84be..29f11e142a9fe2 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 3e81af2d0eb3f4..f496a6bc9b8a67 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 5886a55cbcaf67..1e65920bb5ff85 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 ade8b79d09025e5ceab34966c8655316b78a8893 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:32 -0500 Subject: [PATCH 069/432] 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 2dfe3e6e8be839..f7daa1130ac1fd 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 f496a6bc9b8a67..5d64f774a85451 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 cc6d932800ebd6..8c3b2b8480aabc 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 3029c83a1f88fd..f54f0c7a2ebbbe 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" @@ -2255,6 +2256,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; @@ -2487,6 +2489,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 e3484744c4f711910001450e1bab399d042f4b90 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:36 -0500 Subject: [PATCH 070/432] 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 f6953c79288b7d6a98f8297a000b59da8c514d5d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:46:34 -0500 Subject: [PATCH 071/432] 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 f7daa1130ac1fd..4e115bc540c003 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 5d64f774a85451..37706d067bbf2f 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 c9160a2f1be046..979df86aae8167 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 ffc3ad0c6b1af4a4735fd7a47fbb5ab9861da43a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 23:15:36 -0700 Subject: [PATCH 072/432] 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 4e115bc540c003..0732e63f887682 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 37706d067bbf2f..a4190fc5af32a7 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 f54f0c7a2ebbbe..c38004d3eff8f5 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2274,6 +2274,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) @@ -2763,6 +2764,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 979df86aae8167..48680e152a89f4 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 e6a942d1dfbce2e6f550cec99142a0d678ed2542 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:32 -0500 Subject: [PATCH 073/432] 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 0732e63f887682..626e98d59dac34 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 a4190fc5af32a7..d741a595e81761 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 c38004d3eff8f5..8806dc975b95d0 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" */ @@ -1793,6 +1794,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; @@ -1998,6 +2002,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 48680e152a89f4..41dbd76da74c73 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 e1d5ff4e40df5552b341d7e4fe32381badda462f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:47 -0500 Subject: [PATCH 074/432] 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 | 49 ++++++++++- 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, 618 insertions(+), 29 deletions(-) diff --git a/builtin/add.c b/builtin/add.c index eab8f03cad31d6..a95695d6fc2bf8 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" @@ -288,18 +290,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_( @@ -457,18 +461,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); @@ -570,8 +581,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); @@ -683,6 +713,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 8c3b2b8480aabc..1efcea7d67c126 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -414,6 +414,7 @@ int remove_file_from_index_with_flags(struct index_state *, const char *, int); #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 diff --git a/read-cache.c b/read-cache.c index 8806dc975b95d0..270df01348a555 100644 --- a/read-cache.c +++ b/read-cache.c @@ -677,6 +677,8 @@ int remove_file_from_index_with_flags(struct index_state *istate, printf(_("remove '%s'\n"), path); if (pretend) return 0; + if (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) + clean_status_invalidate_current_proof(istate); return remove_file_from_index(istate, path); } @@ -743,6 +745,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; @@ -753,7 +769,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; @@ -838,12 +855,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); @@ -2854,6 +2881,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) @@ -3121,6 +3149,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; 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 fcaaa21045c1308ec1898321f677cb9620e1e693 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:53:53 -0700 Subject: [PATCH 075/432] 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 270df01348a555..a6858778cfc135 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3616,6 +3616,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 81fe0e3ddd3351b5a10b11cebeef36715448bb43 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 20 Jul 2026 20:42:14 -0500 Subject: [PATCH 076/432] 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 f4ccf8661f7ae19c6ab8b777cab9162231fd95b9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:06:21 -0500 Subject: [PATCH 077/432] 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 a6858778cfc135..1076d064582b7c 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1674,7 +1674,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 81b51a39b545d02a98f6a337a79742c7dbdc42eb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:51:39 -0500 Subject: [PATCH 078/432] 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 626e98d59dac34..86555972a52ea1 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 d741a595e81761..9ff03e477b2a28 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 1076d064582b7c..9d974d45c093f9 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2304,7 +2304,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); @@ -2323,10 +2323,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) @@ -2418,9 +2422,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 41dbd76da74c73..4389b0862cf06d 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 898ba74d39e6269c1dd33f958d7f891056b11fca Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:07:02 -0500 Subject: [PATCH 079/432] 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 2a6ed370c9cf57c927feb9ce20c6226c6c67807a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:41:16 -0500 Subject: [PATCH 080/432] 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 86555972a52ea1..3bb8c3333d1c2c 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 9ff03e477b2a28..61ea7cd29e2e50 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 1efcea7d67c126..a3dc618f3f5ee9 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -480,6 +480,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 9d974d45c093f9..440cda08920fcf 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1675,13 +1675,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]); } @@ -2038,6 +2039,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 8206095d38c0caef529808be69ce053dad2c186f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:42:44 -0500 Subject: [PATCH 081/432] 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 a68c13331a4a772c16b9f6773ab84082fe20ee1c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:44:44 -0500 Subject: [PATCH 082/432] 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 1b16f0c178f1564aa2b89f163dd27bc6b04ac982 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:46:57 -0500 Subject: [PATCH 083/432] 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 1ed346e6bb194e03d42de75378d7f39cc00e2ff6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:46:20 -0500 Subject: [PATCH 084/432] 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 b915aba3b0de0575bd38c3671296d56bb150f8a0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:03 -0500 Subject: [PATCH 085/432] 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 8f2768ae3bafcc..6abc24463635ee 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 bb11dd51a6db3f..5edba88003022f 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 6b87e505f03059..fdb679b79a593d 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 3d816120853738538b3981e0aee9848b06af1f8e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:31 -0500 Subject: [PATCH 086/432] 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 a0b164b2533374d6b6ec93eb1f1df426b9fd7ae3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:14:28 -0500 Subject: [PATCH 087/432] 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 698c8300a54494..bfbbe17cd483e6 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 71ddd0846c6b9d1b27f5f3988c80b4fdb961b1bf Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:26:44 -0500 Subject: [PATCH 088/432] 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 41503a8e54f00383b184f100021c165a2021dd61 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:27:01 -0500 Subject: [PATCH 089/432] 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 bfbbe17cd483e6..cec6a7bc563b80 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 e1b26ce01be71791266453bd488c29d6c8df32cb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:32:42 -0500 Subject: [PATCH 090/432] 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 f647b3e9a9ecfc..7e0f6a14240636 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 1e643c50a12ec0..f2001ebc6d7b6a 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 5edba88003022f..abc5617b7661f9 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 d829638a14e49706e09e89edebd5a4b02537ccbb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:33:21 -0500 Subject: [PATCH 091/432] 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 7e0f6a14240636..4ec968eb7310dc 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 f2001ebc6d7b6a..66049bd0f1601b 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 abc5617b7661f9..1f57f49515d986 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 8dee17a0e74d5a1cadf8830abb81e50db90ef515 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:12 -0500 Subject: [PATCH 092/432] 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 4ec968eb7310dc..981fba0fea5c26 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 66049bd0f1601b..4df74bece2e551 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 1f57f49515d986..50263f513c2367 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 15133f988b2484fa1cdf179ab80eb44af981f0d5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:39 -0500 Subject: [PATCH 093/432] 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 981fba0fea5c26..8f37e5936d8e58 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 4df74bece2e551..01f79769917427 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 50263f513c2367..6e550efbb34c60 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 ccd41f46291255b8f9d0abbf919a2a916a44d386 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:35:16 -0500 Subject: [PATCH 094/432] 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 8f37e5936d8e58..7ea7be4047cc98 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 01f79769917427..86f55e57efa814 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 6e550efbb34c60..d72371e302d477 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 44d8d2331aff533ee77645594226cf19f8daa3bf Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:39:36 -0500 Subject: [PATCH 095/432] 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 7ea7be4047cc98..d5c5732932eb38 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 86f55e57efa814..204b549ebed75f 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 @@ -683,7 +685,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 d72371e302d477..f9ff1b8ed4827e 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 6934d75bd07b8d..6f557abebfd374 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 fdb679b79a593d..cb1a6b181ffbae 100644 --- a/t/meson.build +++ b/t/meson.build @@ -962,6 +962,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 15cc9c0f40be76ccc22eac4f78b7a146d6065dc9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:53:33 -0500 Subject: [PATCH 096/432] 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 cec6a7bc563b80..bcbfdfe12b7409 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 1ef419ffc0b7e4..b6529a9306fdda 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1642,6 +1642,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; @@ -2558,6 +2561,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 7e8b9b38dce29ae81d324b880e437dcc5460a20e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:13:48 -0500 Subject: [PATCH 097/432] 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 2e4278450e2def707e89aa8775a96b417671b42f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:04:25 -0500 Subject: [PATCH 098/432] 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 6abc24463635ee..14272440a7c3b3 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 f9ff1b8ed4827e..90c543e2e230f5 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 cb1a6b181ffbae..339836590c3603 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 349e32707daca7f35c9143cbcd5a94ec89a65101 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:06 -0500 Subject: [PATCH 099/432] 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 14272440a7c3b3..c765c978b81fc5 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 339836590c3603..592c1abbff28f1 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 0827c37f8109d2ca4f8d505dfd3fb6643b30b9ed Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:16 -0500 Subject: [PATCH 100/432] 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 a4a1dffd180deba4f913d3e859e875d4a39fcd11 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 02:28:35 -0500 Subject: [PATCH 101/432] 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 c765c978b81fc5..84186e36bb55d1 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 90c543e2e230f5..2e104119fa56b1 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 bcbfdfe12b7409..e1aad60217d006 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 592c1abbff28f1..8745d20feb759a 100644 --- a/t/meson.build +++ b/t/meson.build @@ -963,6 +963,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 9d0b0b11ea312c961980fb458188d9d5ea841b1b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:16:38 -0500 Subject: [PATCH 102/432] 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 8d2d44f533399ed86116c3a90aacdb7e7045faeb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:12 -0500 Subject: [PATCH 103/432] 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 762bd71f4dc671ebd1294ddadab40afc234d403f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:26 -0500 Subject: [PATCH 104/432] 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 84186e36bb55d1..51aa781379f505 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 2e104119fa56b1..bad1fd85101cb3 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 e3271dc60f7f7e334ea2b53a8c6cce055bf1bb83 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 17:20:24 -0500 Subject: [PATCH 105/432] 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 f8dea4b3953250..199a09691a1c7f 100644 --- a/Documentation/Makefile +++ b/Documentation/Makefile @@ -143,6 +143,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 9ce11d5e484d9c..d628b7c4b3cb04 100644 --- a/Documentation/technical/meson.build +++ b/Documentation/technical/meson.build @@ -32,6 +32,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 cec5f30d72a08c3a9636c3448d4b952ae82ebca6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 19:10:21 -0600 Subject: [PATCH 106/432] 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 374772a5152515dccf86efa33bc2121c3e50f9a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 23:58:32 -0500 Subject: [PATCH 107/432] 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 1c1597b9a21e2949b9e31c1a00ca204c4f055c9f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 00:03:34 -0500 Subject: [PATCH 108/432] 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 109/432] 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 918fd9d4644302cbc4070c7d6fe86a65696f1c4e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 11:49:08 -0500 Subject: [PATCH 110/432] 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 111/432] 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 43f5125ff4b30482cdcd43094999f6899868b07b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:49:03 -0500 Subject: [PATCH 112/432] 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 619b0219a027dad6a59a673ce59956f2bbeb6033 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:06:36 -0500 Subject: [PATCH 113/432] 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 114/432] 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 b9e9c7bc953aea88217ba87984621a844b05d2b5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:24:24 -0500 Subject: [PATCH 115/432] 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 9d9df8c6ad21af4babd9e791c6b41ea7f94b95d4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:09:31 -0500 Subject: [PATCH 116/432] 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 dc3e7eeabf41488cdc608ef391872bd10561fa90 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:57 -0500 Subject: [PATCH 117/432] 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 a0a3fc221998473c0251aeb49465ab2926ced202 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:48:46 -0500 Subject: [PATCH 118/432] 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 | 1383 +++++++++++++++++++++++++++++- wt-status.c | 37 +- 13 files changed, 1860 insertions(+), 90 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 0f84c3bb322da1..df0edd1380ad56 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 976dac6748c365..6f6da90abf1a67 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3316,7 +3316,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); @@ -3330,6 +3331,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 81f8bf59c6a896..304e020206b1bb 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1562,63 +1562,1006 @@ 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" && + test_create_repo daemon-token-reset && + ( + cd daemon-token-reset && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit remove removed && + test_commit keep clean && + git config core.preloadIndexBulk true && + git config core.untrackedCache true && + git config index.skipHash true && + test-tool chmtime =-60 tracked removed clean && + git update-index --refresh && + git config core.fsmonitor true && + start_daemon && + + git update-index --force-write-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 && + test_grep FSUC .git/index && + test_trailing_hash .git/index >.git/index.hash && + test_oid zero >.git/zero && + test_cmp .git/zero .git/index.hash && + test-tool dump-fsmonitor >.git/token.before && + token_before=$(sed -n \ + "s/^fsmonitor last update //p" .git/token.before) && + + git fsmonitor--daemon stop && + start_daemon && + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git status --porcelain=v2 --untracked-files=normal >.git/reset.out && + test_must_be_empty .git/reset.out && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/reset.trace && + test_trace2_data index preload/bulk_provider_applied \ + "[1-9][0-9]*" \ + <.git/reset.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/reset.trace && + test-tool dump-fsmonitor >.git/token.after && + token_after=$(sed -n \ + "s/^fsmonitor last update //p" .git/token.after) && + test -n "$token_before" && + test -n "$token_after" && + test "$token_before" != "$token_after" && + + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git status --porcelain=v2 >.git/warm.out && + test_must_be_empty .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 && + + git fsmonitor--daemon stop && + echo changed >>tracked && + rm removed && + start_daemon && + GIT_TRACE2_EVENT="$PWD/.git/dirty-reset.trace" \ + git status --porcelain=v2 >.git/dirty-reset.out && + test_line_count = 2 .git/dirty-reset.out && + test_grep "^1 \.M .* tracked$" .git/dirty-reset.out && + test_grep "^1 \.D .* removed$" .git/dirty-reset.out && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/dirty-reset.trace && + test_trace2_data index preload/bulk_provider_applied 1 \ + <.git/dirty-reset.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/dirty-reset.trace && + + GIT_TRACE2_EVENT="$PWD/.git/dirty-warm.trace" \ + git status --porcelain=v2 >.git/dirty-warm.out && + test_cmp .git/dirty-reset.out .git/dirty-warm.out && + test_trace2_data index preload/bulk_provider_applied 1 \ + <.git/dirty-warm.trace && + test_trace2_data index refresh/sum_lstat 0 \ + <.git/dirty-warm.trace && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/dirty-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 && + "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_trace2_data fsm_client query/command \ + "builtin:test-capable:0" <.git/status.trace && + 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_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary deltas advance only attribute-stable proofs' ' + test_when_finished "rm -rf token-carry" && + test_create_repo token-carry && + ( + cd token-carry && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines "*.txt text" >.gitattributes && + git add .gitattributes && + git commit -m attributes && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/initial && + test_must_be_empty .git/initial && + 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_grep FSUC .git/index && + test_grep FSCF .git/index && + + touch x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + GIT_TRACE2_EVENT="$PWD/.git/created.trace" \ + git status --porcelain=v2 >.git/created && + test_grep "^? x$" .git/created && + test_trace2_data fsmonitor config/token-advanced 1 \ + <.git/created.trace && + + rm x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + GIT_TRACE2_EVENT="$PWD/.git/deleted.trace" \ + git status --porcelain=v2 >.git/deleted && + test_must_be_empty .git/deleted && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/deleted.trace && + test_trace2_data fsmonitor config/token-advanced 1 \ + <.git/deleted.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git status --porcelain=v2 >.git/attributes && + test_must_be_empty .git/attributes && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/attributes.trace && + test_trace2_data fsmonitor apply_count 1 \ + <.git/attributes.trace && + ! test_trace2_data fsmonitor config/token-advanced 1 \ + <.git/attributes.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'worktree-only checkout preserves closed semantic history' ' + test_when_finished "rm -rf checkout-history" && + test_create_repo checkout-history && + ( + cd checkout-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit other other && + 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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout -- tracked && + + 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 \ + 'source-tree checkout preserves closed semantic history' ' + test_when_finished "rm -rf checkout-source-history" && + test_create_repo checkout-source-history && + ( + cd checkout-source-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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout HEAD -- tracked && + + 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 \ + 'source-tree checkout drops history after an index change' ' + test_when_finished "rm -rf checkout-source-changed" && + test_create_repo checkout-source-changed && + ( + cd checkout-source-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines next >tracked && + git add tracked && + git commit -m next && + 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_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git checkout HEAD^ -- tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + 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 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'checkout-index -u preserves closed semantic history' ' + test_when_finished "rm -rf checkout-index-history" && + test_create_repo checkout-index-history && + ( + cd checkout-index-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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout-index -f -u tracked && + 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 \ + 'stat-only update-index preserves closed semantic history' ' + test_when_finished "rm -rf update-index-history" && + test_create_repo update-index-history && + ( + cd update-index-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_grep FSCF .git/index && + + test-tool chmtime +1 tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git update-index --refresh --force-write-index && + 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 \ + 'add --refresh preserves closed semantic history' ' + test_when_finished "rm -rf add-refresh-history" && + test_create_repo add-refresh-history && + ( + cd add-refresh-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_grep FSCF .git/index && + + test-tool chmtime +1 tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add --refresh tracked && + 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 \ + 'mtime-only ordinary add preserves closed semantic history' ' + test_when_finished "rm -rf add-ordinary-history" && + test_create_repo add-ordinary-history && + ( + cd add-ordinary-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_grep FSCF .git/index && + + test-tool chmtime +1 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/path.trace" \ + git status >.git/path && + test_grep "nothing to commit, working tree clean" .git/path && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/path.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/path.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary add drops history after a logical index change' ' + test_when_finished "rm -rf add-ordinary-changed" && + test_create_repo add-ordinary-changed && + ( + cd add-ordinary-changed && + 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_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/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 M\." .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.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 && + ( + 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_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 \ + '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_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_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git stash push >.git/stash && + test_grep "No local changes to save" .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 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 \ + 'dirty stash push drops closed semantic history' ' + test_when_finished "rm -rf stash-dirty-history" && + test_create_repo stash-dirty-history && + ( + cd stash-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_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + + 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 config/coherent 0 \ + <.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" && + test_create_repo reset-mixed-same-tree && + ( + cd reset-mixed-same-tree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git commit --allow-empty -m same-tree && + 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_grep FSCF .git/index && + + test-tool chmtime +1 tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git reset --mixed HEAD^ >.git/reset && + 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 \ + 'mixed reset drops history after a logical index change' ' + test_when_finished "rm -rf reset-mixed-changed" && + test_create_repo reset-mixed-changed && + ( + cd reset-mixed-changed && + 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_grep FSCF .git/index && + + test_write_lines staged >tracked && + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 M\." .git/staged && + test_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git reset --mixed HEAD >.git/reset && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/actual && + test_grep "modified:.*tracked" .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'hard reset to a same-tree commit preserves closed history' ' + test_when_finished "rm -rf reset-hard-same-tree" && + test_create_repo reset-hard-same-tree && + ( + cd reset-hard-same-tree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git commit --allow-empty -m same-tree && + 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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git reset --hard HEAD^ >.git/reset && + 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 \ + 'hard reset to a different tree drops closed semantic history' ' + test_when_finished "rm -rf reset-hard-changed" && + test_create_repo reset-hard-changed && + ( + cd reset-hard-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines next >tracked && + git add tracked && + git commit -m next && + 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_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git reset --hard HEAD^ >.git/reset && + 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 0 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'forced same-tree checkout preserves closed semantic history' ' + test_when_finished "rm -rf checkout-same-tree" && + test_create_repo checkout-same-tree && + ( + cd checkout-same-tree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git branch same && + 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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout -f same >.git/checkout && + + 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 \ + 'one-tree read-tree reset preserves closed semantic history' ' + test_when_finished "rm -rf read-tree-reset-history" && + test_create_repo read-tree-reset-history && + ( + cd read-tree-reset-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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git read-tree --reset -u HEAD >.git/read-tree && + + 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 index refresh/sum_lstat 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'missing semantic history seeds a forward baseline' ' + test_when_finished \ + "stop_daemon_delete_repo missing-semantic-baseline" && + test_create_repo missing-semantic-baseline && ( - cd capability-superset && + cd missing-semantic-baseline && 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_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid tracked && + test_grep ! FSCF .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 FSCF .git/index && + test_trace2_data fsmonitor semantic/adoption-baseline 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace + ) +' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'missing semantic history with weak stat identity forces content verification' ' + test_when_finished \ + "stop_daemon_delete_repo missing-semantic-history" && + test_create_repo missing-semantic-history && + ( + cd missing-semantic-history && + 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) && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime tracked && + git config core.fsmonitor true && + git update-index --fsmonitor && + git update-index --fsmonitor-valid tracked && + test_grep FSMN .git/index && + test_grep ! FSCF .git/index && 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 && - test_grep ! \ - "\"key\":\"query/incompatible-daemon\"" \ - .git/status.trace && - test_grep ! \ - "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ - .git/status.trace + git status --porcelain=v2 >.git/actual && + test_line_count = 1 .git/actual && + test_grep "^1 \.M .* tracked$" .git/actual && + test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.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 && +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'token closure refresh starts inside its proof epoch' ' + test_when_finished "rm -rf proof-epoch-refresh" && + test_create_repo proof-epoch-refresh && ( - cd binding-a && + cd proof-epoch-refresh && + sane_unset GIT_TEST_SPLIT_INDEX && 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 + 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_grep FSCF .git/index && + + # Leave the next refresh with untracked history to bootstrap. + git update-index --no-untracked-cache 2>.git/no-uc.err && + test_grep FSCF .git/index && + test_grep ! FSUC .git/index && + + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + test_must_fail git commit --dry-run --porcelain \ + >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + captured=$(test_grep -n \ + "\"key\":\"semantic/proof-epoch-captured\"" \ + .git/status.trace | sed -n "1s/:.*//p") && + refreshed=$(test_grep -n \ + "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/status.trace | sed -n "\$s/:.*//p") && + test -n "$captured" && + test -n "$refreshed" && + test "$captured" -lt "$refreshed" + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'trivial query closes zero-trailer unbound history' ' + test_when_finished "rm -rf unbound-trivial" && + test_create_repo unbound-trivial && + ( + cd unbound-trivial && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config index.version 4 && + git config feature.manyFiles true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + test-tool read-cache --test-fscf-round-trip && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_trailing_hash .git/index >.git/index.hash && + test_oid zero >.git/zero && + test_cmp .git/zero .git/index.hash && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TC \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status \ + >.git/recovery.out && + test_grep "nothing to commit, working tree clean" \ + .git/recovery.out && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/recovery.trace && + test_trace2_data fsmonitor semantic/proof-epoch-captured 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/recovery.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/recovery.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git status \ + >.git/warm.out && + test_grep "nothing to commit, working tree clean" \ + .git/warm.out && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/warm.trace && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <.git/warm.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/warm.trace && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/warm.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/warm.trace + ) ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ @@ -1686,4 +2629,362 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'sparse index rebuilds semantic history without expansion' ' + test_when_finished "rm -rf sparse-semantic" && + test_create_repo sparse-semantic && + ( + cd sparse-semantic && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir in outside && + printf "aaaa\n" >in/tracked && + printf "outside\n" >outside/file && + git add . && + git commit -m base && + git sparse-checkout set --cone --sparse-index in && + git ls-files --sparse >.git/sparse.before && + test_grep "^outside/$" .git/sparse.before && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test-tool chmtime =-60 in/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get in/tracked) && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCC \ + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/prime.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/prime.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/prime.trace && + test_grep FSMN .git/index && + git -c core.fsmonitor=false ls-files --sparse \ + >.git/sparse.after-prime && + test_grep "^outside/$" .git/sparse.after-prime && + printf "bbbb\n" >in/tracked && + test-tool chmtime =$mtime in/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=in/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/change.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* in/tracked$" .git/actual && + test_trace2_data fsmonitor apply_count 1 \ + <.git/change.trace && + test_grep FSMN .git/index && + git -c core.fsmonitor=false ls-files --sparse \ + >.git/sparse.after-change && + test_grep "^outside/$" .git/sparse.after-change + ) +' + +prepare_semantic_untracked_repo () { + r=$1 && + test_create_repo "$r" && + ( + cd "$r" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + printf "aaaa\n" >cached/tracked && + printf "cccc\n" >cached/hook-tracked && + git add .gitignore cached/.gitignore cached/hook-tracked \ + cached/tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test_write_lines ignored >cached/junk.ignored && + git status --porcelain=v2 >.git/prime.actual && + test_must_be_empty .git/prime.actual && + test_grep UNTR .git/index && + 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 FSMN .git/index && + test_grep ! FSCF .git/index + ) +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'semantic adoption closes the untracked scan' ' + test_when_finished "rm -rf semantic-untracked" && + prepare_semantic_untracked_repo semantic-untracked && + ( + cd semantic-untracked && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + 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/untracked-deferred 1 \ + <.git/status.trace && + 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 \ + "[0-9][0-9]*" <.git/status.trace >.git/apply-count && + test_line_count = 2 .git/apply-count && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'failed untracked closure discards semantic adoption' ' + test_when_finished "rm -rf failed-semantic-untracked" && + prepare_semantic_untracked_repo failed-semantic-untracked && + ( + cd failed-semantic-untracked && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_DISABLE_UNTRACKED_CACHE=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + 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 0 \ + <.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 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 UNTRACKED_CACHE,!MINGW,!CYGWIN \ + 'commit closes hook changes without an untracked cache' ' + test_when_finished "rm -rf commit-hook-closure" && + prepare_semantic_untracked_repo commit-hook-closure && + ( + cd commit-hook-closure && + sane_unset GIT_TEST_SPLIT_INDEX && + git config core.untrackedCache false && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --no-untracked-cache && + test_grep ! UNTR .git/index && + write_script .git/hooks/pre-commit <<-\EOF && + mtime=$(test-tool chmtime --get cached/hook-tracked) && + printf "dddd\n" >cached/hook-tracked && + test-tool chmtime =$mtime cached/hook-tracked + EOF + GIT_EDITOR=: \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/hook-tracked \ + GIT_TRACE2_EVENT="$PWD/.git/commit.trace" \ + git commit --allow-empty --edit -m adoption && + test_trace2_data status fsmonitor_token/semantic-closed 1 \ + <.git/commit.trace && + test_trace2_data fsmonitor token_closure/apply_count "[1-9]" \ + <.git/commit.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/commit.trace >.git/accepted && + test_line_count = 2 .git/accepted && + 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/status.actual && + test_grep "^1 \.M .* cached/hook-tracked$" \ + .git/status.actual && + test_grep ! UNTR .git/index + ) +' + +test_expect_success UNTRACKED_CACHE,!MINGW,!CYGWIN \ + 'failed hook closure refreshes the worktree' ' + test_when_finished "rm -rf commit-hook-fallback" && + prepare_semantic_untracked_repo commit-hook-fallback && + ( + cd commit-hook-fallback && + sane_unset GIT_TEST_SPLIT_INDEX && + write_script .git/hooks/pre-commit <<-\EOF && + mtime=$(test-tool chmtime --get cached/hook-tracked) && + printf "dddd\n" >cached/hook-tracked && + test-tool chmtime =$mtime cached/hook-tracked + EOF + GIT_EDITOR=: \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCE \ + GIT_TRACE2_EVENT="$PWD/.git/commit.trace" \ + git commit --allow-empty --edit -m adoption && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/commit.trace && + test_trace2_data status count/changed 2 <.git/commit.trace && + test_grep "cached/hook-tracked$" .git/COMMIT_EDITMSG && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/status.actual && + test_grep "cached/hook-tracked$" .git/status.actual + ) +' + +test_expect_success UNTRACKED_CACHE,!MINGW,!CYGWIN \ + 'post-hook refresh preserves hook index updates' ' + test_when_finished "rm -rf commit-hook-index" && + prepare_semantic_untracked_repo commit-hook-index && + ( + cd commit-hook-index && + sane_unset GIT_TEST_SPLIT_INDEX && + write_script .git/hooks/pre-commit <<-\EOF && + printf "hook update\n" >cached/hook-tracked && + oid=$(git hash-object -w cached/hook-tracked) && + git update-index --cacheinfo \ + 100644,$oid,cached/hook-tracked + EOF + GIT_EDITOR=: \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git commit --allow-empty --edit -m adoption && + printf "hook update\n" >expect && + git show HEAD:cached/hook-tracked >actual && + test_cmp expect actual + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked attribute events reopen semantic history' ' + test_when_finished "rm -rf tracked-attr-change" && + test_create_repo tracked-attr-change && + ( + cd tracked-attr-change && + sane_unset GIT_TEST_SPLIT_INDEX && + printf "*.txt text eol=crlf\n" >.gitattributes && + printf "alpha\r\n" >tracked.txt && + git add .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 && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime.actual && + test_must_be_empty .git/prime.actual && + test_grep FSCF .git/index && + test-tool chmtime =-60 tracked.txt && + + printf "*.txt -text\n" >.gitattributes && + test-tool chmtime +1 tracked.txt && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \.M .* tracked.txt$" .git/actual && + test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/status.trace + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-only status adopts missing semantic history' ' + test_when_finished "rm -rf tracked-semantic-adoption" && + test_create_repo tracked-semantic-adoption && + ( + cd tracked-semantic-adoption && + 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 && + git config core.untrackedCache true && + test-tool chmtime -60 tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked) && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime 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 tracked && + test_grep ! FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/actual && + test_grep "^1 \.M .* tracked$" .git/actual && + test_trace2_data status fsmonitor_token/semantic-closed 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'collapsed sparse index uses ordinary token closure' ' + test_when_finished "rm -rf sparse-tracked-only" && + test_create_repo sparse-tracked-only && + ( + cd sparse-tracked-only && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir in outside && + printf "aaaa\n" >in/tracked && + printf "outside\n" >outside/file && + git add . && + git commit -m base && + git sparse-checkout set --cone --sparse-index in && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test-tool chmtime =-60 in/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get in/tracked) && + printf "bbbb\n" >in/tracked && + test-tool chmtime =$mtime in/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 in/tracked && + test_grep ! FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=in/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/actual && + test_grep "^1 \.M .* in/tracked$" .git/actual && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.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_grep FSCF .git/index && + git -c core.fsmonitor=false ls-files --sparse \ + >.git/sparse.after && + test_grep "^outside/$" .git/sparse.after + ) +' + test_done 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 081d266e55cf2dfa4e782b9197a1911c0f9021a2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:25:59 -0500 Subject: [PATCH 119/432] 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 f0f502cd81f1739bbaa0bf687c8449c57f3c59e9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 5 Aug 2026 21:28:42 -0700 Subject: [PATCH 120/432] 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 9fa20ba24c6c33ad50d5e6d56723a6a4cbac7d11 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:43:46 -0500 Subject: [PATCH 121/432] 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 07a27c04923c12d5aa8bd6186e89659b060cdd77 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 10:44:40 -0500 Subject: [PATCH 122/432] 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 063252fe963bf513eba5beb32fc68533168c2459 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:11:23 -0500 Subject: [PATCH 123/432] 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 7b706be59ac562e1054614422dda16e5fba5ea7a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:13:44 -0500 Subject: [PATCH 124/432] 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 c4f258515b3ffa41664bd96df89416c809c66834 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:59 -0500 Subject: [PATCH 125/432] 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 214a26d4e753801e691e85de256640066e7b805d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 7 Aug 2026 01:00:24 -0500 Subject: [PATCH 126/432] 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 8b2303f28b8b4fff2526ee3e25f74eb8e6c3a6fb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:30:24 -0500 Subject: [PATCH 127/432] 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 bfd282a3ac40010ce31369cebae0ab0c609132ea Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:24 -0500 Subject: [PATCH 128/432] 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 6bdcd3645736b32f83639d012a074a4631db933f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:31 -0500 Subject: [PATCH 129/432] 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 b360ab1f4cfc42aec3327a934c878aba098c1a4a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:37 -0500 Subject: [PATCH 130/432] 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, 3668 insertions(+), 93 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 20a81a249ad472..d123bd6df4fdf1 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,9 @@ int cmd_reset(int argc, !pathspec.nr && !intent_to_add && !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 df0edd1380ad56..9b1e8189e2b134 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 6f6da90abf1a67..41e46feed0aee7 100644 --- a/read-cache.c +++ b/read-cache.c @@ -701,8 +701,13 @@ int remove_file_from_index_with_flags(struct index_state *istate, printf(_("remove '%s'\n"), path); if (pretend) return 0; - if (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) - clean_status_invalidate_current_proof(istate); + if (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) { + int pos = index_name_pos(istate, path, strlen(path)); + + if (!clean_status_index_entry_is_semantically_safe( + istate, pos >= 0 ? istate->cache[pos] : NULL, NULL)) + clean_status_invalidate_current_proof(istate); + } return remove_file_from_index(istate, path); } @@ -793,7 +798,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; @@ -880,9 +885,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) @@ -893,7 +900,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) 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 13bab9626a67063f0a91512ee85cb4a9023cece4 Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Tue, 28 Jul 2026 21:51:08 -0500 Subject: [PATCH 131/432] 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 224689709b326f950591a5ca465126a69b61e92a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:10 -0500 Subject: [PATCH 132/432] 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 f76b21223df37170a76a02bf41d2b19d6c89219c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:33 -0500 Subject: [PATCH 133/432] 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 d0af38df20d2ca..5b5b7f0368b330 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 287e6fdc06d562939a09d2d0a565041d74af8927 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:43 -0500 Subject: [PATCH 134/432] 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 d123bd6df4fdf1..37febf65c420f9 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" @@ -525,6 +526,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) { @@ -572,7 +575,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 6299801f4b8c1f02814cf41b55eb4b22b8066826 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:58 -0500 Subject: [PATCH 135/432] 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 136/432] 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 40626261f9acba1e42784a7a04e0308dc0cbc31e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 12 Aug 2026 00:39:37 -0500 Subject: [PATCH 137/432] 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 138/432] 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 139/432] 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 22d891536a654d19052a1381876137c1d5ad9106 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:03:23 -0700 Subject: [PATCH 140/432] 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 552611d428097e96e8ecfb531dff93b89ff35640 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:04:03 -0700 Subject: [PATCH 141/432] 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 117159ab3aa7b33b960a9ca82557091e19ef7cd2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:05:22 -0700 Subject: [PATCH 142/432] 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 ecb5c0a99eddc59ed4e0531c3ba074359db38d77 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:06:00 -0700 Subject: [PATCH 143/432] 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 4a647a1f98b088f6cfb96e2798d7d5a34cd43376 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 16:07:06 -0700 Subject: [PATCH 144/432] 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 f321c3eceaddbbe65c065a5963b36bd94ab9e050 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:56:51 -0500 Subject: [PATCH 145/432] 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 5c33662955cddfc011433488147bccdaad66f503 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:08:32 -0500 Subject: [PATCH 146/432] 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 086476bd77c76a..caf11759379b74 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 8eb266cfd13308..77fabb8b908b79 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) @@ -434,6 +440,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 c0769848587b1a..36b8a8c9a0b8ef 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 b3effc633b1cb5440ff0dc21c2061e5fd8a82db7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:48:16 -0500 Subject: [PATCH 147/432] 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 89e09cd195022bc0f00b4486efdf96a96aad987f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 11:30:07 -0700 Subject: [PATCH 148/432] 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 24f2b9681d5700e6bcafde676220d9150bade243 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:04 -0500 Subject: [PATCH 149/432] 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 57e216592da430f5c6640d3c2390f155bb32e21f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:41:23 -0700 Subject: [PATCH 150/432] 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 caf11759379b74..0e74f201e928ea 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 36b8a8c9a0b8ef..b6fbb268fe896b 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); } } @@ -1455,7 +1456,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 4159049a650f68f60d53ce1c7ecbaaf9d968e097 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:57:14 -0500 Subject: [PATCH 151/432] 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 96df15b5cde1ed..f5767ccf77047f 100644 --- a/git.c +++ b/git.c @@ -933,6 +933,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 65cc3a630d5b97ef76d757cacae7cfef8503cf84 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 152/432] 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 7c5a30c6852f48571bd8b15e8e62d039d32f0861 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:42:25 -0700 Subject: [PATCH 153/432] 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 f94b11326ae82e364600b7c09f6dd47782d7fa68 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:41:39 -0500 Subject: [PATCH 154/432] 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 e62b8488c739c41162485a4e0593a5119ec7c2c1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:35:52 -0500 Subject: [PATCH 155/432] 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 b7e027b5543cdddf63a402981684707ddcdd92a7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:28 -0500 Subject: [PATCH 156/432] 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 d4b775953d3842..f61aa0701e964a 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 181d61a8a0bd18..f40be7f4576867 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 fbb55cab7ac221b36e5acc55f25b8a30144de0ea Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:57:40 -0500 Subject: [PATCH 157/432] 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 d4b775953d3842..a57a2a1559ed61 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 d86f2acd2b2a46..47df40f5e4133f 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 181d61a8a0bd18..f02350d848d697 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 f2706e8ef4f2aaa4b9f2eba53373b826fee4b5da Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:19:27 -0700 Subject: [PATCH 158/432] 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 0c4fe21030b651709903ba68f14a55243ea61294 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:37:23 -0500 Subject: [PATCH 159/432] 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 f4b0bdc9d648462c314795083662643f1ad61845 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 02:00:58 -0500 Subject: [PATCH 160/432] 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 368c48d600672c763bd58ef858a6781261a113cb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:11:47 -0700 Subject: [PATCH 161/432] 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 f21c4d69f4a5b5..a152f90d9d800a 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 47df40f5e4133f..e0d8e056e1494e 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 165e4b6cf6b8622ae47ed55cbc73723540b50a9e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:12:41 -0700 Subject: [PATCH 162/432] 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 a152f90d9d800a..fd0daf95886d34 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 e0d8e056e1494e..b4f0ed3bcbc251 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 8edf9546504a5a50c064df3b5fe3a8ac23a7ec93 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:45:06 -0500 Subject: [PATCH 163/432] 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 d49ff67894660ba03476c71704e5754cea82d18c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:13:45 -0700 Subject: [PATCH 164/432] 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 fd0daf95886d34..0f078d83d2eff9 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 b4f0ed3bcbc251..2fe2c4e13883f5 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 0a1dd06551d9256c915e7115774259dfe37a8cfe Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:45:25 -0700 Subject: [PATCH 165/432] 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 4b85a68b7980015f441b5e26173791d971772e90 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:15:13 -0700 Subject: [PATCH 166/432] 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 0f078d83d2eff9..79527e33c7ffd2 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 2fe2c4e13883f5..bbc30dea7a802f 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 c3ca7c75333a2bddb59be52c6fa75af1a5339287 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:00 -0700 Subject: [PATCH 167/432] 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 79527e33c7ffd2..73529fc0c375a9 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 bbc30dea7a802f..b91d70668bc75c 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 e6dc3cfa3be952..1bc16d910c4268 100644 --- a/t/meson.build +++ b/t/meson.build @@ -947,6 +947,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 4ac2bbd00ae44266e6dc5bfe7f8dfef8b90bc018 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:16:40 -0700 Subject: [PATCH 168/432] 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 1ecc26ec7eb7022b87104c7f0e371a79ba1002b9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:47:44 -0500 Subject: [PATCH 169/432] 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 a57a2a1559ed61..47e4469e625f29 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 95ef6e64dcabff..89fd7bfce90f40 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 8f56203f34d9bc..64f2321921dbf8 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 @@ -668,6 +669,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 47df40f5e4133f..1f670615ec07a6 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 2c1ebe3a57f35f80b8f117f124219869b8b7000e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:24:52 -0700 Subject: [PATCH 170/432] 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 0cede215f5ca6ec351b42d8955bcfc228a817d9f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:48:20 -0500 Subject: [PATCH 171/432] 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 47e4469e625f29..7e30fb9021337b 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 64f2321921dbf8..373b6ee36950d8 100644 --- a/contrib/buildsystems/CMakeLists.txt +++ b/contrib/buildsystems/CMakeLists.txt @@ -671,6 +671,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 1f670615ec07a6..ac313b8b326fbd 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 e82d87894be02e38d2701f932bd451d81a0f1396 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:29:12 -0700 Subject: [PATCH 172/432] 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 73529fc0c375a9..287543e8798dee 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 b91d70668bc75c..ddb5a2d864024d 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 1bc16d910c4268..4e5a69cd2d0809 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 fbdb661b772802e6ffdc72070a00d7253ac6b256 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 07:26:38 -0500 Subject: [PATCH 173/432] 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 89fd7bfce90f40..f647b3e9a9ecfc 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 373b6ee36950d8..614f070a66d968 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 ac313b8b326fbd..66c063619b3056 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 7a5d607045154d7c4feac8671432908a356ddba1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:30:22 -0700 Subject: [PATCH 174/432] 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 287543e8798dee..db27b53d6284f1 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 ddb5a2d864024d..f5a06cb8c65af4 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 4e5a69cd2d0809..4320cbf0b835ae 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 d2080fbac5fbd21b2554ad8ab1ad099de064d3f8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 08:30:18 -0500 Subject: [PATCH 175/432] 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 e2168c48e945c3db976d12c4bc20297820f09afa Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:44:02 -0700 Subject: [PATCH 176/432] 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 77fabb8b908b79..9926858eeefdcd 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 e1db6495ae8257b99c71ea9192a1b4d00d684ea6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:01 -0700 Subject: [PATCH 177/432] 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 dc1f85f88ec32fed825140705dd66ce5f861a95d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:15 -0500 Subject: [PATCH 178/432] 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 7e30fb9021337b..40870c2f9ca700 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 614f070a66d968..9e267ab9d364d2 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 66c063619b3056..e695099174968e 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 f02350d848d697..3410f2752e0d2b 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 adce804e4403c3688392937b3311b64a4ff55256 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:46:00 -0700 Subject: [PATCH 179/432] 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 9926858eeefdcd..f4fff9a26703dd 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 b6fbb268fe896b..b9a1103f8ae345 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" */ @@ -1791,6 +1792,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() */ @@ -2480,6 +2484,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 cf16f493ba456c3676aa3b0c5672b1cba0d0f6c2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:31:54 -0700 Subject: [PATCH 180/432] 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 91fa3771bf053bd33f124cf1df0251b272c385c4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:49:16 -0500 Subject: [PATCH 181/432] 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 9e267ab9d364d2..4d4c9cde2f0aa1 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 e07540035242838e689e738c5c7fd8c3cfaf23a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:47:29 -0700 Subject: [PATCH 182/432] 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 f4fff9a26703dd..960021037d12b2 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 b9a1103f8ae345..4f1aaad523e5ca 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1996,6 +1996,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); } @@ -3091,6 +3092,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 54aa8665d3aa25985264d306fa1027566283ff3d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:33:17 -0700 Subject: [PATCH 183/432] 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 db27b53d6284f1..16c958a5d439cd 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 f5a06cb8c65af4..3157c34006d245 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 4320cbf0b835ae..efa8c53da961df 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 aabf065917cc321daa9fd6c52f1b15ed1a8d8a98 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 12:06:20 -0500 Subject: [PATCH 184/432] 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 ae161e84af132c747d53a26d8c2ece1be4d45c8a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 12:08:32 -0700 Subject: [PATCH 185/432] 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 f21c4d69f4a5b5..57f2228ff10d21 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 e6dc3cfa3be952..de7817c3c76097 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 4238dc53da2842eea6c9bfd96294b62fe85014a3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:34:40 -0700 Subject: [PATCH 186/432] 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 16c958a5d439cd..88226f8b445322 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 3157c34006d245..7e494d672c7050 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 a4cd239f53f5323322974ee83753dd80f07c6c97 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 10:30:31 -0500 Subject: [PATCH 187/432] 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 40870c2f9ca700..e4e3800ece6553 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 4d4c9cde2f0aa1..1e643c50a12ec0 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 @@ -677,7 +678,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 e695099174968e..27470fbb0984b5 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 9a9daaf2afe5e2..0849ced1b4cd19 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 3410f2752e0d2b..88cf54dc570e55 100644 --- a/t/meson.build +++ b/t/meson.build @@ -947,6 +947,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 18ee0741e3f3a73ad4fd6eafa36029fb94568e3f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:49 -0500 Subject: [PATCH 188/432] 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 bc9a82dc8c21a0e3068aa509a3f420b021afdc01 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:40:39 -0700 Subject: [PATCH 189/432] 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 4ba9836e5c9e786ed600835c318990ef3103d37a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 10:31:40 -0500 Subject: [PATCH 190/432] 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 0849ced1b4cd19..6934d75bd07b8d 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 5aa9c901143c5184295a0f41944763fb0c320db2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:44:57 -0500 Subject: [PATCH 191/432] 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 f26700da0e9c02cfed3dd65051832f805272e603 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:30:48 -0700 Subject: [PATCH 192/432] 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 88226f8b445322..788dc66567b13b 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 7e494d672c7050..73b2c700863ccb 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 efa8c53da961df..ad25820c8281e6 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 5eed0b9266fbe74a33860c13ef3be10cfcc053b0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:48:46 -0700 Subject: [PATCH 193/432] 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 8f9ada6f2126147a16a0716cbe1a15f2b976fa2a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 14:42:56 -0700 Subject: [PATCH 194/432] 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 788dc66567b13b..10232268cc84be 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 73b2c700863ccb..3e81af2d0eb3f4 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 ad25820c8281e6..5886a55cbcaf67 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 8d003e02f7566babb7218ce484d0ada95d8b7352 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:54:41 -0500 Subject: [PATCH 195/432] 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 4f7607acfb75b17ce5cdb00c9e8f2deffe994fc5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 10:50:14 -0700 Subject: [PATCH 196/432] 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 c0769848587b1a..40d01bdc772035 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2013,27 +2013,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 c999930fd5528f16c618863d539c300368b5df19 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 21:52:33 -0700 Subject: [PATCH 197/432] 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 cd8df2212864ae73094d6dbc5fec5167926ac00a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 11 Jul 2026 10:32:32 -0700 Subject: [PATCH 198/432] 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 c45c17e605afb6726426fcc49157801e793bccfa Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:55:11 -0500 Subject: [PATCH 199/432] 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 dfcb08e8d18d08b19c901a41dde8fe4795d7e5c4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:33 -0500 Subject: [PATCH 200/432] 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 ace0b0097054dc472a8db78ab618fc86b909a7c3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:48:56 -0500 Subject: [PATCH 201/432] 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 732c70079a8b99..5d59356ffeb908 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) { @@ -1472,19 +1489,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 164ebc859fac1bfb53ab28cd5ad3d5396d3e957a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:50:12 -0500 Subject: [PATCH 202/432] 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 960021037d12b2..cc6d932800ebd6 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 4f1aaad523e5ca..3029c83a1f88fd 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2485,6 +2485,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 d81902ab66feabe0dfd685b6b5e7838c861eecc3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:47:10 -0700 Subject: [PATCH 203/432] 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 10232268cc84be..29f11e142a9fe2 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 3e81af2d0eb3f4..f496a6bc9b8a67 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 5886a55cbcaf67..1e65920bb5ff85 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 0c0ac94ee83abfb936a11b9c4814b5613806f97b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 15:24:55 -0500 Subject: [PATCH 204/432] 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 d69f875f62315808c3243ba6394b49b58432f1e8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:55:56 -0500 Subject: [PATCH 205/432] 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 cc6d932800ebd6..a1a9fce438f4c8 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; @@ -477,6 +479,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 3029c83a1f88fd..732c70079a8b99 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1564,7 +1564,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++) { @@ -1608,6 +1608,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); @@ -2487,6 +2501,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 906756be8734c9e7a7ba0842c850aa01c351231d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:01:53 -0500 Subject: [PATCH 206/432] 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 40d01bdc772035..2e04c2d38a1862 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2008,23 +2008,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) { @@ -2035,20 +2116,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; } @@ -2057,11 +2137,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; } @@ -2293,6 +2384,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); @@ -2314,13 +2407,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 cc7f4014de9f2ee329f9862c7b7e60bb7a21e128 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:51:15 -0500 Subject: [PATCH 207/432] 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 0e74f201e928ea..1487199e231578 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 a1a9fce438f4c8..c9f3c6cb7e9646 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -500,6 +500,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 5d59356ffeb908..307bd5366c379c 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 a52ef383c443e199b034a2a9b7a4dcae12a48fe8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:32 -0500 Subject: [PATCH 208/432] 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 2dfe3e6e8be839..f7daa1130ac1fd 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 f496a6bc9b8a67..5d64f774a85451 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 cc6d932800ebd6..8c3b2b8480aabc 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 3029c83a1f88fd..f54f0c7a2ebbbe 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" @@ -2255,6 +2256,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; @@ -2487,6 +2489,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 5e0ea2b37262fd7585445d84d127628e1adc7b37 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 25 Jul 2026 21:55:36 -0500 Subject: [PATCH 209/432] 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 d458572c62965924a15455ca170b61eba9f3c87c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:46:34 -0500 Subject: [PATCH 210/432] 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 f7daa1130ac1fd..4e115bc540c003 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 5d64f774a85451..37706d067bbf2f 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 c9160a2f1be046..979df86aae8167 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 4723a2a1c789892d8f0d8f4d0c32f760fc95f2fb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 23:15:36 -0700 Subject: [PATCH 211/432] 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 4e115bc540c003..0732e63f887682 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 37706d067bbf2f..a4190fc5af32a7 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 f54f0c7a2ebbbe..c38004d3eff8f5 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2274,6 +2274,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) @@ -2763,6 +2764,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 979df86aae8167..48680e152a89f4 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 f6abf39a8f22eeb8032a99fd80982e55afe495eb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:32 -0500 Subject: [PATCH 212/432] 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 0732e63f887682..626e98d59dac34 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 a4190fc5af32a7..d741a595e81761 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 c38004d3eff8f5..8806dc975b95d0 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" */ @@ -1793,6 +1794,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; @@ -1998,6 +2002,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 48680e152a89f4..41dbd76da74c73 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 7ed7c7f5e8303f2a92e52d6c42cb34c1e82bc720 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:47:47 -0500 Subject: [PATCH 213/432] 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 | 49 ++++++++++- 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, 618 insertions(+), 29 deletions(-) diff --git a/builtin/add.c b/builtin/add.c index eab8f03cad31d6..a95695d6fc2bf8 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" @@ -288,18 +290,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_( @@ -457,18 +461,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); @@ -570,8 +581,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); @@ -683,6 +713,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 8c3b2b8480aabc..1efcea7d67c126 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -414,6 +414,7 @@ int remove_file_from_index_with_flags(struct index_state *, const char *, int); #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 diff --git a/read-cache.c b/read-cache.c index 8806dc975b95d0..270df01348a555 100644 --- a/read-cache.c +++ b/read-cache.c @@ -677,6 +677,8 @@ int remove_file_from_index_with_flags(struct index_state *istate, printf(_("remove '%s'\n"), path); if (pretend) return 0; + if (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) + clean_status_invalidate_current_proof(istate); return remove_file_from_index(istate, path); } @@ -743,6 +745,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; @@ -753,7 +769,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; @@ -838,12 +855,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); @@ -2854,6 +2881,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) @@ -3121,6 +3149,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; 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 6be60ce2b06b3f60ae9c275defee70125b5d3162 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 10 Jul 2026 22:53:53 -0700 Subject: [PATCH 214/432] 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 270df01348a555..a6858778cfc135 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3616,6 +3616,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 50f2f807d9b232d2db04bb17808b71b4174ac27e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 20 Jul 2026 20:42:14 -0500 Subject: [PATCH 215/432] 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 e3b798a1053dbd1d7c63e0964825e84f2cd1ffd8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:06:21 -0500 Subject: [PATCH 216/432] 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 a6858778cfc135..1076d064582b7c 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1674,7 +1674,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 c4a8c4c5fb8f2c9f8b23386374bc5efbfb63d29c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:51:39 -0500 Subject: [PATCH 217/432] 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 626e98d59dac34..86555972a52ea1 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 d741a595e81761..9ff03e477b2a28 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 1076d064582b7c..9d974d45c093f9 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2304,7 +2304,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); @@ -2323,10 +2323,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) @@ -2418,9 +2422,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 41dbd76da74c73..4389b0862cf06d 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 a9d6e7653be25781034324e43ac704362cc73f8a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:07:02 -0500 Subject: [PATCH 218/432] 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 054530274db1969b697c154fa92b6800aa188c94 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:41:16 -0500 Subject: [PATCH 219/432] 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 86555972a52ea1..3bb8c3333d1c2c 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 9ff03e477b2a28..61ea7cd29e2e50 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 1efcea7d67c126..a3dc618f3f5ee9 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -480,6 +480,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 9d974d45c093f9..440cda08920fcf 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1675,13 +1675,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]); } @@ -2038,6 +2039,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 a9ba041a50a89b3866721f1fc002ceb3d67e594f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:42:44 -0500 Subject: [PATCH 220/432] 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 9a29db57cd19a4967b8fd8c50bbfaf0e0aaa5571 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:44:44 -0500 Subject: [PATCH 221/432] 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 124aec13fcc8f7f0aff827312c912a873ae9ec78 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 23 Jul 2026 23:46:57 -0500 Subject: [PATCH 222/432] 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 c64d9d327bedb1e3eab660ca541058eb1e854ccd Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:46:20 -0500 Subject: [PATCH 223/432] 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 377b8c5a4d0374bb42b7be51e73da79bc0c60cba Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:03 -0500 Subject: [PATCH 224/432] 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 8f2768ae3bafcc..6abc24463635ee 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 bb11dd51a6db3f..5edba88003022f 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 6b87e505f03059..fdb679b79a593d 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 505e149994b0a946de8bf4746611db65bcbdaa27 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:03:31 -0500 Subject: [PATCH 225/432] 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 408da96af967587a605f71cc4c1597d8100f38e4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:14:28 -0500 Subject: [PATCH 226/432] 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 698c8300a54494..bfbbe17cd483e6 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 8c1d2c62e1ec29d02462d618dff1ba9977dfa062 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:26:44 -0500 Subject: [PATCH 227/432] 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 19881aaec3e7003cd1527ebc85b40d43b5b5e3c2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:27:01 -0500 Subject: [PATCH 228/432] 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 bfbbe17cd483e6..cec6a7bc563b80 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 51700e713aa5bcd72fc41f206ccd5b2d7ce5a7b5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:32:42 -0500 Subject: [PATCH 229/432] 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 f647b3e9a9ecfc..7e0f6a14240636 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 1e643c50a12ec0..f2001ebc6d7b6a 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 5edba88003022f..abc5617b7661f9 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 fe85124d1a4e110d6aca9c9ed876318251b2f9b6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:33:21 -0500 Subject: [PATCH 230/432] 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 7e0f6a14240636..4ec968eb7310dc 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 f2001ebc6d7b6a..66049bd0f1601b 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 abc5617b7661f9..1f57f49515d986 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 ea3d88cbaa01de82bf82f5af38be39eaa667e833 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:12 -0500 Subject: [PATCH 231/432] 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 4ec968eb7310dc..981fba0fea5c26 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 66049bd0f1601b..4df74bece2e551 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 1f57f49515d986..50263f513c2367 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 903986b3bab390a57260c5baf4bf2192b5834ce2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:34:39 -0500 Subject: [PATCH 232/432] 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 981fba0fea5c26..8f37e5936d8e58 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 4df74bece2e551..01f79769917427 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 50263f513c2367..6e550efbb34c60 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 44231edcd2597aa805246bfdc8f5f695a17ac8b0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:35:16 -0500 Subject: [PATCH 233/432] 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 8f37e5936d8e58..7ea7be4047cc98 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 01f79769917427..86f55e57efa814 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 6e550efbb34c60..d72371e302d477 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 124fdfe9234bd1c073403dec95006a9ac8993074 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 13:39:36 -0500 Subject: [PATCH 234/432] 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 7ea7be4047cc98..d5c5732932eb38 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 86f55e57efa814..204b549ebed75f 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 @@ -683,7 +685,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 d72371e302d477..f9ff1b8ed4827e 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 6934d75bd07b8d..6f557abebfd374 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 fdb679b79a593d..cb1a6b181ffbae 100644 --- a/t/meson.build +++ b/t/meson.build @@ -962,6 +962,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 0929c7b9629fc99cfb7cbfecc6e2b8a6c6f41d1d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 29 Jul 2026 16:53:33 -0500 Subject: [PATCH 235/432] 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 cec6a7bc563b80..bcbfdfe12b7409 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 1ef419ffc0b7e4..b6529a9306fdda 100644 --- a/read-cache.c +++ b/read-cache.c @@ -1642,6 +1642,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; @@ -2558,6 +2561,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 35931f374d7a4c0490013c5adfa96c6b475cd68a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:13:48 -0500 Subject: [PATCH 236/432] 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 f72d9f6828a9d4f04127ffeb534bff324ea35c61 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 14:04:25 -0500 Subject: [PATCH 237/432] 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 6abc24463635ee..14272440a7c3b3 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 f9ff1b8ed4827e..90c543e2e230f5 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 cb1a6b181ffbae..339836590c3603 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 d7a0fcc2906ccfab11ce0676c1075761d6d8cc41 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:06 -0500 Subject: [PATCH 238/432] 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 14272440a7c3b3..c765c978b81fc5 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 339836590c3603..592c1abbff28f1 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 be1f1615951a3f43205b2d5a0f25d7d931b84596 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 00:57:16 -0500 Subject: [PATCH 239/432] 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 25fd7e9264fafe4efa8095ef8daec7f3af44a21d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 28 Jul 2026 02:28:35 -0500 Subject: [PATCH 240/432] 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 c765c978b81fc5..84186e36bb55d1 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 90c543e2e230f5..2e104119fa56b1 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 bcbfdfe12b7409..e1aad60217d006 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 592c1abbff28f1..8745d20feb759a 100644 --- a/t/meson.build +++ b/t/meson.build @@ -963,6 +963,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 45284dba5452833d242c467a54c99f58dc940bd6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 24 Jul 2026 01:16:38 -0500 Subject: [PATCH 241/432] 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 9450f7dbac36e15e758a5f3d0355b70e029fe164 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:12 -0500 Subject: [PATCH 242/432] 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 984e6bc0f0d3fa6542178480afd0c2cbd57bade0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 27 Jul 2026 16:07:26 -0500 Subject: [PATCH 243/432] 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 84186e36bb55d1..51aa781379f505 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 2e104119fa56b1..bad1fd85101cb3 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 ab2d58bf03c60545e03bac688140ed50573433ae Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 21 Jul 2026 17:20:24 -0500 Subject: [PATCH 244/432] 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 f8dea4b3953250..199a09691a1c7f 100644 --- a/Documentation/Makefile +++ b/Documentation/Makefile @@ -143,6 +143,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 9ce11d5e484d9c..d628b7c4b3cb04 100644 --- a/Documentation/technical/meson.build +++ b/Documentation/technical/meson.build @@ -32,6 +32,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 487a384350ae22c61e8cf656453b09bb9d3ed452 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 19:10:21 -0600 Subject: [PATCH 245/432] 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 68a2139338fea0731425d123d45c6e5f209c4003 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 23:58:32 -0500 Subject: [PATCH 246/432] 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 a5c115e022045dacae2b5c321d3f9f5039bdc0be Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 00:03:34 -0500 Subject: [PATCH 247/432] 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 248/432] 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 fe3dcf687a4c5559e1847e2a306d4ea7514815d6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 11:49:08 -0500 Subject: [PATCH 249/432] 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 250/432] 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 dae1208137cf545974f9ceb017897ca0bf9399d3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:49:03 -0500 Subject: [PATCH 251/432] 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 0d8557348457d702a5bce1691176e9981e59dfb4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:06:36 -0500 Subject: [PATCH 252/432] 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 253/432] 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 128801cc80ecc9bcb1338e8bad6c7f0418e1d32e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:24:24 -0500 Subject: [PATCH 254/432] 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 5a2f72a2f7f2095f914251dea81b6ae11ff5bdce Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:09:31 -0500 Subject: [PATCH 255/432] 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 378744b68f6785874424485ed05428b27ae75c5a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:57 -0500 Subject: [PATCH 256/432] 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 99031cddbe8b46240b6d20bfbcbf0c81d4a937bc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 22:48:46 -0500 Subject: [PATCH 257/432] 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 | 1383 +++++++++++++++++++++++++++++- wt-status.c | 37 +- 13 files changed, 1860 insertions(+), 90 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 0f84c3bb322da1..df0edd1380ad56 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 976dac6748c365..6f6da90abf1a67 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3316,7 +3316,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); @@ -3330,6 +3331,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 81f8bf59c6a896..304e020206b1bb 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1562,63 +1562,1006 @@ 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" && + test_create_repo daemon-token-reset && + ( + cd daemon-token-reset && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit remove removed && + test_commit keep clean && + git config core.preloadIndexBulk true && + git config core.untrackedCache true && + git config index.skipHash true && + test-tool chmtime =-60 tracked removed clean && + git update-index --refresh && + git config core.fsmonitor true && + start_daemon && + + git update-index --force-write-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 && + test_grep FSUC .git/index && + test_trailing_hash .git/index >.git/index.hash && + test_oid zero >.git/zero && + test_cmp .git/zero .git/index.hash && + test-tool dump-fsmonitor >.git/token.before && + token_before=$(sed -n \ + "s/^fsmonitor last update //p" .git/token.before) && + + git fsmonitor--daemon stop && + start_daemon && + GIT_TRACE2_EVENT="$PWD/.git/reset.trace" \ + git status --porcelain=v2 --untracked-files=normal >.git/reset.out && + test_must_be_empty .git/reset.out && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/reset.trace && + test_trace2_data index preload/bulk_provider_applied \ + "[1-9][0-9]*" \ + <.git/reset.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/reset.trace && + test-tool dump-fsmonitor >.git/token.after && + token_after=$(sed -n \ + "s/^fsmonitor last update //p" .git/token.after) && + test -n "$token_before" && + test -n "$token_after" && + test "$token_before" != "$token_after" && + + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git status --porcelain=v2 >.git/warm.out && + test_must_be_empty .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 && + + git fsmonitor--daemon stop && + echo changed >>tracked && + rm removed && + start_daemon && + GIT_TRACE2_EVENT="$PWD/.git/dirty-reset.trace" \ + git status --porcelain=v2 >.git/dirty-reset.out && + test_line_count = 2 .git/dirty-reset.out && + test_grep "^1 \.M .* tracked$" .git/dirty-reset.out && + test_grep "^1 \.D .* removed$" .git/dirty-reset.out && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/dirty-reset.trace && + test_trace2_data index preload/bulk_provider_applied 1 \ + <.git/dirty-reset.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/dirty-reset.trace && + + GIT_TRACE2_EVENT="$PWD/.git/dirty-warm.trace" \ + git status --porcelain=v2 >.git/dirty-warm.out && + test_cmp .git/dirty-reset.out .git/dirty-warm.out && + test_trace2_data index preload/bulk_provider_applied 1 \ + <.git/dirty-warm.trace && + test_trace2_data index refresh/sum_lstat 0 \ + <.git/dirty-warm.trace && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/dirty-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 && + "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_trace2_data fsm_client query/command \ + "builtin:test-capable:0" <.git/status.trace && + 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_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary deltas advance only attribute-stable proofs' ' + test_when_finished "rm -rf token-carry" && + test_create_repo token-carry && + ( + cd token-carry && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines "*.txt text" >.gitattributes && + git add .gitattributes && + git commit -m attributes && + git config core.untrackedCache true && + git -c core.fsmonitor=false status --porcelain=v2 \ + >.git/initial && + test_must_be_empty .git/initial && + 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_grep FSUC .git/index && + test_grep FSCF .git/index && + + touch x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + GIT_TRACE2_EVENT="$PWD/.git/created.trace" \ + git status --porcelain=v2 >.git/created && + test_grep "^? x$" .git/created && + test_trace2_data fsmonitor config/token-advanced 1 \ + <.git/created.trace && + + rm x && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=x \ + GIT_TRACE2_EVENT="$PWD/.git/deleted.trace" \ + git status --porcelain=v2 >.git/deleted && + test_must_be_empty .git/deleted && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/deleted.trace && + test_trace2_data fsmonitor config/token-advanced 1 \ + <.git/deleted.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/attributes.trace" \ + git status --porcelain=v2 >.git/attributes && + test_must_be_empty .git/attributes && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/attributes.trace && + test_trace2_data fsmonitor apply_count 1 \ + <.git/attributes.trace && + ! test_trace2_data fsmonitor config/token-advanced 1 \ + <.git/attributes.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'worktree-only checkout preserves closed semantic history' ' + test_when_finished "rm -rf checkout-history" && + test_create_repo checkout-history && + ( + cd checkout-history && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_commit other other && + 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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout -- tracked && + + 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 \ + 'source-tree checkout preserves closed semantic history' ' + test_when_finished "rm -rf checkout-source-history" && + test_create_repo checkout-source-history && + ( + cd checkout-source-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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout HEAD -- tracked && + + 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 \ + 'source-tree checkout drops history after an index change' ' + test_when_finished "rm -rf checkout-source-changed" && + test_create_repo checkout-source-changed && + ( + cd checkout-source-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines next >tracked && + git add tracked && + git commit -m next && + 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_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git checkout HEAD^ -- tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + 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 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'checkout-index -u preserves closed semantic history' ' + test_when_finished "rm -rf checkout-index-history" && + test_create_repo checkout-index-history && + ( + cd checkout-index-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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout-index -f -u tracked && + 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 \ + 'stat-only update-index preserves closed semantic history' ' + test_when_finished "rm -rf update-index-history" && + test_create_repo update-index-history && + ( + cd update-index-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_grep FSCF .git/index && + + test-tool chmtime +1 tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git update-index --refresh --force-write-index && + 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 \ + 'add --refresh preserves closed semantic history' ' + test_when_finished "rm -rf add-refresh-history" && + test_create_repo add-refresh-history && + ( + cd add-refresh-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_grep FSCF .git/index && + + test-tool chmtime +1 tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git add --refresh tracked && + 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 \ + 'mtime-only ordinary add preserves closed semantic history' ' + test_when_finished "rm -rf add-ordinary-history" && + test_create_repo add-ordinary-history && + ( + cd add-ordinary-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_grep FSCF .git/index && + + test-tool chmtime +1 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/path.trace" \ + git status >.git/path && + test_grep "nothing to commit, working tree clean" .git/path && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/path.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/path.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'ordinary add drops history after a logical index change' ' + test_when_finished "rm -rf add-ordinary-changed" && + test_create_repo add-ordinary-changed && + ( + cd add-ordinary-changed && + 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_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/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 M\." .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.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 && + ( + 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_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 \ + '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_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_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git stash push >.git/stash && + test_grep "No local changes to save" .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 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 \ + 'dirty stash push drops closed semantic history' ' + test_when_finished "rm -rf stash-dirty-history" && + test_create_repo stash-dirty-history && + ( + cd stash-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_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_grep FSCF .git/index && + + 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 config/coherent 0 \ + <.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" && + test_create_repo reset-mixed-same-tree && + ( + cd reset-mixed-same-tree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git commit --allow-empty -m same-tree && + 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_grep FSCF .git/index && + + test-tool chmtime +1 tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git reset --mixed HEAD^ >.git/reset && + 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 \ + 'mixed reset drops history after a logical index change' ' + test_when_finished "rm -rf reset-mixed-changed" && + test_create_repo reset-mixed-changed && + ( + cd reset-mixed-changed && + 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_grep FSCF .git/index && + + test_write_lines staged >tracked && + git add tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/staged && + test_grep "^1 M\." .git/staged && + test_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git reset --mixed HEAD >.git/reset && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status >.git/actual && + test_grep "modified:.*tracked" .git/actual && + test_trace2_data fsmonitor config/coherent 0 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'hard reset to a same-tree commit preserves closed history' ' + test_when_finished "rm -rf reset-hard-same-tree" && + test_create_repo reset-hard-same-tree && + ( + cd reset-hard-same-tree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git commit --allow-empty -m same-tree && + 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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git reset --hard HEAD^ >.git/reset && + 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 \ + 'hard reset to a different tree drops closed semantic history' ' + test_when_finished "rm -rf reset-hard-changed" && + test_create_repo reset-hard-changed && + ( + cd reset-hard-changed && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + test_write_lines next >tracked && + git add tracked && + git commit -m next && + 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_grep FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git reset --hard HEAD^ >.git/reset && + 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 0 \ + <.git/status.trace + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'forced same-tree checkout preserves closed semantic history' ' + test_when_finished "rm -rf checkout-same-tree" && + test_create_repo checkout-same-tree && + ( + cd checkout-same-tree && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git branch same && + 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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git checkout -f same >.git/checkout && + + 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 \ + 'one-tree read-tree reset preserves closed semantic history' ' + test_when_finished "rm -rf read-tree-reset-history" && + test_create_repo read-tree-reset-history && + ( + cd read-tree-reset-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_grep FSCF .git/index && + + test_write_lines changed >tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git status >.git/dirty && + test_grep "modified:.*tracked" .git/dirty && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + git read-tree --reset -u HEAD >.git/read-tree && + + 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 index refresh/sum_lstat 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'missing semantic history seeds a forward baseline' ' + test_when_finished \ + "stop_daemon_delete_repo missing-semantic-baseline" && + test_create_repo missing-semantic-baseline && ( - cd capability-superset && + cd missing-semantic-baseline && 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_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor-valid tracked && + test_grep ! FSCF .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 FSCF .git/index && + test_trace2_data fsmonitor semantic/adoption-baseline 1 \ + <.git/status.trace && + ! test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.git/status.trace && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace + ) +' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'missing semantic history with weak stat identity forces content verification' ' + test_when_finished \ + "stop_daemon_delete_repo missing-semantic-history" && + test_create_repo missing-semantic-history && + ( + cd missing-semantic-history && + 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) && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime tracked && + git config core.fsmonitor true && + git update-index --fsmonitor && + git update-index --fsmonitor-valid tracked && + test_grep FSMN .git/index && + test_grep ! FSCF .git/index && 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 && - test_grep ! \ - "\"key\":\"query/incompatible-daemon\"" \ - .git/status.trace && - test_grep ! \ - "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ - .git/status.trace + git status --porcelain=v2 >.git/actual && + test_line_count = 1 .git/actual && + test_grep "^1 \.M .* tracked$" .git/actual && + test_trace2_data fsmonitor semantic/strong-invalidation 1 \ + <.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 && +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'token closure refresh starts inside its proof epoch' ' + test_when_finished "rm -rf proof-epoch-refresh" && + test_create_repo proof-epoch-refresh && ( - cd binding-a && + cd proof-epoch-refresh && + sane_unset GIT_TEST_SPLIT_INDEX && 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 + 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_grep FSCF .git/index && + + # Leave the next refresh with untracked history to bootstrap. + git update-index --no-untracked-cache 2>.git/no-uc.err && + test_grep FSCF .git/index && + test_grep ! FSUC .git/index && + + test_env GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + test_must_fail git commit --dry-run --porcelain \ + >.git/actual && + test_must_be_empty .git/actual && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + captured=$(test_grep -n \ + "\"key\":\"semantic/proof-epoch-captured\"" \ + .git/status.trace | sed -n "1s/:.*//p") && + refreshed=$(test_grep -n \ + "\"category\":\"index\",\"label\":\"refresh\"" \ + .git/status.trace | sed -n "\$s/:.*//p") && + test -n "$captured" && + test -n "$refreshed" && + test "$captured" -lt "$refreshed" + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'trivial query closes zero-trailer unbound history' ' + test_when_finished "rm -rf unbound-trivial" && + test_create_repo unbound-trivial && + ( + cd unbound-trivial && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config index.version 4 && + git config feature.manyFiles true && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + test-tool read-cache --test-fscf-round-trip && + test_grep FSMN .git/index && + test_grep FSCF .git/index && + test_trailing_hash .git/index >.git/index.hash && + test_oid zero >.git/zero && + test_cmp .git/zero .git/index.hash && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TC \ + GIT_TRACE2_EVENT="$PWD/.git/recovery.trace" \ + git status \ + >.git/recovery.out && + test_grep "nothing to commit, working tree clean" \ + .git/recovery.out && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/recovery.trace && + test_trace2_data fsmonitor semantic/proof-epoch-captured 1 \ + <.git/recovery.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/recovery.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/recovery.trace && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + GIT_TRACE2_EVENT="$PWD/.git/warm.trace" \ + git status \ + >.git/warm.out && + test_grep "nothing to commit, working tree clean" \ + .git/warm.out && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/warm.trace && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <.git/warm.trace && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/warm.trace && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/warm.trace && + ! test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/warm.trace + ) ' test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ @@ -1686,4 +2629,362 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'sparse index rebuilds semantic history without expansion' ' + test_when_finished "rm -rf sparse-semantic" && + test_create_repo sparse-semantic && + ( + cd sparse-semantic && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir in outside && + printf "aaaa\n" >in/tracked && + printf "outside\n" >outside/file && + git add . && + git commit -m base && + git sparse-checkout set --cone --sparse-index in && + git ls-files --sparse >.git/sparse.before && + test_grep "^outside/$" .git/sparse.before && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test-tool chmtime =-60 in/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get in/tracked) && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TCCC \ + GIT_TRACE2_EVENT="$PWD/.git/prime.trace" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + test_trace2_data fsm_client query/trivial-response 1 \ + <.git/prime.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/prime.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/prime.trace && + test_grep FSMN .git/index && + git -c core.fsmonitor=false ls-files --sparse \ + >.git/sparse.after-prime && + test_grep "^outside/$" .git/sparse.after-prime && + printf "bbbb\n" >in/tracked && + test-tool chmtime =$mtime in/tracked && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=D \ + GIT_TEST_FSMONITOR_QUERY_PATH=in/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/change.trace" \ + git status --porcelain=v2 >.git/actual && + test_grep "^1 \.M .* in/tracked$" .git/actual && + test_trace2_data fsmonitor apply_count 1 \ + <.git/change.trace && + test_grep FSMN .git/index && + git -c core.fsmonitor=false ls-files --sparse \ + >.git/sparse.after-change && + test_grep "^outside/$" .git/sparse.after-change + ) +' + +prepare_semantic_untracked_repo () { + r=$1 && + test_create_repo "$r" && + ( + cd "$r" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines "*.ignored" >.gitignore && + test_write_lines "*.ignored" >cached/.gitignore && + printf "aaaa\n" >cached/tracked && + printf "cccc\n" >cached/hook-tracked && + git add .gitignore cached/.gitignore cached/hook-tracked \ + cached/tracked && + git commit -m base && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test_write_lines ignored >cached/junk.ignored && + git status --porcelain=v2 >.git/prime.actual && + test_must_be_empty .git/prime.actual && + test_grep UNTR .git/index && + 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 FSMN .git/index && + test_grep ! FSCF .git/index + ) +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'semantic adoption closes the untracked scan' ' + test_when_finished "rm -rf semantic-untracked" && + prepare_semantic_untracked_repo semantic-untracked && + ( + cd semantic-untracked && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + 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/untracked-deferred 1 \ + <.git/status.trace && + 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 \ + "[0-9][0-9]*" <.git/status.trace >.git/apply-count && + test_line_count = 2 .git/apply-count && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index + ) +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'failed untracked closure discards semantic adoption' ' + test_when_finished "rm -rf failed-semantic-untracked" && + prepare_semantic_untracked_repo failed-semantic-untracked && + ( + cd failed-semantic-untracked && + sane_unset GIT_TEST_SPLIT_INDEX && + GIT_DISABLE_UNTRACKED_CACHE=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CC \ + 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 0 \ + <.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 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 UNTRACKED_CACHE,!MINGW,!CYGWIN \ + 'commit closes hook changes without an untracked cache' ' + test_when_finished "rm -rf commit-hook-closure" && + prepare_semantic_untracked_repo commit-hook-closure && + ( + cd commit-hook-closure && + sane_unset GIT_TEST_SPLIT_INDEX && + git config core.untrackedCache false && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --no-untracked-cache && + test_grep ! UNTR .git/index && + write_script .git/hooks/pre-commit <<-\EOF && + mtime=$(test-tool chmtime --get cached/hook-tracked) && + printf "dddd\n" >cached/hook-tracked && + test-tool chmtime =$mtime cached/hook-tracked + EOF + GIT_EDITOR=: \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/hook-tracked \ + GIT_TRACE2_EVENT="$PWD/.git/commit.trace" \ + git commit --allow-empty --edit -m adoption && + test_trace2_data status fsmonitor_token/semantic-closed 1 \ + <.git/commit.trace && + test_trace2_data fsmonitor token_closure/apply_count "[1-9]" \ + <.git/commit.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/commit.trace >.git/accepted && + test_line_count = 2 .git/accepted && + 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/status.actual && + test_grep "^1 \.M .* cached/hook-tracked$" \ + .git/status.actual && + test_grep ! UNTR .git/index + ) +' + +test_expect_success UNTRACKED_CACHE,!MINGW,!CYGWIN \ + 'failed hook closure refreshes the worktree' ' + test_when_finished "rm -rf commit-hook-fallback" && + prepare_semantic_untracked_repo commit-hook-fallback && + ( + cd commit-hook-fallback && + sane_unset GIT_TEST_SPLIT_INDEX && + write_script .git/hooks/pre-commit <<-\EOF && + mtime=$(test-tool chmtime --get cached/hook-tracked) && + printf "dddd\n" >cached/hook-tracked && + test-tool chmtime =$mtime cached/hook-tracked + EOF + GIT_EDITOR=: \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCE \ + GIT_TRACE2_EVENT="$PWD/.git/commit.trace" \ + git commit --allow-empty --edit -m adoption && + test_trace2_data fsmonitor token_closure/rejected 1 \ + <.git/commit.trace && + test_trace2_data status count/changed 2 <.git/commit.trace && + test_grep "cached/hook-tracked$" .git/COMMIT_EDITMSG && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/status.actual && + test_grep "cached/hook-tracked$" .git/status.actual + ) +' + +test_expect_success UNTRACKED_CACHE,!MINGW,!CYGWIN \ + 'post-hook refresh preserves hook index updates' ' + test_when_finished "rm -rf commit-hook-index" && + prepare_semantic_untracked_repo commit-hook-index && + ( + cd commit-hook-index && + sane_unset GIT_TEST_SPLIT_INDEX && + write_script .git/hooks/pre-commit <<-\EOF && + printf "hook update\n" >cached/hook-tracked && + oid=$(git hash-object -w cached/hook-tracked) && + git update-index --cacheinfo \ + 100644,$oid,cached/hook-tracked + EOF + GIT_EDITOR=: \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git commit --allow-empty --edit -m adoption && + printf "hook update\n" >expect && + git show HEAD:cached/hook-tracked >actual && + test_cmp expect actual + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked attribute events reopen semantic history' ' + test_when_finished "rm -rf tracked-attr-change" && + test_create_repo tracked-attr-change && + ( + cd tracked-attr-change && + sane_unset GIT_TEST_SPLIT_INDEX && + printf "*.txt text eol=crlf\n" >.gitattributes && + printf "alpha\r\n" >tracked.txt && + git add .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 && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + git status --porcelain=v2 >.git/prime.actual && + test_must_be_empty .git/prime.actual && + test_grep FSCF .git/index && + test-tool chmtime =-60 tracked.txt && + + printf "*.txt -text\n" >.gitattributes && + test-tool chmtime +1 tracked.txt && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=.gitattributes \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \.M .* tracked.txt$" .git/actual && + test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9]" <.git/status.trace + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'tracked-only status adopts missing semantic history' ' + test_when_finished "rm -rf tracked-semantic-adoption" && + test_create_repo tracked-semantic-adoption && + ( + cd tracked-semantic-adoption && + 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 && + git config core.untrackedCache true && + test-tool chmtime -60 tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get tracked) && + printf "bbbb\n" >tracked && + test-tool chmtime =$mtime 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 tracked && + test_grep ! FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/actual && + test_grep "^1 \.M .* tracked$" .git/actual && + test_trace2_data status fsmonitor_token/semantic-closed 1 \ + <.git/status.trace && + test_trace2_data fsmonitor token_closure/accepted 1 \ + <.git/status.trace && + test_grep FSCF .git/index + ) +' + +test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'collapsed sparse index uses ordinary token closure' ' + test_when_finished "rm -rf sparse-tracked-only" && + test_create_repo sparse-tracked-only && + ( + cd sparse-tracked-only && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir in outside && + printf "aaaa\n" >in/tracked && + printf "outside\n" >outside/file && + git add . && + git commit -m base && + git sparse-checkout set --cone --sparse-index in && + git config core.trustctime false && + git config core.checkStat minimal && + git config core.untrackedCache true && + test-tool chmtime =-60 in/tracked && + git update-index --refresh && + mtime=$(test-tool chmtime --get in/tracked) && + printf "bbbb\n" >in/tracked && + test-tool chmtime =$mtime in/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 in/tracked && + test_grep ! FSCF .git/index && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DDC \ + GIT_TEST_FSMONITOR_QUERY_PATH=in/tracked \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 --untracked-files=no \ + >.git/actual && + test_grep "^1 \.M .* in/tracked$" .git/actual && + ! test_trace2_data status semantic_verify/prepared 1 \ + <.git/status.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_grep FSCF .git/index && + git -c core.fsmonitor=false ls-files --sparse \ + >.git/sparse.after && + test_grep "^outside/$" .git/sparse.after + ) +' + test_done 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 b5a28dca14d91cbbba343ea62726c82f3f049901 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:25:59 -0500 Subject: [PATCH 258/432] 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 b56b58dd23c4e8bb4fa00c7745d6599639163251 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 5 Aug 2026 21:28:42 -0700 Subject: [PATCH 259/432] 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 7c2f753c2de00b87201a059022d535eb8ac57e7e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 10 Aug 2026 23:43:46 -0500 Subject: [PATCH 260/432] 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 2bcb2925bb745eab0b81a8f88444933c55297b7d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 10:44:40 -0500 Subject: [PATCH 261/432] 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 28505e88d2591ea52f588c2cfd0661d3b9bb5b25 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:11:23 -0500 Subject: [PATCH 262/432] 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 ceaa3315f8d0bbbd0362c3708fcb0cdd9953846d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:13:44 -0500 Subject: [PATCH 263/432] 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 76a309a01d60dd00678ad5c1c6d7f6efb4435961 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 11:50:59 -0500 Subject: [PATCH 264/432] 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 58b4a5a5490d5791084ac78635f46dda76bb23d1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 6 Aug 2026 23:00:24 -0700 Subject: [PATCH 265/432] 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 9ad6b796898d37a79633d5f8fcfb64bc857b3bc2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:30:24 -0500 Subject: [PATCH 266/432] 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 91c5314a41e3403e7cfc11d1b56ca80e2d4092e9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:24 -0500 Subject: [PATCH 267/432] 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 ca7ab8a5bf566adb150ecb62b3000b7593441a3e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:31 -0500 Subject: [PATCH 268/432] 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 a34b412642885aac4ba518d8f173b22fe3bc03c6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:32:37 -0500 Subject: [PATCH 269/432] 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, 9895 insertions(+), 250 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 a95695d6fc2bf8..49943ca3964049 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -589,11 +589,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 20a81a249ad472..8631597ba59e1d 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); } @@ -497,6 +508,11 @@ int cmd_reset(int argc, if ((reset_type == MIXED || reset_type == HARD) && !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 df0edd1380ad56..9b1e8189e2b134 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 6f6da90abf1a67..ecf6cf9466aa55 100644 --- a/read-cache.c +++ b/read-cache.c @@ -701,8 +701,13 @@ int remove_file_from_index_with_flags(struct index_state *istate, printf(_("remove '%s'\n"), path); if (pretend) return 0; - if (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) - clean_status_invalidate_current_proof(istate); + if (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) { + int pos = index_name_pos(istate, path, strlen(path)); + + if (!clean_status_index_entry_is_semantically_safe( + istate, pos >= 0 ? istate->cache[pos] : NULL, NULL)) + clean_status_invalidate_current_proof(istate); + } return remove_file_from_index(istate, path); } @@ -793,7 +798,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; @@ -880,9 +885,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) @@ -893,7 +900,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) @@ -1487,6 +1494,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) 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 ff9d80ca85718bf5d03bcd20eea8f0c57f4ce09e Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Tue, 28 Jul 2026 19:51:08 -0700 Subject: [PATCH 270/432] 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 6b9b7719a399ff1f9e2c6b11ba4a094afae081ac Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:10 -0500 Subject: [PATCH 271/432] 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 f75350d7c21ad0d433b9fef861e77801db55ee50 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:33 -0500 Subject: [PATCH 272/432] 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 d0af38df20d2ca..5b5b7f0368b330 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 daff9b7428796132bb3b5dd633defa36e52eabe3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:43 -0500 Subject: [PATCH 273/432] 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 8631597ba59e1d..90590f83809322 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" @@ -527,6 +528,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) { @@ -574,7 +577,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 e4a2f833e342dbc18fe1ee8256721b4847117610 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 11 Aug 2026 23:59:58 -0500 Subject: [PATCH 274/432] 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/432] 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 1116611c4fe39d49119d69e1adb8f7fce926c91e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 10:14:56 -0500 Subject: [PATCH 276/432] 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 5bc12deaec444617a40e9886c6efeede5619d148 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 12 Aug 2026 00:39:37 -0500 Subject: [PATCH 277/432] 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: Thu, 13 Aug 2026 10:15:30 -0500 Subject: [PATCH 278/432] 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 2b2d61e032acdbe8108c1ed532aebc86d144b76d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 12 Aug 2026 00:39:56 -0500 Subject: [PATCH 279/432] 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: Thu, 13 Aug 2026 11:12:41 -0500 Subject: [PATCH 280/432] 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 21f3bd07522c43cb8a27931c4f3ecdbab9f7e4a9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 12 Aug 2026 00:50:57 -0500 Subject: [PATCH 281/432] 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 135cda7a53ca40d216a6696df7d69f02c219a807 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 11:12:53 -0500 Subject: [PATCH 282/432] 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 5886a18dd2ac47a7ef934704c6824ed067ce0c46 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 11:13:06 -0500 Subject: [PATCH 283/432] 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 12:54:20 -0500 Subject: [PATCH 284/432] 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 49943ca3964049..d9fe038f7a9061 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -717,8 +717,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 b1814d4d0667aac0333d96709c0c4e13887950dc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 11:13:22 -0500 Subject: [PATCH 285/432] 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 90590f83809322..115572740a91ad 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -542,8 +542,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 9fcb8e42a29517f56eb0b2246ee9658a3c765a01 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 12:19:28 -0500 Subject: [PATCH 286/432] 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 8e030338867512fa6e410fa893ac2c33031ee3a4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 12:54:27 -0500 Subject: [PATCH 287/432] 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 41bff55d492b008de28fe51ff083eed36777e184 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 13:26:53 -0500 Subject: [PATCH 288/432] 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 6b945d3d6d345aabca8b5f5c0e58e1cbd0befbc3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 16:19:31 -0500 Subject: [PATCH 289/432] 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 8962cb3f763c9e938e3537ed1f25f94c6696e1d0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 13 Aug 2026 16:19:38 -0500 Subject: [PATCH 290/432] 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 bc87951dfc0f0a452b9eab71edb35fb181dd3b56 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 11:36:46 -0500 Subject: [PATCH 291/432] 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 c7d8faf33ed9f2d77794267602f2b15aeb1281aa Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 12:22:47 -0500 Subject: [PATCH 292/432] 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/432] 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/432] 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 b441f40d474e00b96b35db4bda274dbeaaa795a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 12:57:24 -0500 Subject: [PATCH 295/432] 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 85b7b09eeff0a5854f8b8cca9e56613d572211d6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 13:31:15 -0500 Subject: [PATCH 296/432] 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 d4a7e0a11b3e7f2fb0581a1f42639f5e6604008b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 14:33:37 -0500 Subject: [PATCH 297/432] 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 9b1e8189e2b134..7c160f0d42439c 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 ecf6cf9466aa55..e6c029bc68407a 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; } @@ -3076,6 +3084,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 @@ -3327,7 +3366,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 3f62bd9289fe46fe0430282070913c43b721bb60 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 15:01:13 -0500 Subject: [PATCH 298/432] 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 8aa924347acdeaef04af8bc8af0e2605dadb8be2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 15:08:35 -0500 Subject: [PATCH 299/432] 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 ffb0d064434e4a505dc97d0cc262c982321c2d4e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 15:13:37 -0500 Subject: [PATCH 300/432] 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 a5846055726248c65c954b8dc31456468a1ddf77 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 16:15:45 -0500 Subject: [PATCH 301/432] 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 e6c029bc68407a..962b0391f51bba 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3380,6 +3380,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 897c1c3a443e4d38964faed74bec16614e8e01c0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 16:15:54 -0500 Subject: [PATCH 302/432] 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 4be7b19320e785c94626773fbd68ccbf7ef72654 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 16:34:15 -0500 Subject: [PATCH 303/432] 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 5758f4f6997fbb43232c07801e74ca0ce8e0d8f4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 20:06:12 -0500 Subject: [PATCH 304/432] 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 512ff0b00585dc4cf56d8904408de02bd4d05480 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 20:34:48 -0500 Subject: [PATCH 305/432] 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 e1b70517ffb67a1725ad4bd69d332442d4f35528 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:12:52 -0500 Subject: [PATCH 306/432] 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 962b0391f51bba..70630c199578ee 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3383,6 +3383,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 b5967d3319e80e6714859c03abb4a853be5b6320 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:22:07 -0500 Subject: [PATCH 307/432] 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 0f0d2e3bc7bad626f3221873d2f0818ac9396849 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:33:37 -0500 Subject: [PATCH 308/432] 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 e7bb64732a29a425e38e8d13fbab5e8d0879ce9c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 21:36:48 -0500 Subject: [PATCH 309/432] 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 71c6ae25917fcbf3c8231ac19a190f656cfaf091 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 23:38:02 -0500 Subject: [PATCH 310/432] 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 ba4f69875323b262257dc9a67b00fafbdebb01d9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 14 Aug 2026 23:53:36 -0500 Subject: [PATCH 311/432] 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 38974f18ccd9543ad0690daebe34551c34bfbc72 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 01:30:38 -0500 Subject: [PATCH 312/432] 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 5b4eb23a833295..a7fcf6d8080e57 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, *argv_to_free = NULL; 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 373d4aeba33de5..888ba6e219c1f5 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" @@ -212,11 +214,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[] = { @@ -247,7 +258,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); @@ -612,6 +635,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 115572740a91ad..0d8660fa3b9f46 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -542,8 +542,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 65afd100d98e61..0751ae0f5bf7e2 100644 --- a/sequencer.c +++ b/sequencer.c @@ -4743,7 +4743,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 25e07b652a124fcca92026c25097d30d6fff5a81 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 01:35:22 -0500 Subject: [PATCH 313/432] 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 de1f40399d4d87ed777646a243b2b30e8d620c5e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:08:20 -0500 Subject: [PATCH 314/432] 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 cfa46095056dc7ff23a802472bd630c7f2902d38 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:27:09 -0500 Subject: [PATCH 315/432] 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 79008a575bd9fee37dd063f836fabd4bf36e9ec8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:37:25 -0500 Subject: [PATCH 316/432] 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 c73b3bf5dc814589d5f55e0d6f687ed03caa87e4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 02:57:49 -0500 Subject: [PATCH 317/432] 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 0ff66b9431a2dab1dc41bec5a2d3b0651a81d1b5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 03:12:08 -0500 Subject: [PATCH 318/432] 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 39d6f4dbb4dcd6cb6191f364e913522ce8173cda Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 03:45:13 -0500 Subject: [PATCH 319/432] 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 8b9abcc26a076f3802c0578e4684b79c69a514e6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 19:15:59 -0500 Subject: [PATCH 320/432] 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 4ce520b50fb3b9b87e3cef1c2333276a083fa7e4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 19:16:13 -0500 Subject: [PATCH 321/432] 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 9de7f009e41af6da9aa27d9053c0e5ca37be9d03 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 21:43:12 -0500 Subject: [PATCH 322/432] 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 7533c04a524ed3d745c6edccaf64d58cbc8f7f1e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:02:25 -0500 Subject: [PATCH 323/432] 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 1a816d0ce1a726e53133e279aa669077dcd76643 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:10:43 -0500 Subject: [PATCH 324/432] 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 7f27676f0e1e1501933ac47c6268dac4aad89b29 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:18:47 -0500 Subject: [PATCH 325/432] 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 7e161e46882cad96a3566ca72151d4bf36d995ec Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:30:29 -0500 Subject: [PATCH 326/432] 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 6ac52eb3390be82d6a31f70ab02c329f74485e30 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 22:49:47 -0500 Subject: [PATCH 327/432] 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 3c4e9b3bd176dd95016e1a67dc296c54b7487ab9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:01:28 -0500 Subject: [PATCH 328/432] 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 09b85bbf5a382495eaa9ff0eb36b820cd344302d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:07:47 -0500 Subject: [PATCH 329/432] 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 d60a9cab417aed66b08ce1c597598ebc79ea03de Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:12:25 -0500 Subject: [PATCH 330/432] 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 3aea30abf679b2bd38a2c171595a9b466e2f35c6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:14:33 -0500 Subject: [PATCH 331/432] 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 51aa781379f505..fd867037fcbc4e 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 8745d20feb759a..31751e3fddd30e 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 806c60a5436271b3edb3db63f67dabcbb8d65f1d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 15 Aug 2026 23:54:44 -0500 Subject: [PATCH 332/432] 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 fdc90ab5e5b55b17f0f27d10af3c721a84658c38 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 00:03:09 -0500 Subject: [PATCH 333/432] 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 7c160f0d42439c..8d1d508370c01c 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 407556eb2d7dd00184e41ad9a7528b830dd33c57 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 00:40:08 -0500 Subject: [PATCH 334/432] 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 25835e7a5da276be2ee7e76797bea2e0412fb573 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 11:46:47 -0500 Subject: [PATCH 335/432] 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 4e0bff456b4258cd87107ab2dc50f5d18ee5b127 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 11:57:15 -0500 Subject: [PATCH 336/432] 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 8851fb2cafcfd5e4d8801243c929d67d24659771 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:10:18 -0500 Subject: [PATCH 337/432] 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 2c1ddcedc75c82173e1194e4d012c7bd233d5ee3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:17:49 -0500 Subject: [PATCH 338/432] 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 75af907ce63410395f274996e1efff9689e94bf6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:23:42 -0500 Subject: [PATCH 339/432] 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 0128580793ede6271d518961710efc261a5766fc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:40:31 -0500 Subject: [PATCH 340/432] 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 1d90abfba5914fabadcc7623f6fc0ba9c96ce3bc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:58:02 -0500 Subject: [PATCH 341/432] 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 8d1d508370c01c..d72535b56b27e8 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 70630c199578ee..9a6af9010e1672 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) @@ -1372,7 +1406,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; @@ -1729,7 +1763,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 5ce3a08ddad7e9f09f5810ef839b5ee9deaf1390 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 12:58:13 -0500 Subject: [PATCH 342/432] 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 57785f299c643de5253d4108a29ba09a8d1be7a7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:03:19 -0500 Subject: [PATCH 343/432] 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 6758ad326c49df39d9bc4ea6eadf70a61ea5dc54 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:22:16 -0500 Subject: [PATCH 344/432] 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 31751e3fddd30e..f9239bffd8c614 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', @@ -967,6 +967,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 c6e43d5d382b426abdaba21f3fe9768710fa9a47 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:42:39 -0500 Subject: [PATCH 345/432] 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 a3f0a03f4563b0bef18d73b8fe94c1b6f5be9828 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 13:59:10 -0500 Subject: [PATCH 346/432] 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 bbaff5d9b560a195bf3d712ac32ec334514f6261 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 14:05:43 -0500 Subject: [PATCH 347/432] 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 00ffe093ac4e3b85b47d05177e56106090af3c1d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 14:58:01 -0500 Subject: [PATCH 348/432] 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 2f50bbb191c94abd1624acc39145744322c232c5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 16:01:16 -0500 Subject: [PATCH 349/432] 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 b2774c4581faba5282667906f924849c4ac97904 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 17:10:24 -0500 Subject: [PATCH 350/432] 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 93fcc2ab3a95898f1a9108780f8d84a47d4ddb75 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 17:51:27 -0500 Subject: [PATCH 351/432] 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 c904258b676d80931aa5fe8fd548b5dac250db39 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 16 Aug 2026 18:47:23 -0500 Subject: [PATCH 352/432] 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 945c934bd4e34455ae50618ae544c6556f08561b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 02:19:10 -0500 Subject: [PATCH 353/432] 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 d72535b56b27e8..d603aea8a032d7 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 9a6af9010e1672..3e28ecf885b967 100644 --- a/read-cache.c +++ b/read-cache.c @@ -2487,8 +2487,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; @@ -2502,12 +2503,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); @@ -2646,6 +2650,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 bc5329666c5cdb2b86708504338a3bbddbeb8802 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 03:21:27 -0500 Subject: [PATCH 354/432] 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 0d19bae40ad47225dd69eee700acc645448bf584 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:21:01 -0500 Subject: [PATCH 355/432] 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 8b6bfe5ba7cd92dd49fe361cbc6289ad9097213f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:29:19 -0500 Subject: [PATCH 356/432] 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 1487199e231578..5dc5a7eca84496 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 6c6572fbd4147b67aca509f2800ee01e6a9bc1c6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:40:31 -0500 Subject: [PATCH 357/432] 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 c44046b5c5bc6c16b8222835ab189bd050a6ae4c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 11:43:55 -0500 Subject: [PATCH 358/432] 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 3e28ecf885b967..14368e654d64dd 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" @@ -2775,6 +2776,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 3c1668fc40627b80cc458677f65b009e73742a7a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:35:06 -0500 Subject: [PATCH 359/432] 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 b746a5ca348494fedf6e14b9c7acd0ce26349e3c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:36:03 -0500 Subject: [PATCH 360/432] 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 f9239bffd8c614..0ee91c32b56838 100644 --- a/t/meson.build +++ b/t/meson.build @@ -969,6 +969,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/432] 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 f8673a7aff65d6b07c8e4a60e8dfc3e3533b2d40 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:39:24 -0500 Subject: [PATCH 362/432] 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 0ee91c32b56838..192af966e90fad 100644 --- a/t/meson.build +++ b/t/meson.build @@ -970,6 +970,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 78231e704c62a8a4168c5245329153fa07179e28 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:41:39 -0500 Subject: [PATCH 363/432] 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 3b083bcd97de98172c710bfd09e2f046336ce36b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 12:43:57 -0500 Subject: [PATCH 364/432] 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 a89c828c8c457c58db8846e54007dfd42386c84c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:04:47 -0500 Subject: [PATCH 365/432] 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 d5cf450ea719c9ab9c0506f1502d031055a68545 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:15:47 -0500 Subject: [PATCH 366/432] 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 b44fbb96c3d45f59dc81b689b8a51e2d96f735da Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:31:26 -0500 Subject: [PATCH 367/432] 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 add6c9f2c93a81ac9aee1bb6fdc7b79dbe13fbe3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 13:58:22 -0500 Subject: [PATCH 368/432] 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 d603aea8a032d7..ac0dfbfb32042f 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 14368e654d64dd..6ceb666c6b986f 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" @@ -1849,14 +1850,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; @@ -1939,30 +1936,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 @@ -1972,42 +2015,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); @@ -2038,18 +2124,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) @@ -2323,20 +2580,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; } @@ -2358,7 +2627,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; } @@ -2378,6 +2648,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 */ @@ -2396,7 +2667,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; @@ -2433,6 +2705,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 192af966e90fad..48c38ce2df9bc7 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 da6ea85650f20e5e9ae74f991acf34c7e9816e59 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:10:04 -0500 Subject: [PATCH 369/432] 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 df1ad6c4900a805fb2c03f0d2ebc596a2990c959 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:23:44 -0500 Subject: [PATCH 370/432] 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 fed5b752e60b1c634a210b0d47362d7b3c1e850c Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:29:52 -0500 Subject: [PATCH 371/432] 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 c9d293333077434b343eff53cf144cd5a4ae9ff0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:29:55 -0500 Subject: [PATCH 372/432] 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 e4d6efe3957309cff850001a7f772f121d481d03 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 14:53:51 -0500 Subject: [PATCH 373/432] 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 6ceb666c6b986f..8e018244850091 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 8cc3a48abb8efad9cba755a448089dad5fb01c54 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 15:58:48 -0500 Subject: [PATCH 374/432] 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 8c98efb8bc2cdde6c0ad548d5d7cb53c05054e61 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 15:58:54 -0500 Subject: [PATCH 375/432] 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 80fce6ed4d5d51eed243894b1fd979742df8a7d9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:04:17 -0500 Subject: [PATCH 376/432] 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 b65fc919c06fda018c901b291e8c41db4915851d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:04:28 -0500 Subject: [PATCH 377/432] 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 ac0dfbfb32042f..ac8dff585978ae 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 8e018244850091..80f1466b32b6c0 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" @@ -3364,17 +3365,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; @@ -3816,17 +3831,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; } @@ -3840,12 +3863,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", @@ -3862,7 +3894,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; } @@ -4004,8 +4037,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; @@ -4029,7 +4063,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; } @@ -4059,7 +4094,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); @@ -4089,6 +4125,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 5ecbe071b02362c154ff1fac3843ebb699078b85 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:49:28 -0500 Subject: [PATCH 378/432] 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 9738dcaaede8b8ab14e1dbc4eb5dc4c108edebe5 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 16:56:48 -0500 Subject: [PATCH 379/432] 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 48c38ce2df9bc7..46e6b27a93ecfe 100644 --- a/t/meson.build +++ b/t/meson.build @@ -972,6 +972,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 05f4310eb5b22c9f6b17bd22c15ad00b039464b6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 17:26:48 -0500 Subject: [PATCH 380/432] 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 092e1bfc44031dbe725ae5c6f108fd3997d57b82 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Tue, 21 Jul 2026 17:04:56 -0400 Subject: [PATCH 381/432] 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 0397f90317cdb65b54f3c142ea293b1a9e3a4453 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 18:07:24 -0500 Subject: [PATCH 382/432] 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 9157b557fca03c99f16fa2b05c6d325aec39dd76 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 19:50:22 -0500 Subject: [PATCH 383/432] 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 ac8dff585978ae..6847a874c4d75f 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -555,6 +555,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 80f1466b32b6c0..fd3cd62d5bd5d6 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, @@ -1613,6 +1618,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; @@ -3434,6 +3441,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 && @@ -3444,9 +3469,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) && @@ -3458,7 +3481,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); } @@ -3823,6 +3847,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) @@ -4053,6 +4082,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 73ad2452e42b09deb1d21473ad0f50970cfc77ce Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 21:29:54 -0500 Subject: [PATCH 384/432] 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 92e616d1a2e4f52d31921ae6456e0a5c79eb17d9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 21:30:13 -0500 Subject: [PATCH 385/432] 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 b9efccb3f873806308802d501cfee90d8aac51b0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 17 Aug 2026 22:22:53 -0500 Subject: [PATCH 386/432] 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 fd3cd62d5bd5d6..d59ed502b37dae 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3439,7 +3439,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); @@ -3449,7 +3449,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)) @@ -3470,7 +3472,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) && @@ -3491,10 +3493,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; @@ -3738,7 +3742,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); @@ -3868,6 +3872,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)) @@ -3883,9 +3888,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)); @@ -4010,7 +4020,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 15a40e0fca00383cc72c0c25413ac29a9e83800d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:00:44 -0500 Subject: [PATCH 387/432] 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 a065df1ae601885b5e6df71b84392bc64d90b5c3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:11:11 -0500 Subject: [PATCH 388/432] 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 6847a874c4d75f..fc241e70899940 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 d59ed502b37dae..0525fb947f17ff 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3374,7 +3374,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, @@ -3386,7 +3387,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); } @@ -3867,7 +3868,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; @@ -3917,6 +3919,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", @@ -3934,7 +3938,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; } @@ -4078,7 +4082,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; @@ -4111,7 +4116,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; } @@ -4142,7 +4147,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); @@ -4175,7 +4180,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 80f369a6adf2e7b75dfc6c800b04739aa376ced7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:14:03 -0500 Subject: [PATCH 389/432] 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 799a26fc7b13f43cf543902d7842d3e5a546e319 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 12:26:53 -0500 Subject: [PATCH 390/432] 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 0525fb947f17ff..4eb9c7bdf71594 100644 --- a/read-cache.c +++ b/read-cache.c @@ -4210,7 +4210,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; @@ -4223,7 +4222,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 dd1294c80e554072ff77a8eb2bc07b420af90a38 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:03:09 -0500 Subject: [PATCH 391/432] 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 b079f4c18ac0cf1aed83547c1aaa6f53805273a6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:03:52 -0500 Subject: [PATCH 392/432] 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 b5325b609fedc9ef107e66dbe7f5a6fc89d47ba0 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:15:14 -0500 Subject: [PATCH 393/432] 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 fc241e70899940..da72f6e2fc4b6a 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -510,8 +510,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 4eb9c7bdf71594..6c1270306438ba 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 59045e100e0c3f66c3728e931d0009f5614f3101 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:15:27 -0500 Subject: [PATCH 394/432] 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 2d4eda61cacff9ae0ea425210fbfebac1d725aa4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 13:52:41 -0500 Subject: [PATCH 395/432] 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 418580abb45442c4f6125eb197ca8f0663226ce6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 14:16:29 -0500 Subject: [PATCH 396/432] 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 d0fde4d93ca000d08fe95b8761355aac364eea07 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 14:23:57 -0500 Subject: [PATCH 397/432] 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 83a7d25fe7f310e00c8a79c324dbc7e291c1b4c8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 14:50:18 -0500 Subject: [PATCH 398/432] 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 8cd86abdefab486c7986bb4a5b0748315e93c7aa Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 15:27:58 -0500 Subject: [PATCH 399/432] 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 0fb6e545bfd2ad0f00dfd312945ebd7567d6f02f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 17:10:06 -0500 Subject: [PATCH 400/432] 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 bb349b76958ed36054af90d65579096873fe22fc Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Tue, 18 Aug 2026 18:36:40 -0500 Subject: [PATCH 401/432] 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 eac63b0e157120a00e5dda79eed346e81ebf5b48 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 19 Aug 2026 01:04:40 -0500 Subject: [PATCH 402/432] 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 722441573c02735fb760a2ef5df361d5c311e756 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 19 Aug 2026 01:20:30 -0500 Subject: [PATCH 403/432] 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 \ From 7e7ac923ce3e69910265ac2c79a2a0497577e198 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 21 Aug 2026 11:50:25 -0500 Subject: [PATCH 404/432] fsmonitor: retry late FSEvents cookies after timeout with_lock__wait_for_cookie() gives a filesystem provider one second to report a synchronization cookie. A healthy FSEvents stream can miss that deadline while macOS is under load. The daemon then returns a trivial response, and status scans the entire index even though event delivery is still making progress. 4b1c56aeed (fsmonitor: flush pending FSEvents before cookie wait, 2026-07-21) requested an asynchronous flush on every Darwin query but kept the same one-second deadline. f439708ff1 (Revert "fsmonitor: flush pending FSEvents before cookie wait", 2026-08-17) reverted it after a matched 48-query test still saw 12 timeouts in each arm. Avoid restoring that unqualified hot-path request. When the initial Darwin wait expires, request an asynchronous FSEvents flush and wait one more bounded interval. Successful queries retain the original wait and do not issue a flush or extend their deadline. The asynchronous call cannot block on the callback while the client holds main_lock. If the provider stays silent, retain the existing trivial-response fallback after the retry. Add a test-only callback delay to exercise both outcomes: a 1.2-second delay is recovered, while a 2.5-second delay still reaches the bounded fallback. --- builtin/fsmonitor--daemon.c | 31 ++++++++++++++++++++ compat/fsmonitor/fsm-darwin-gcc.h | 1 + compat/fsmonitor/fsm-listen-darwin.c | 15 ++++++++++ compat/fsmonitor/fsm-listen.h | 5 ++++ t/t7527-builtin-fsmonitor.sh | 43 ++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 1c53a5af4dd6df..d243567de5b1a1 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -238,6 +238,37 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( &state->main_lock, &ts); if (err == ETIMEDOUT && cookie->result == FCIR_INIT) { +#ifdef __APPLE__ + struct timeval rescue_now; + + /* + * FSEvents may be healthy but late enough that its normal + * delivery misses our bounded wait. Flush only after that + * wait expires, so successful queries pay no extra cost. + * The asynchronous flush cannot block on the listener callback, + * which needs main_lock to publish the cookie. + */ + trace_printf_key(&trace_fsmonitor, + "cookie_wait: requesting FSEvents flush after initial timeout"); + fsm_listen__flush_async(state); + + /* + * Give the listener one more bounded interval to deliver and + * publish the cookie rather than falling back to a full index + * scan. A broken provider still reaches the existing error + * path instead of hanging a client indefinitely. + */ + gettimeofday(&rescue_now, NULL); + ts.tv_sec = rescue_now.tv_sec + 1; + ts.tv_nsec = rescue_now.tv_usec * 1000; + err = 0; + while (cookie->result == FCIR_INIT && !err) + err = pthread_cond_timedwait(&state->cookies_cond, + &state->main_lock, + &ts); +#endif + } + if (err == ETIMEDOUT && cookie->result == FCIR_INIT) { trace_printf_key(&trace_fsmonitor, "cookie_wait timed out"); cookie->result = FCIR_ERROR; 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..5ebc70902553c5 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -29,6 +29,7 @@ #include "fsmonitor--daemon.h" #include "fsmonitor-path-utils.h" #include "gettext.h" +#include "parse.h" #include "simple-ipc.h" #include "string-list.h" #include "trace.h" @@ -57,6 +58,8 @@ struct fsm_listen_data unsigned int stream_scheduled:1; unsigned int stream_started:1; + unsigned int test_cookie_delayed:1; + unsigned long test_cookie_delay_ms; }; static void log_flags_set(const char *path, const FSEventStreamEventFlags flag) @@ -453,6 +456,11 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, } free(resolved); + if (cookie_list.nr && data->test_cookie_delay_ms && + !data->test_cookie_delayed) { + data->test_cookie_delayed = 1; + sleep_millisec(data->test_cookie_delay_ms); + } fsmonitor_publish(state, batch, &cookie_list); string_list_clear(&cookie_list, 0); strbuf_release(&tmp); @@ -511,6 +519,8 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) CALLOC_ARRAY(data, 1); state->listen_data = data; + data->test_cookie_delay_ms = git_env_ulong( + "GIT_TEST_FSMONITOR_COOKIE_DELAY_MS", 0); data->cfsr_event_path_key = CFStringCreateWithCString( NULL, "path", kCFStringEncodingUTF8); @@ -586,6 +596,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.h b/compat/fsmonitor/fsm-listen.h index 41650bf8972217..d58e01243b9ad9 100644 --- a/compat/fsmonitor/fsm-listen.h +++ b/compat/fsmonitor/fsm-listen.h @@ -38,6 +38,11 @@ void fsm_listen__dtor(struct fsmonitor_daemon_state *state); */ void fsm_listen__loop(struct fsmonitor_daemon_state *state); +#ifdef __APPLE__ +/* Request delivery of all FSEvents that occurred before this call. */ +void fsm_listen__flush_async(struct fsmonitor_daemon_state *state); +#endif + /* * Gently request that the fsmonitor listener thread shutdown. * It does not wait for it to stop. The caller should do a JOIN diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 04c188ac50132a..afac37d7abfe31 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -196,6 +196,49 @@ test_expect_success 'implicit daemon start' ' test_must_fail git -C test_implicit fsmonitor--daemon status ' +test_expect_success MACOS 'rescue a delayed FSEvents cookie after timeout' ' + test_when_finished "stop_daemon_delete_repo test_delayed_cookie" && + + git init test_delayed_cookie && + ( + GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=1200 && + GIT_TRACE_FSMONITOR="$PWD/delayed-cookie.trace" && + export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS GIT_TRACE_FSMONITOR && + git -C test_delayed_cookie fsmonitor--daemon start \ + --start-timeout=10 + ) && + + test-tool -C test_delayed_cookie fsmonitor-client query \ + --token 0 >actual 2>error && + test_file_not_empty actual && + test_grep "cookie_wait: requesting FSEvents flush after initial timeout" \ + delayed-cookie.trace && + test_grep "cookie-seen:" delayed-cookie.trace && + test_grep ! "cookie_wait timed out$" delayed-cookie.trace && + test_must_be_empty error +' + +test_expect_success MACOS 'fall back when a delayed FSEvents cookie stays late' ' + test_when_finished "stop_daemon_delete_repo test_lost_cookie" && + + git init test_lost_cookie && + ( + GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=2500 && + GIT_TRACE_FSMONITOR="$PWD/lost-cookie.trace" && + export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS GIT_TRACE_FSMONITOR && + git -C test_lost_cookie fsmonitor--daemon start \ + --start-timeout=10 + ) && + + test-tool -C test_lost_cookie fsmonitor-client query \ + --token 0 >actual 2>error && + test_file_not_empty actual && + test_grep "cookie_wait: requesting FSEvents flush after initial timeout" \ + lost-cookie.trace && + test_grep "cookie_wait timed out$" lost-cookie.trace && + test_must_be_empty error +' + # Verify that the daemon has shutdown. Spin a few seconds to # make the test a little more robust during CI testing. # From 30bc5b745d37956889958f64f6a04b07d918992f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 21 Aug 2026 14:48:44 -0500 Subject: [PATCH 405/432] fsmonitor: retain paths when compacting old batches The daemon currently assumes that each client which advances an FSMonitor token also updates the repository's canonical index. That does not hold for commands using GIT_INDEX_FILE. A private index can advance the daemon past the canonical index's token and cause the canonical index's next query to receive a global invalidation. Keep a deduplicated overflow batch instead of discarding old paths. Clients at the overflow sequence still get an exact delta. Older clients get a conservative union of paths, which may overreport but cannot miss a change. All paths are interned. Keep a pointer-identity hash set with the overflow batch so later compactions hash only newly retired paths, rather than rebuilding a set over the daemon's lifetime history. Add a regression which advances a private index repeatedly, verifies that compaction remains deduplicated, and then checks that a read-only canonical status reports both changed files without a trivial response. --- builtin/fsmonitor--daemon.c | 143 +++++++++++++++++++++++++++++++---- t/t7527-builtin-fsmonitor.sh | 51 +++++++++++++ 2 files changed, 178 insertions(+), 16 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index d243567de5b1a1..1ac2fcc5ebb1af 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -8,12 +8,14 @@ #include "environment.h" #include "gettext.h" #include "parse-options.h" + #include "fsmonitor-ll.h" #include "fsmonitor-ipc.h" #include "fsmonitor-settings.h" #include "compat/fsmonitor/fsm-health.h" #include "compat/fsmonitor/fsm-listen.h" #include "fsmonitor--daemon.h" +#include "khash.h" #include "simple-ipc.h" #include "strmap.h" @@ -30,6 +32,19 @@ static const char * const builtin_fsmonitor__daemon_usage[] = { }; #ifdef HAVE_FSMONITOR_DAEMON_BACKEND +static khint_t fsmonitor_path_hash(const char *path) +{ + return memhash(&path, sizeof(path)); +} + +static int fsmonitor_path_equal(const char *a, const char *b) +{ + return a == b; +} + +KHASH_INIT(fsmonitor_path_set, const char *, int, 0, + fsmonitor_path_hash, fsmonitor_path_equal) + /* * Global state loaded from config. */ @@ -421,6 +436,7 @@ struct fsmonitor_batch { const char **interned_paths; size_t nr, alloc; time_t pinned_time; + kh_fsmonitor_path_set_t *overflow_paths; }; static struct fsmonitor_token_data *fsmonitor_new_token_data(void) @@ -520,6 +536,7 @@ void fsmonitor_batch__free_list(struct fsmonitor_batch *batch) * are interned, so we don't own them. We only own * the array. */ + kh_destroy_fsmonitor_path_set(batch->overflow_paths); free(batch->interned_paths); free(batch); @@ -552,16 +569,93 @@ static void fsmonitor_batch__combine(struct fsmonitor_batch *batch_dest, batch_src->interned_paths[k]; } +static void fsmonitor_batch__add_overflow_path(struct fsmonitor_batch *batch, + const char *path) +{ + int added; + + kh_put_fsmonitor_path_set(batch->overflow_paths, path, &added); + if (!added) + return; + + ALLOC_GROW(batch->interned_paths, batch->nr + 1, batch->alloc); + batch->interned_paths[batch->nr++] = path; +} + +/* + * Collapse this batch and everything older than it into one deduplicated + * overflow batch. Every path is interned, so pointer identity is sufficient. + * + * Keep the set with the overflow batch. Future compactions then hash only + * newly retired paths instead of repeatedly rebuilding the complete set. + */ +static size_t fsmonitor_batch__compact_tail(struct fsmonitor_batch *batch, + size_t *input_nr) +{ + struct fsmonitor_batch compacted = { 0 }; + struct fsmonitor_batch *item, *overflow = NULL; + + *input_nr = 0; + for (item = batch; item; item = item->next) { + *input_nr = st_add(*input_nr, item->nr); + if (item->overflow_paths) { + overflow = item; + break; + } + } + + if (overflow) { + /* + * Reuse the persistent set and array from the prior overflow + * batch. Only the newly retired paths need a lookup. + */ + if (overflow->next) + BUG("overflow batch is not the batch tail"); + for (item = batch; item != overflow; item = item->next) { + size_t k; + + for (k = 0; k < item->nr; k++) + fsmonitor_batch__add_overflow_path( + overflow, item->interned_paths[k]); + } + compacted.interned_paths = overflow->interned_paths; + compacted.nr = overflow->nr; + compacted.alloc = overflow->alloc; + compacted.overflow_paths = overflow->overflow_paths; + overflow->interned_paths = NULL; + overflow->nr = overflow->alloc = 0; + overflow->overflow_paths = NULL; + } else { + compacted.overflow_paths = kh_init_fsmonitor_path_set(); + for (item = batch; item; item = item->next) { + size_t k; + + for (k = 0; k < item->nr; k++) + fsmonitor_batch__add_overflow_path( + &compacted, item->interned_paths[k]); + } + } + + free(batch->interned_paths); + batch->interned_paths = compacted.interned_paths; + batch->nr = compacted.nr; + batch->alloc = compacted.alloc; + batch->overflow_paths = compacted.overflow_paths; + + return batch->nr; +} + /* * To keep the batch list from growing unbounded in response to filesystem - * activity, we try to truncate old batches from the end of the list as - * they become irrelevant. + * activity, collapse old batches from the end of the list after a delay. * - * We assume that the .git/index will be updated with the most recent token - * any time the index is updated. And future commands will only ask for - * recent changes *since* that new token. So as tokens advance into the - * future, older batch items will never be requested/needed. So we can - * truncate them without loss of functionality. + * A repository may have multiple durable indexes with different tokens. In + * particular, advancing a private GIT_INDEX_FILE does not advance .git/index. + * We therefore cannot discard old paths just because one client asked for a + * newer token. Instead, keep their deduplicated union in an overflow batch. + * Requests older than the overflow sequence may receive extra paths, but not + * miss any. A request at that sequence excludes the overflow batch and + * remains exact. * * However, multiple commands may be talking to the daemon concurrently * or perform a slow command, so a little "token skew" is possible. @@ -580,6 +674,7 @@ static void fsmonitor_batch__combine(struct fsmonitor_batch *batch_dest, * the official list so that the caller can free it after leaving the lock. */ #define MY_TIME_DELAY_SECONDS (5 * 60) /* seconds */ +static unsigned long truncate_delay_seconds = MY_TIME_DELAY_SECONDS; static struct fsmonitor_batch *with_lock__truncate_old_batches( struct fsmonitor_daemon_state *state, @@ -589,6 +684,7 @@ static struct fsmonitor_batch *with_lock__truncate_old_batches( const struct fsmonitor_batch *batch; struct fsmonitor_batch *remainder; + size_t input_nr, unique_nr; if (!batch_marker) return NULL; @@ -597,13 +693,13 @@ static struct fsmonitor_batch *with_lock__truncate_old_batches( batch_marker->batch_seq_nr, (uint64_t)batch_marker->pinned_time); - for (batch = batch_marker; batch; batch = batch->next) { + for (batch = batch_marker->next; batch; batch = batch->next) { time_t t; - if (!batch->pinned_time) /* an overflow batch */ + if (batch->overflow_paths) continue; - t = batch->pinned_time + MY_TIME_DELAY_SECONDS; + t = batch->pinned_time + truncate_delay_seconds; if (t > batch_marker->pinned_time) /* too close to marker */ continue; @@ -613,9 +709,20 @@ static struct fsmonitor_batch *with_lock__truncate_old_batches( return NULL; truncate_past_here: + remainder = ((struct fsmonitor_batch *)batch)->next; + if (!remainder) + return NULL; + + unique_nr = fsmonitor_batch__compact_tail( + (struct fsmonitor_batch *)batch, &input_nr); + trace_printf_key(&trace_fsmonitor, + "Compact: batch %"PRIu64" covers %"PRIuMAX + " of %"PRIuMAX" paths", + batch->batch_seq_nr, + (uintmax_t)unique_nr, (uintmax_t)input_nr); + state->current_token_data->batch_tail = (struct fsmonitor_batch *)batch; - remainder = ((struct fsmonitor_batch *)batch)->next; ((struct fsmonitor_batch *)batch)->next = NULL; return remainder; @@ -940,13 +1047,14 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, do_trivial = 1; } else if (requested_oldest_seq_nr < - token_data->batch_tail->batch_seq_nr) { + token_data->batch_tail->batch_seq_nr && + !token_data->batch_tail->overflow_paths) { /* * The client wants older events than we have for - * this token_id. This means that the end of our - * batch list was truncated and we cannot give the - * client a complete snapshot relative to their - * request. + * this token_id. A normal tail means that the end + * of our batch list was truncated and we cannot + * give the client a complete snapshot. An overflow + * tail conservatively contains all older paths. */ trace_printf_key(&trace_fsmonitor, "client requested truncated data"); @@ -1410,6 +1518,9 @@ static int fsmonitor_run_daemon(void) int err; memset(&state, 0, sizeof(state)); + truncate_delay_seconds = git_env_ulong( + "GIT_TEST_FSMONITOR_TRUNCATE_DELAY_SECONDS", + MY_TIME_DELAY_SECONDS); hashmap_init(&state.cookies, cookies_cmp, NULL, 0); pthread_mutex_init(&state.main_lock, NULL); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index afac37d7abfe31..6d0fc8a26cacd0 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -239,6 +239,57 @@ test_expect_success MACOS 'fall back when a delayed FSEvents cookie stays late' test_must_be_empty error ' +test_expect_success MACOS 'private index cannot prune canonical index history' ' + test_when_finished "stop_daemon_delete_repo test_index_history" && + test_when_finished "rm -f private-index" && + + git init test_index_history && + ( + cd test_index_history && + test_commit base tracked && + test_commit other other && + git config core.untrackedCache true && + git config core.fsmonitor true && + ( + GIT_TEST_FSMONITOR_TRUNCATE_DELAY_SECONDS=0 && + export GIT_TEST_FSMONITOR_TRUNCATE_DELAY_SECONDS && + start_daemon --tf "$PWD/../index-history.trace" + ) && + + GIT_INDEX_FILE="$PWD/.git/index" \ + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + cp .git/index ../private-index && + cp .git/index .git/index.before && + + echo first >>tracked && + GIT_INDEX_FILE="$PWD/../private-index" git add -u && + echo second >>other && + GIT_INDEX_FILE="$PWD/../private-index" git add -u && + echo third >>tracked && + GIT_INDEX_FILE="$PWD/../private-index" git add -u && + echo fourth >>other && + GIT_INDEX_FILE="$PWD/../private-index" git add -u && + echo fifth >>tracked && + GIT_INDEX_FILE="$PWD/../private-index" git add -u && + test_cmp .git/index.before .git/index && + test_grep "Compact: batch" ../index-history.trace \ + >../index-history.compactions && + test_line_count = 3 ../index-history.compactions && + test_grep "covers 2 of 3 paths" ../index-history.compactions && + + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/canonical.trace" \ + git status --porcelain=v2 >.git/canonical && + test_line_count = 2 .git/canonical && + test_grep "^1 \.M .* tracked$" .git/canonical && + test_grep "^1 \.M .* other$" .git/canonical && + test_cmp .git/index.before .git/index && + ! test_trace2_data fsm_client query/trivial-response 1 \ + <.git/canonical.trace + ) +' + # Verify that the daemon has shutdown. Spin a few seconds to # make the test a little more robust during CI testing. # From 0b6a8510aa0671b093c4c771114a3c9e3aa3457a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 11:50:10 -0500 Subject: [PATCH 406/432] fsmonitor: preserve event chronology across compaction Retired batches are collapsed into a path-only overflow set. That keeps old indexes complete, but it loses the sequence in which each path was last observed. A client that consumed an inode event can therefore see it again after another index compacts the batch list, causing repeated hard-link scans. Unpinned batches have a zero pinned time and are also eligible for compaction immediately despite the default grace period. Do not use unpinned batches as truncation boundaries. Record the newest original batch sequence for every overflow path, and filter overflow responses against the client's requested sequence. The normal batch walk remains unchanged; sequence lookups are confined to overflow responses. Cover both the default retention grace and the cross-index hard-link case. The latter persists a nonzero checkpoint, compacts through a private index, and verifies repeated canonical reads do not rescan or fall back to global invalidation. --- builtin/fsmonitor--daemon.c | 124 ++++++++++++++++++++++++++----- t/helper/test-fsmonitor-client.c | 27 +++++++ t/t7527-builtin-fsmonitor.sh | 120 ++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 20 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 1ac2fcc5ebb1af..9ba1d77f30e657 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -42,7 +42,7 @@ static int fsmonitor_path_equal(const char *a, const char *b) return a == b; } -KHASH_INIT(fsmonitor_path_set, const char *, int, 0, +KHASH_INIT(fsmonitor_path_sequence, const char *, uint64_t, 1, fsmonitor_path_hash, fsmonitor_path_equal) /* @@ -436,7 +436,7 @@ struct fsmonitor_batch { const char **interned_paths; size_t nr, alloc; time_t pinned_time; - kh_fsmonitor_path_set_t *overflow_paths; + kh_fsmonitor_path_sequence_t *overflow_path_seqs; }; static struct fsmonitor_token_data *fsmonitor_new_token_data(void) @@ -536,7 +536,7 @@ void fsmonitor_batch__free_list(struct fsmonitor_batch *batch) * are interned, so we don't own them. We only own * the array. */ - kh_destroy_fsmonitor_path_set(batch->overflow_paths); + kh_destroy_fsmonitor_path_sequence(batch->overflow_path_seqs); free(batch->interned_paths); free(batch); @@ -570,13 +570,20 @@ static void fsmonitor_batch__combine(struct fsmonitor_batch *batch_dest, } static void fsmonitor_batch__add_overflow_path(struct fsmonitor_batch *batch, - const char *path) + const char *path, + uint64_t batch_seq_nr) { + khint_t pos; int added; - kh_put_fsmonitor_path_set(batch->overflow_paths, path, &added); - if (!added) + pos = kh_put_fsmonitor_path_sequence( + batch->overflow_path_seqs, path, &added); + if (!added) { + if (kh_value(batch->overflow_path_seqs, pos) < batch_seq_nr) + kh_value(batch->overflow_path_seqs, pos) = batch_seq_nr; return; + } + kh_value(batch->overflow_path_seqs, pos) = batch_seq_nr; ALLOC_GROW(batch->interned_paths, batch->nr + 1, batch->alloc); batch->interned_paths[batch->nr++] = path; @@ -598,7 +605,7 @@ static size_t fsmonitor_batch__compact_tail(struct fsmonitor_batch *batch, *input_nr = 0; for (item = batch; item; item = item->next) { *input_nr = st_add(*input_nr, item->nr); - if (item->overflow_paths) { + if (item->overflow_path_seqs) { overflow = item; break; } @@ -616,23 +623,26 @@ static size_t fsmonitor_batch__compact_tail(struct fsmonitor_batch *batch, for (k = 0; k < item->nr; k++) fsmonitor_batch__add_overflow_path( - overflow, item->interned_paths[k]); + overflow, item->interned_paths[k], + item->batch_seq_nr); } compacted.interned_paths = overflow->interned_paths; compacted.nr = overflow->nr; compacted.alloc = overflow->alloc; - compacted.overflow_paths = overflow->overflow_paths; + compacted.overflow_path_seqs = overflow->overflow_path_seqs; overflow->interned_paths = NULL; overflow->nr = overflow->alloc = 0; - overflow->overflow_paths = NULL; + overflow->overflow_path_seqs = NULL; } else { - compacted.overflow_paths = kh_init_fsmonitor_path_set(); + compacted.overflow_path_seqs = + kh_init_fsmonitor_path_sequence(); for (item = batch; item; item = item->next) { size_t k; for (k = 0; k < item->nr; k++) fsmonitor_batch__add_overflow_path( - &compacted, item->interned_paths[k]); + &compacted, item->interned_paths[k], + item->batch_seq_nr); } } @@ -640,7 +650,7 @@ static size_t fsmonitor_batch__compact_tail(struct fsmonitor_batch *batch, batch->interned_paths = compacted.interned_paths; batch->nr = compacted.nr; batch->alloc = compacted.alloc; - batch->overflow_paths = compacted.overflow_paths; + batch->overflow_path_seqs = compacted.overflow_path_seqs; return batch->nr; } @@ -653,9 +663,8 @@ static size_t fsmonitor_batch__compact_tail(struct fsmonitor_batch *batch, * particular, advancing a private GIT_INDEX_FILE does not advance .git/index. * We therefore cannot discard old paths just because one client asked for a * newer token. Instead, keep their deduplicated union in an overflow batch. - * Requests older than the overflow sequence may receive extra paths, but not - * miss any. A request at that sequence excludes the overflow batch and - * remains exact. + * Keep the newest original sequence number for each path so that clients do + * not receive events that they consumed before their requested checkpoint. * * However, multiple commands may be talking to the daemon concurrently * or perform a slow command, so a little "token skew" is possible. @@ -696,7 +705,7 @@ static struct fsmonitor_batch *with_lock__truncate_old_batches( for (batch = batch_marker->next; batch; batch = batch->next) { time_t t; - if (batch->overflow_paths) + if (!batch->pinned_time || batch->overflow_path_seqs) continue; t = batch->pinned_time + truncate_delay_seconds; @@ -830,6 +839,18 @@ static int fsmonitor_parse_client_token(const char *buf_token, return 0; } +static void fsmonitor_reply_overflow_paths( + const struct fsmonitor_batch *batch, + uint64_t requested_oldest_seq_nr, + int hardlink_aware_query, + ipc_server_reply_cb *reply, + struct ipc_server_reply_data *reply_data, + struct strset *shown, + struct strbuf *payload, + uint64_t *total_response_len, + intmax_t *count, + intmax_t *duplicates); + static int do_handle_client(struct fsmonitor_daemon_state *state, const char *command, ipc_server_reply_cb *reply, @@ -1048,13 +1069,14 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, } else if (requested_oldest_seq_nr < token_data->batch_tail->batch_seq_nr && - !token_data->batch_tail->overflow_paths) { + !token_data->batch_tail->overflow_path_seqs) { /* * The client wants older events than we have for * this token_id. A normal tail means that the end * of our batch list was truncated and we cannot * give the client a complete snapshot. An overflow - * tail conservatively contains all older paths. + * tail retains the latest original sequence for each + * older path. */ trace_printf_key(&trace_fsmonitor, "client requested truncated data"); @@ -1103,7 +1125,8 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, */ strset_init_with_options(&shown, NULL, 0); for (batch = batch_head; - batch && batch->batch_seq_nr > requested_oldest_seq_nr; + batch && batch->batch_seq_nr > requested_oldest_seq_nr && + !batch->overflow_path_seqs; batch = batch->next) { size_t k; @@ -1138,6 +1161,13 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, } } } + if (batch && batch->batch_seq_nr > requested_oldest_seq_nr) { + fsmonitor_reply_overflow_paths( + batch, requested_oldest_seq_nr, hardlink_aware_query, + reply, reply_data, &shown, &payload, + &total_response_len, &count, &duplicates); + batch = batch->next; + } if (payload.len) { reply(reply_data, payload.buf, payload.len); @@ -1195,6 +1225,60 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, return 0; } +static void fsmonitor_reply_overflow_paths( + const struct fsmonitor_batch *batch, + uint64_t requested_oldest_seq_nr, + int hardlink_aware_query, + ipc_server_reply_cb *reply, + struct ipc_server_reply_data *reply_data, + struct strset *shown, + struct strbuf *payload, + uint64_t *total_response_len, + intmax_t *count, + intmax_t *duplicates) +{ + size_t k; + + if (!batch->overflow_path_seqs) + BUG("expected an overflow batch"); + + for (k = 0; k < batch->nr; k++) { + const char *s = batch->interned_paths[k]; + khint_t pos = kh_get_fsmonitor_path_sequence( + batch->overflow_path_seqs, s); + size_t s_len; + + if (pos == kh_end(batch->overflow_path_seqs)) + BUG("overflow path is missing its sequence"); + if (kh_value(batch->overflow_path_seqs, pos) <= + requested_oldest_seq_nr) + continue; + + if (!hardlink_aware_query && + starts_with(s, FSMONITOR_PATH_HARDLINK_INODE_PREFIX)) + s = FSMONITOR_PATH_GLOBAL_INVALIDATE; + + if (!strset_add(shown, s)) + (*duplicates)++; + else { + trace_printf_key(&trace_fsmonitor, + "send[%"PRIuMAX"]: %s", *count, s); + + /* Each path gets written with a trailing NUL */ + s_len = strlen(s) + 1; + + if (payload->len + s_len >= LARGE_PACKET_DATA_MAX) { + reply(reply_data, payload->buf, payload->len); + *total_response_len += payload->len; + strbuf_reset(payload); + } + + strbuf_add(payload, s, s_len); + (*count)++; + } + } +} + static ipc_server_application_cb handle_client; static int handle_client(void *data, diff --git a/t/helper/test-fsmonitor-client.c b/t/helper/test-fsmonitor-client.c index 653d09455382bb..a3f9eb0bbe7bf2 100644 --- a/t/helper/test-fsmonitor-client.c +++ b/t/helper/test-fsmonitor-client.c @@ -64,6 +64,29 @@ static int do_send_query(const char *token) return 0; } +/* + * Send a protocol-v2 token without the capability and worktree-binding + * prefix used by current clients. This models an older client that does + * not understand hard-link inode events. + */ +static int do_send_legacy_query(const char *token) +{ + struct strbuf answer = STRBUF_INIT; + int ret; + + if (!token || !*token) + token = get_token_from_index(); + + ret = fsmonitor_ipc__send_command(token, &answer); + if (ret < 0) + die("could not query fsmonitor--daemon"); + + write_in_full(1, answer.buf, answer.len); + strbuf_release(&answer); + + return 0; +} + /* * Send a "flush" command to the `git-fsmonitor--daemon` (if running) * and tell it to flush its cache. @@ -221,6 +244,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 query-legacy []", "test-tool fsmonitor-client flush", "test-tool fsmonitor-client record-watch-limit", "test-tool fsmonitor-client hammer [] [] []", @@ -249,6 +273,9 @@ int cmd__fsmonitor_client(int argc, const char **argv) if (!strcmp(subcmd, "query")) return !!do_send_query(token); + if (!strcmp(subcmd, "query-legacy")) + return !!do_send_legacy_query(token); + if (!strcmp(subcmd, "flush")) return !!do_send_flush(); diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 6d0fc8a26cacd0..e95e7931ed0642 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -239,6 +239,41 @@ test_expect_success MACOS 'fall back when a delayed FSEvents cookie stays late' test_must_be_empty error ' +test_expect_success MACOS 'fresh unpinned batches honor the retention grace' ' + test_when_finished "stop_daemon_delete_repo test_fresh_history" && + + git init test_fresh_history && + ( + cd test_fresh_history && + printf "target/\\n" >.gitignore && + printf "a\\n" >tracked-a && + printf "b\\n" >tracked-b && + git add .gitignore tracked-a tracked-b && + git commit -m base && + sane_unset GIT_TEST_FSMONITOR_TRUNCATE_DELAY_SECONDS && + start_daemon --tf "$PWD/../fresh-history.trace" && + git config core.fsmonitor true && + git config core.untrackedCache true && + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + mkdir target && + for i in $(test_seq 1 5250) + do + printf "x\\n" >"target/ignored-$i" || return 1 + done && + test-tool fsmonitor-client query >../fresh-history.response && + perl -0ne '\''$nr++; END { print "$nr\n" }'\'' \ + <../fresh-history.response >../fresh-history.count && + test "$(cat ../fresh-history.count)" -gt 1025 && + git status --porcelain=v2 >.git/after-burst && + printf "new\\n" >>tracked-a && + git status --porcelain=v2 >.git/after-event && + test_grep ! "Compact: batch" ../fresh-history.trace + ) +' + test_expect_success MACOS 'private index cannot prune canonical index history' ' test_when_finished "stop_daemon_delete_repo test_index_history" && test_when_finished "rm -f private-index" && @@ -290,6 +325,91 @@ test_expect_success MACOS 'private index cannot prune canonical index history' ' ) ' +test_expect_success MACOS,HARDLINKS \ + 'compaction does not replay consumed hardlink events' ' + test_when_finished "stop_daemon_delete_repo test_hardlink_history" && + test_when_finished "rm -f hardlink-private-index" && + + git init test_hardlink_history && + ( + cd test_hardlink_history && + printf "target/\\n" >.gitignore && + printf "a\\n" >tracked-a && + printf "b\\n" >tracked-b && + git add .gitignore tracked-a tracked-b && + git commit -m base && + git config core.untrackedCache true && + git config core.trustctime false && + git config core.checkStat minimal && + ( + GIT_TEST_FSMONITOR_TRUNCATE_DELAY_SECONDS=0 && + export GIT_TEST_FSMONITOR_TRUNCATE_DELAY_SECONDS && + start_daemon --tf "$PWD/../hardlink-history.trace" + ) && + git config core.fsmonitor true && + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + mkdir target && + printf "AAAA\\n" >target/object && + ln target/object target/object-link && + printf "BBBB\\n" >target/object-link && + GIT_TRACE2_EVENT="$PWD/.git/consume.trace" \ + git status --porcelain=v2 >.git/consume && + test_must_be_empty .git/consume && + test_trace2_data fsmonitor apply/hardlink-index-scan 1 \ + <.git/consume.trace && + git update-index --refresh --force-write-index && + test-tool dump-fsmonitor >.git/checkpoint && + checkpoint=$(sed -n "s/^fsmonitor last update //p" \ + .git/checkpoint) && + test -n "$checkpoint" && + test "${checkpoint##*:}" -gt 0 && + cp .git/index .git/index.before && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/control.trace" \ + git status --porcelain=v2 >.git/control && + test_must_be_empty .git/control && + ! test_trace2_data fsmonitor apply/hardlink-index-scan 1 \ + <.git/control.trace && + cp .git/index ../hardlink-private-index && + grep -c "event: //inode:" ../hardlink-history.trace \ + >.git/inodes.before && + for i in $(test_seq 1 8) + do + if test $((i % 2)) -eq 0 + then + printf "private-%s\\n" "$i" >>tracked-a + else + printf "private-%s\\n" "$i" >>tracked-b + fi && + GIT_INDEX_FILE="$PWD/../hardlink-private-index" \ + git add -u || return 1 + done && + test_cmp .git/index.before .git/index && + grep -c "event: //inode:" ../hardlink-history.trace \ + >.git/inodes.after && + test_cmp .git/inodes.before .git/inodes.after && + test_grep "Compact: batch" ../hardlink-history.trace && + for i in $(test_seq 1 5) + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/repeat-$i.trace" \ + git status --porcelain=v2 \ + >.git/repeat-$i || return 1 + ! test_trace2_data fsmonitor apply/hardlink-index-scan 1 \ + <.git/repeat-$i.trace || return 1 + done && + test_cmp .git/index.before .git/index && + test-tool fsmonitor-client query-legacy \ + --token "$checkpoint" >.git/legacy && + nul_to_q <.git/legacy >.git/legacy-q && + test_grep ! "Q/Q" .git/legacy-q && + test_grep ! "Q//Q" .git/legacy-q + ) +' + # Verify that the daemon has shutdown. Spin a few seconds to # make the test a little more robust during CI testing. # From 6c5b551854edf68fea7493abe4f571ff37a3211f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 11:50:41 -0500 Subject: [PATCH 407/432] t7527: query delayed cookies with valid v2 tokens The delayed-cookie tests send the v1 timestamp token "0" and only check that the response is nonempty. Both recovery and fallback can satisfy that assertion with the same trivial response, so the tests do not distinguish a rescued cookie from a token-generation reset. Send a deterministic valid v2 token instead. Verify that the 1200ms case preserves its token generation without a global invalidation, while the 2500ms case changes generation and sends the fallback invalidation. --- t/t7527-builtin-fsmonitor.sh | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index e95e7931ed0642..ea60a193d6a68a 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -202,15 +202,20 @@ test_expect_success MACOS 'rescue a delayed FSEvents cookie after timeout' ' git init test_delayed_cookie && ( GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=1200 && - GIT_TRACE_FSMONITOR="$PWD/delayed-cookie.trace" && - export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS GIT_TRACE_FSMONITOR && - git -C test_delayed_cookie fsmonitor--daemon start \ - --start-timeout=10 + export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS && + start_daemon -C test_delayed_cookie \ + --tf "$PWD/delayed-cookie.trace" --tk true ) && + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && test-tool -C test_delayed_cookie fsmonitor-client query \ - --token 0 >actual 2>error && - test_file_not_empty actual && + --token "$token" >actual 2>error && + nul_to_q actual-q && + response=$(sed -n "s/Q.*//p" actual-q) && + test "${response%:*}" = "${token%:*}" && + test_grep "^builtin:.*Q$" actual-q && + test_grep ! "Q/Q" actual-q && + test_grep ! "Q//Q" actual-q && test_grep "cookie_wait: requesting FSEvents flush after initial timeout" \ delayed-cookie.trace && test_grep "cookie-seen:" delayed-cookie.trace && @@ -224,15 +229,18 @@ test_expect_success MACOS 'fall back when a delayed FSEvents cookie stays late' git init test_lost_cookie && ( GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=2500 && - GIT_TRACE_FSMONITOR="$PWD/lost-cookie.trace" && - export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS GIT_TRACE_FSMONITOR && - git -C test_lost_cookie fsmonitor--daemon start \ - --start-timeout=10 + export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS && + start_daemon -C test_lost_cookie \ + --tf "$PWD/lost-cookie.trace" --tk true ) && + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && test-tool -C test_lost_cookie fsmonitor-client query \ - --token 0 >actual 2>error && - test_file_not_empty actual && + --token "$token" >actual 2>error && + nul_to_q actual-q && + response=$(sed -n "s/Q.*//p" actual-q) && + test "${response%:*}" != "${token%:*}" && + test_grep "Q/Q$" actual-q && test_grep "cookie_wait: requesting FSEvents flush after initial timeout" \ lost-cookie.trace && test_grep "cookie_wait timed out$" lost-cookie.trace && From 2dc919657f4b33b137430e08b49e94fe95c730d8 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 16:26:00 -0500 Subject: [PATCH 408/432] merge: preserve clean status proofs for non-ff merges 215845a7ad (fsmonitor: preserve authenticated proofs across ordinary commands, 2026-08-15) enabled the clean-status history handoff for merges, but excluded invocations where fast_forward was FF_NO. Requested merge topology does not determine whether the resulting index is semantically safe. A clean non-fast-forward merge can carry the same authenticated FSUC/FSCF state as a fast-forward merge. As a result, --no-ff, --no-ff --no-commit, and merge.ff=false all dropped FSUC and reduced the FSCF flags from 15 to 9 after a clean merge. Each subsequent read-only status invalidated the external history and rescanned the semantic manifest. Enable the handoff for every merge using the canonical index. Conflict handling still invalidates unsafe proofs, and explicit alternate indexes remain excluded. Cover all three non-fast-forward forms, repeated read-only status calls, conflicts, and alternate indexes. --- builtin/merge.c | 2 +- t/t7519-status-fsmonitor.sh | 131 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/builtin/merge.c b/builtin/merge.c index a7fcf6d8080e57..82a276074329d6 100644 --- a/builtin/merge.c +++ b/builtin/merge.c @@ -1472,7 +1472,7 @@ int cmd_merge(int argc, goto done; } - if (fast_forward != FF_NO && !getenv(INDEX_ENVIRONMENT) && + if (!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); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 9454c11695077f..f511993452c078 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2518,6 +2518,137 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'clean non-fast-forward merges preserve authenticated worktree proofs' ' + test_when_finished "rm -rf clean-no-ff-proof-*" && + for mode in cli no-commit config + do + repo=clean-no-ff-proof-$mode && + test_create_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base base && + primary=$(git symbolic-ref --short HEAD) && + git switch -c side && + test_commit topic topic && + git switch "$primary" && + test_commit primary primary && + 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 && + case "$mode" in + cli) + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git merge --no-ff --no-edit side + ;; + no-commit) + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git merge --no-ff --no-commit side + ;; + config) + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git -c merge.ff=false merge --no-edit side + ;; + esac && + test_fsmonitor_full_proof .git/index paired && + cp .git/index .git/readonly.index && + for run in 1 2 3 + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$PWD/.git/status-$run.trace" \ + git status --porcelain=v2 \ + >.git/status-$run && + test_cmp_bin .git/readonly.index .git/index && + ! test_trace2_data fsmonitor \ + history/external-proof-invalidated 1 \ + <.git/status-$run.trace && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-count \ + <.git/status-$run.trace && + if test "$mode" = no-commit + then + test_grep "^1 A\\. .* topic$" \ + .git/status-$run + else + test_must_be_empty .git/status-$run + fi || return 1 + done + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'non-fast-forward conflicts and alternate indexes fail closed' ' + test_when_finished "rm -rf no-ff-alt-proof no-ff-conflict-proof" && + test_create_repo no-ff-alt-proof && + ( + cd no-ff-alt-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base base && + primary=$(git symbolic-ref --short HEAD) && + git switch -c side && + test_commit topic topic && + git switch "$primary" && + test_commit primary primary && + 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 && + cp .git/index .git/alternate.index && + GIT_INDEX_FILE="$PWD/.git/alternate.index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git merge --no-ff --no-commit side && + ! test_fsmonitor_full_proof .git/alternate.index paired \ + 2>.git/alternate.proof && + test_grep ! FSUC .git/alternate.index + ) && + test_create_repo no-ff-conflict-proof && + ( + cd no-ff-conflict-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines base >tracked && + git add tracked && + git commit -m base && + primary=$(git symbolic-ref --short HEAD) && + git switch -c side && + test_write_lines side >tracked && + git commit -am side && + git switch "$primary" && + test_write_lines primary >tracked && + git commit -am primary && + 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_must_fail env \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ + git merge --no-ff side && + ! test_fsmonitor_full_proof .git/index paired \ + 2>.git/conflict.proof && + test_grep ! FSUC .git/index && + git ls-files -u >.git/unmerged && + test_file_not_empty .git/unmerged + ) +' + 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" && From 8dfbe016a86c556499c95a555273a29fabdb62ca Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 21:12:52 -0500 Subject: [PATCH 409/432] status: issue clean proof after repairing the index An exact clean status can repair a stale FSMonitor checkpoint or cached stat data while it scans. The repair requires an index write, so the existing issue path leaves no clean sidecar behind. Read-only callers then repeat the full scan until a second writable exact status publishes the proof. After the repair is written and resumable history is durable, install a sidecar bound to the rewritten index. Keep optional-lock-disabled commands read-only, preserve the literal exact-command restriction, and do not extend sidecar support to linked worktrees. Cover repeated read-only scans after a legacy daemon replacement, the single writable index repair in main and linked worktrees, and the next read-only sidecar hit in the main worktree. Keep option-bearing status commands ineligible for proof publication. --- .../technical/status-clean-proof.adoc | 9 +- builtin/commit.c | 27 +++- t/t7527-builtin-fsmonitor.sh | 120 ++++++++++++++++++ t/t7530-status-clean-sidecar.sh | 27 +++- 4 files changed, 170 insertions(+), 13 deletions(-) diff --git a/Documentation/technical/status-clean-proof.adoc b/Documentation/technical/status-clean-proof.adoc index fb5f24da58a133..66c2edaac297cd 100644 --- a/Documentation/technical/status-clean-proof.adoc +++ b/Documentation/technical/status-clean-proof.adoc @@ -83,9 +83,12 @@ 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 -issuing a sidecar does not itself rewrite the index. With optional locks -disabled, status does not issue a sidecar. +the pinned index is rechecked. If the exact query first has to repair +the index's file system monitor checkpoint, status writes that repair, +refreshes the resumable history checkpoint, and then installs a proof +bound to the rewritten index. Status rolls back the lock used for the +sidecar itself, so issuing a sidecar does not itself rewrite the index. +With optional locks disabled, status does not issue a sidecar. Validation and races -------------------- diff --git a/builtin/commit.c b/builtin/commit.c index 5e8c425fa13f1d..97a9fec1a89476 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1906,6 +1906,8 @@ struct repository *repo UNUSED) int repository_inputs_changed = 0; int sidecar_provider_reset = 0; int reissue_after_write = 0; + int issue_exact_after_write = 0; + int exact_after_write_candidate = 0; int save_history_after_write = 0; int deferred_scoped_history = 0; int guarded_scoped_history_source = 0; @@ -2204,6 +2206,17 @@ struct repository *repo UNUSED) reissue_clean_sidecar && preserve_entry_changes && !external_restored && !persist_restored_boundary && !hook_exists(the_repository, "post-index-change"); + /* + * An exact query may have completed a clean scan while repairing + * the provider checkpoint or cached stat data. Bind its proof to + * the repaired index, after the resumable history is durable. + */ + exact_after_write_candidate = exact_clean_query && + preserve_entry_changes && !external_restored && + !persist_restored_boundary && + !hook_exists(the_repository, "post-index-change"); + issue_exact_after_write = + exact_after_write_candidate && external_saved; if (the_repository->index->fsmonitor_legacy_untracked_fallback && !preserve_entry_changes && !external_saved) { @@ -2251,17 +2264,23 @@ struct repository *repo UNUSED) !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)) + the_repository->index)) { trace2_data_intmax("fsmonitor", the_repository, "history/external-postwrite-stored", 1); + if (exact_after_write_candidate) + issue_exact_after_write = 1; + } rollback_lock_file(&index_lock); } - if (reissue_after_write && + if ((reissue_after_write || issue_exact_after_write) && repo_hold_locked_index(the_repository, &index_lock, 0) >= 0) { if (clean_status_issue_sidecar( - &s, &clean_digest, &index_lock, 1)) + &s, &clean_digest, &index_lock, + reissue_after_write)) trace2_data_intmax("status", the_repository, - "clean-proof/postwrite-reissued", 1); + reissue_after_write ? + "clean-proof/postwrite-reissued" : + "clean-proof/postwrite-issued", 1); else rollback_lock_file(&index_lock); } diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index ea60a193d6a68a..027e76eb263572 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -88,6 +88,13 @@ stop_daemon_delete_repo () { rm -rf $1 } +stop_daemon_delete_linked_repo () { + r=$1 && + wt=$2 && + { maybe_timeout 30 git -C "$wt" fsmonitor--daemon stop 2>/dev/null || :; } && + rm -rf "$r" "$wt" +} + start_daemon () { r= tf= t2= tk= && @@ -2051,6 +2058,119 @@ test_expect_success 'bound query replaces a legacy daemon' ' ) ' +test_expect_success MACOS \ + 'read-only legacy upgrade waits for one writable exact repair' ' + test_when_finished \ + "stop_daemon_delete_repo legacy-read-only-upgrade" && + test_create_repo legacy-read-only-upgrade && + ( + cd legacy-read-only-upgrade && + sane_unset GIT_TEST_SPLIT_INDEX && + git config core.fsmonitor false && + for i in $(test_seq 1 64) + do + test_write_lines "$i" >"tracked-$i" || return 1 + done && + git add . && + git commit -qm base && + git config core.preloadIndex false && + git config core.untrackedCache true && + 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 status --porcelain=v2 --untracked-files=normal \ + --no-ahead-behind >.git/prime && + test_must_be_empty .git/prime && + test_path_is_missing .git/index.csts && + test-tool simple-ipc stop-daemon --name="$ipc_path" && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 --fsmonitor-legacy && + cp .git/index .git/index.before && + + for label in first repeat + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/$label.trace" \ + git status --porcelain=v2 >.git/$label && + test_must_be_empty .git/$label && + test_cmp_bin .git/index.before .git/index && + test_trace2_data index refresh/sum_lstat 64 \ + <.git/$label.trace || return 1 + done && + test_trace2_data fsm_client query/incompatible-daemon 1 \ + <.git/first.trace && + test_path_is_missing .git/index.csts && + { git fsmonitor--daemon stop 2>/dev/null || :; } && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/repair.trace" \ + git status --porcelain=v2 >.git/repair && + test_must_be_empty .git/repair && + ! test_cmp_bin .git/index.before .git/index && + test-tool dump-fsmonitor >.git/fsmonitor && + test_grep "fsmonitor last update builtin:test:1" \ + .git/fsmonitor + ) +' + +test_expect_success MACOS \ + 'linked worktree legacy upgrade uses its writable index repair' ' + test_when_finished \ + "stop_daemon_delete_linked_repo legacy-linked legacy-linked-wt" && + test_create_repo legacy-linked && + ( + cd legacy-linked && + git config core.fsmonitor false && + for i in $(test_seq 1 32) + do + test_write_lines "$i" >"tracked-$i" || return 1 + done && + git add . && + git commit -qm base && + git worktree add -q -b linked ../legacy-linked-wt + ) && + git -C legacy-linked config core.preloadIndex false && + git -C legacy-linked config core.untrackedCache true && + git -C legacy-linked config core.fsmonitor true && + gitdir=$(git -C legacy-linked-wt rev-parse --absolute-git-dir) && + ipc_path=$(git -C legacy-linked-wt 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 -C legacy-linked-wt status --porcelain=v2 \ + --untracked-files=normal --no-ahead-behind >linked.prime && + test_must_be_empty linked.prime && + test-tool simple-ipc stop-daemon --name="$ipc_path" && + test-tool simple-ipc start-daemon \ + --name="$ipc_path" --threads=1 --fsmonitor-legacy && + cp "$gitdir/index" linked.index.before && + for label in first repeat + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/linked-$label.trace" \ + git -C legacy-linked-wt status --porcelain=v2 \ + >linked-$label && + test_must_be_empty linked-$label && + test_cmp_bin linked.index.before "$gitdir/index" && + test_trace2_data index refresh/sum_lstat 32 \ + /dev/null || :; } && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/linked-repair.trace" \ + git -C legacy-linked-wt status --porcelain=v2 >linked-repair && + test_must_be_empty linked-repair && + ! test_cmp_bin linked.index.before "$gitdir/index" && + test_path_is_missing "$gitdir/index.csts" && + test-tool -C legacy-linked-wt dump-fsmonitor >linked.fsmonitor && + test_grep "fsmonitor last update builtin:test:1" \ + linked.fsmonitor +' + test_expect_success MACOS 'bound query upgrades stale directory event daemon' ' test_when_finished \ "stop_daemon_delete_repo directory-daemon-upgrade" && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 03fbcdc68a2246..c67531a7a5fcd0 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -1727,7 +1727,7 @@ test_expect_success DURABLE_FSMONITOR \ ' test_expect_success DURABLE_FSMONITOR \ - 'exact status persists stat repairs before a sidecar' ' + 'exact status installs a sidecar after stat repairs' ' test_when_finished "stop_daemon external-stat-exact" && setup_repo external-stat-exact && git -C external-stat-exact config core.autocrlf false && @@ -1740,21 +1740,32 @@ test_expect_success DURABLE_FSMONITOR \ 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 \ + test_trace2_data status clean-proof/hit 1 \ actual && test_must_be_empty actual && test_path_is_missing sidecar-shape/.git/index.csts && + bulk_status -C sidecar-shape status --porcelain=v2 \ + --untracked-files=normal --no-ahead-behind >actual && + test_must_be_empty actual && + test_path_is_missing sidecar-shape/.git/index.csts && test_env GIT_TRACE2_EVENT="$PWD/shape-branch.trace" \ bulk_status -C sidecar-shape \ From f4eb55c9212f15b4cc66cc9af419809213c17425 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 26 Aug 2026 18:52:53 -0500 Subject: [PATCH 410/432] status: preserve clean proofs across configured pulls A configured pull can discard each layer of authenticated status history even when worktree inputs remain unchanged. Command-scoped protocol and HTTP settings change the config digest, directory events with more than 64 tracked descendants reject the semantic proof, and a fast-forward which adds an indexed directory drops the paired untracked cache. The next status can consequently preload and refresh the full index. Treat command-scoped protocol and HTTP settings as transport-only. For a large directory event, authenticate each distinct attribute source once instead of rejecting the cone outright. When a checkout adds tracked paths, retain the paired untracked cache and replay those additions through its existing invalidation path. Cover configured pulls in main and linked worktrees, large directory events, nested attribute-source changes, and branch switches which add tracked directories. The conservative full-scan fallback remains in place when an attribute source changes. --- clean-status-config.c | 4 + clean-status-manifest.c | 202 +++++++++++++++++---------- clean-status-manifest.h | 3 + clean-status.c | 78 +++++++---- t/t7519-status-fsmonitor.sh | 53 +++++-- t/t7527-builtin-fsmonitor.sh | 62 ++++++++ t/unit-tests/u-clean-status-config.c | 5 + unpack-trees.c | 52 ++++++- 8 files changed, 350 insertions(+), 109 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index e6523e193ccaaa..8589becffce312 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -90,6 +90,10 @@ static int config_is_command_transport(const char *key, if (!ctx || !ctx->kvi || ctx->kvi->scope != CONFIG_SCOPE_COMMAND) return 0; + if (!strcmp(key, "protocol.version") || + !strcmp(key, "fetch.uriprotocols") || + starts_with(key, "http.")) + return 1; if (starts_with(key, "credential.")) return 1; if (parse_config_key(key, "url", &subsection, &subsection_len, diff --git a/clean-status-manifest.c b/clean-status-manifest.c index 8c7e79e0b481ed..7143e65934346f 100644 --- a/clean-status-manifest.c +++ b/clean-status-manifest.c @@ -233,6 +233,93 @@ static int directory_attribute_source_matches( return entry && entry->source == ATTR_MANIFEST_INDEX && !memcmp(entry->hash, indexed->oid.hash, algo->rawsz); } + +static int directory_attribute_sources_match_manifest( + struct index_state *istate, const char *directory, unsigned int first) +{ + struct clean_status_state *state = istate->clean_status; + struct semantic_verify_root *root = NULL; + struct semantic_verify_path *path = NULL; + 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 namespace_unstable = 0; + size_t len = strlen(directory), previous_len = 0; + int manifest_ret, safe = 0; + + 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_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]; + const char *slash = ce->name + len; + + while ((slash = strchr(slash, '/')) != NULL) { + 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); + } + 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, name, entry, first + i)) + goto done; + } + + 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); + string_list_clear(&candidates, 0); + strbuf_release(&candidate); + return safe; +} #endif int clean_status_manifest_path_attributes_unchanged( @@ -328,25 +415,56 @@ int clean_status_manifest_path_attributes_unchanged( #endif } +int clean_status_manifest_directory_sources_unchanged( + const struct index_state *istate, const char *directory) +{ +#if SEMANTIC_VERIFY_HAS_ANCHORED_OPEN + struct clean_status_state *state = istate->clean_status; + const struct git_hash_algo *algo = istate->repo->hash_algo; + unsigned int first; + size_t len; + int pos; + uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | + FSMONITOR_CLEAN_PROOF_FULL_INDEX; + + if (!state || !state->manifest.current_valid || + !state->manifest.checked || state->manifest.current_invalidated || + state->manifest.global_fallback || + (state->manifest.current_flags & required) != required || + !attr_manifest_valid(state->manifest.current.buf, + state->manifest.current.len, algo)) + return 0; + len = strlen(directory); + if (!len || directory[len - 1] != '/') + return 0; + pos = index_name_pos((struct index_state *)istate, directory, len); + if (pos >= 0) + return 0; + first = -pos - 1; + if (first >= istate->cache_nr || + !starts_with(istate->cache[first]->name, directory)) + return 0; + return directory_attribute_sources_match_manifest( + (struct index_state *)istate, directory, first); +#else + (void)istate; + (void)directory; + return 0; +#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 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, previous_len = 0; - int pos, manifest_ret, pinned = 0, safe = 0; + unsigned int first; + size_t len; + int pos, pinned = 0, safe = 0; uint32_t required = FSMONITOR_CLEAN_PROOF_MANIFEST_COMPLETE | FSMONITOR_CLEAN_PROOF_FULL_INDEX; @@ -399,72 +517,17 @@ int clean_status_manifest_directory_unchanged( 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_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]; - const char *slash = ce->name + len; 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; - while ((slash = strchr(slash, '/')) != NULL) { - 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); - } - 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, name, entry, - first + i)) - goto done; } - - semantic_verify_path_free(path, &namespace_unstable, NULL); - path = NULL; - if (namespace_unstable || !semantic_verify_root_stable(root) || + if (!directory_attribute_sources_match_manifest( + istate, directory, first) || !clean_status_index_snapshot_still_matches_proof_epoch( &snapshot, istate) || !semantic_verify_proof_is_current( @@ -481,13 +544,8 @@ int clean_status_manifest_directory_unchanged( 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; diff --git a/clean-status-manifest.h b/clean-status-manifest.h index 8bc4f1ac28ad28..cc256334d912f0 100644 --- a/clean-status-manifest.h +++ b/clean-status-manifest.h @@ -38,6 +38,9 @@ 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); +/* Recheck each distinct attribute source below an indexed directory. */ +int clean_status_manifest_directory_sources_unchanged( + const struct index_state *istate, const char *directory); 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 6c0dd8f1bebac8..5c38011c9271bb 100644 --- a/clean-status.c +++ b/clean-status.c @@ -456,23 +456,16 @@ static int path_has_no_new_attribute_sources( return safe; } -int clean_status_index_entry_is_semantically_safe( - const struct index_state *istate, - const struct cache_entry *old, +static int clean_status_index_entries_have_safe_shape( + 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; struct conv_attrs attrs; const char *base; - int suspended = clean_status_fsmonitor_backoff_suspended(istate); - if (!state || - (!suspended && !clean_status_revalidated_token_matches(istate)) || - (!suspended && state->filter_configured && - !state->filter_scope_valid) || - istate->split_index || - istate->sparse_index || !entry) + if (!state || !entry) return 0; if ((old && (!S_ISREG(old->ce_mode) && !S_ISLNK(old->ce_mode))) || (new_entry && (!S_ISREG(new_entry->ce_mode) && @@ -490,16 +483,35 @@ 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)) return 0; } + return 1; +} + +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; + int suspended = clean_status_fsmonitor_backoff_suspended(istate); + + if (!state || + (!suspended && !clean_status_revalidated_token_matches(istate)) || + (!suspended && state->filter_configured && + !state->filter_scope_valid) || + istate->split_index || istate->sparse_index || !entry || + !clean_status_index_entries_have_safe_shape(istate, old, new_entry)) + 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 (!old || !new_entry) return path_has_no_new_attribute_sources(istate, entry->name, old && !new_entry); @@ -547,7 +559,7 @@ static int clean_status_changed_directory_is_semantically_safe( const char *basename; unsigned int first, i, namespace_unstable = 0; size_t len; - int parent_fd, next, removed, safe = 0; + int bulk, parent_fd, next, removed, safe = 0; if (!state || !fstat_is_reliable() || !state->current_config_valid || !state->current_attr_valid || @@ -570,10 +582,8 @@ static int clean_status_changed_directory_is_semantically_safe( 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; + bulk = istate->cache_nr - first > 64 && + starts_with(istate->cache[first + 64]->name, name); if (attr_manifest_cursor_init(&cursor, state->manifest.current.buf, @@ -599,12 +609,30 @@ static int clean_status_changed_directory_is_semantically_safe( 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, removed ? istate->cache[i] : NULL, - removed ? NULL : istate->cache[i])) + if (bulk) { + /* + * Avoid repeating anchored ancestry checks for every entry in a + * large cone. The manifest helper verifies each distinct attribute + * candidate once. Removed cones retain the conservative fallback. + */ + if (removed || + !clean_status_manifest_directory_sources_unchanged( + istate, name)) goto done; + for (i = first; i < istate->cache_nr && + starts_with(istate->cache[i]->name, name); i++) + if (!clean_status_index_entries_have_safe_shape( + istate, NULL, istate->cache[i])) + goto done; + } else { + for (i = first; i < istate->cache_nr && + starts_with(istate->cache[i]->name, name); i++) + if (!clean_status_index_entry_is_semantically_safe( + istate, + removed ? istate->cache[i] : NULL, + removed ? NULL : istate->cache[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 f511993452c078..aa6bd3165539dd 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2932,8 +2932,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ for role in main linked do case "$role" in - main) worktree="$repo" ;; - linked) worktree="$linked" ;; + main) + worktree="$repo" && + invalidated=96 + ;; + linked) + worktree="$linked" && + invalidated=192 + ;; esac && if test "$mode" != ff then @@ -2956,9 +2962,21 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 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" && + if test "$mode" = ff + then + mkdir -p "upstream-$mode-$role/nested" && + for file in $(test_seq 1 96) + do + test_write_lines "$mode-$role-$file" \ + >"upstream-$mode-$role/nested/$file" || + return 1 + done && + git add "upstream-$mode-$role" + else + test_write_lines "$mode-$role" \ + >"upstream-$mode-$role" && + git add "upstream-$mode-$role" + fi && git commit -qm "upstream-$mode-$role" && git push --quiet origin main && if test "$mode" = autostash @@ -2971,7 +2989,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ GIT_TRACE2_EVENT="$gitdir/pull.trace" \ - git -C "$worktree" "$@" \ + git -C "$worktree" \ + -c protocol.version=2 \ + -c fetch.uriprotocols=https \ + -c http.https://example.invalid.extraHeader=header \ + -c http.https://example.invalid.proactiveAuth=basic \ + -c http.https://example.invalid.sslVerify=true \ + "$@" \ >"$gitdir/pull" && if test "$mode" != ff then @@ -2987,6 +3011,12 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ! test_trace2_data fsmonitor \ semantic/manifest-scan-count 1 \ <"$gitdir/pull.trace" && + if test "$mode" = ff + then + test_trace2_data fsmonitor \ + history/untracked-paired-new-directory-invalidated \ + "$invalidated" <"$gitdir/pull.trace" + fi && perl "$PWD/.git/check-pull-proof.pl" \ <"$gitdir/index" && cp "$gitdir/index" "$gitdir/readonly.index" && @@ -5295,9 +5325,12 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO if test "$branch" = alternate then test_trace2_data fsmonitor \ - history/untracked-paired-new-directory-deferred 1 \ + history/untracked-paired-new-directory-invalidated 2 \ + <".git/switch-$branch.trace" && + test_trace2_data fsmonitor \ + history/untracked-paired-transfer 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 \ @@ -5331,11 +5364,9 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO test "$visited_dirs" -lt 12 elif test "$branch" = alternate then - test_trace2_data fsmonitor \ + ! 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" \ diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 027e76eb263572..80dac65081a508 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -3242,6 +3242,68 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'large directory events authenticate distinct attribute sources once' ' + test_when_finished "rm -rf large-directory-event" && + test_create_repo large-directory-event && + ( + cd large-directory-event && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p bulk/nested siblings && + for file in $(test_seq 1 96) + do + test_write_lines "bulk-$file" >"bulk/nested/$file" || + return 1 + done && + for file in $(test_seq 1 128) + do + test_write_lines "sibling-$file" >"siblings/$file" || + return 1 + done && + git add bulk siblings && + 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 && + + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=bulk/ \ + GIT_TRACE2_EVENT="$PWD/.git/bulk.trace" \ + git status --porcelain=v2 >.git/bulk.actual && + test_must_be_empty .git/bulk.actual && + test_trace2_data fsmonitor \ + semantic/authenticated-restored-directory 1 \ + <.git/bulk.trace && + test_trace2_data index refresh/sum_lstat 96 \ + <.git/bulk.trace && + ! have_t2_data_event fsmonitor semantic/attributes-cone \ + <.git/bulk.trace && + ! have_t2_data_event fsmonitor semantic/manifest-scan-count \ + <.git/bulk.trace && + + test_write_lines "*.txt text" >bulk/nested/.gitattributes && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/attributes.expect && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=bulk/ \ + 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 semantic/attributes-cone 96 \ + <.git/attributes.trace && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <.git/attributes.trace && + ! have_t2_data_event fsmonitor \ + semantic/authenticated-restored-directory \ + <.git/attributes.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'deleted staged directories never discard nested attribute sources' ' test_when_finished \ diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 58270e6250c732..51ebf8785bc907 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -70,6 +70,11 @@ void test_clean_status_config__origin_only_affects_full_hash(void) void test_clean_status_config__command_transport_config_does_not_change_proof(void) { static const char *const ignored_keys[] = { + "protocol.version", + "fetch.uriprotocols", + "http.https://Example.Invalid.extraheader", + "http.https://Example.Invalid.proactiveauth", + "http.https://Example.Invalid.sslverify", "credential.helper", "credential.https://Example/Team.helper", "url.https://Proxy.Example/Team/.insteadof", diff --git a/unpack-trees.c b/unpack-trees.c index 5079079129af9c..9c180eda3178ff 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -1914,6 +1914,50 @@ static int checkout_introduces_new_indexed_directory( return 0; } +static unsigned int checkout_invalidate_new_index_entries( + struct index_state *source, struct index_state *result) +{ + unsigned int invalidated = 0, 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]; + int cmp; + + while (source_pos < source->cache_nr) { + const struct cache_entry *source_entry = + source->cache[source_pos]; + + cmp = strcmp(source_entry->name, entry->name); + if (!cmp) + cmp = ce_stage(source_entry) - ce_stage(entry); + if (cmp >= 0) + break; + source_pos++; + } + if (source_pos < source->cache_nr) { + const struct cache_entry *source_entry = + source->cache[source_pos]; + + cmp = strcmp(source_entry->name, entry->name); + if (!cmp) + cmp = ce_stage(source_entry) - ce_stage(entry); + } else { + cmp = 1; + } + if (!cmp) + continue; + /* + * The result receives the source untracked cache after its entries + * are built. Replay additions now so a newly tracked directory is + * represented by invalid cache nodes rather than dropping FSUC. + */ + untracked_cache_invalidate_path(result, entry->name, 0); + invalidated++; + } + return invalidated; +} + /* * 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. @@ -2142,7 +2186,6 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options 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 && !o->internal.result.sparse_index && !o->src_index->split_index && @@ -2169,6 +2212,13 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options o->internal.result.untracked->use_fsmonitor = 1; trace2_data_intmax("fsmonitor", repo, "history/untracked-paired-transfer", 1); + if (new_indexed_directory) + trace2_data_intmax( + "fsmonitor", repo, + "history/untracked-paired-new-directory-invalidated", + checkout_invalidate_new_index_entries( + o->src_index, + &o->internal.result)); } else if (new_indexed_directory) { trace2_data_intmax( "fsmonitor", repo, From 9a72a7020b561ef2b24d791fe42cac2a528c13bb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 26 Aug 2026 23:20:07 -0500 Subject: [PATCH 411/432] unpack-trees: retain clean proofs across policy updates Configured pulls preserve FSMonitor clean proofs when checkout can authenticate every index change. Tracked policy files were an exception: adding or replacing .gitattributes or .gitignore made the generic semantic transfer reject the whole proof. Later read-only status commands then had to rescan the worktree and could not restore the paired untracked proof. Let checkout retain history across regular policy-file changes that it writes itself. Attribute changes refresh the worktree manifest before the provider boundary is rebound, and fail closed if that refresh cannot authenticate the new sources. Keep the existing untracked-cache invalidation for ignore changes, and transfer that cache only while the full tracked proof remains current. Exercise configured fast-forward pulls in main and linked worktrees. A required-filter control also verifies that changed attributes invalidate the affected tracked entry instead of certifying it. --- clean-status-history.c | 93 +++++++++++++++++++++++++++++++++++-- clean-status.h | 3 ++ t/t7519-status-fsmonitor.sh | 74 +++++++++++++++++++++++++---- unpack-trees.c | 35 +++++++++++++- 4 files changed, 189 insertions(+), 16 deletions(-) diff --git a/clean-status-history.c b/clean-status-history.c index 97a501424c0f58..80909a59a985b0 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -2018,14 +2018,65 @@ int clean_status_transfer_current_proof_if_same_index( return replace_current_fsmonitor_proof(dst, src); } -int clean_status_transfer_current_proof_if_semantically_same_index( - struct index_state *dst, const struct index_state *src) +enum checkout_policy_change { + CHECKOUT_POLICY_NONE = 0, + CHECKOUT_POLICY_IGNORE = 1 << 0, + CHECKOUT_POLICY_ATTRIBUTES = 1 << 1, +}; + +static enum checkout_policy_change checkout_policy_change_kind( + const struct cache_entry *old, const struct cache_entry *new_entry) +{ + const struct cache_entry *entry = old ? old : new_entry; + const char *base; + + if (!entry || + (old && (!S_ISREG(old->ce_mode) || ce_stage(old) || + ce_skip_worktree(old) || ce_intent_to_add(old) || + (old->ce_flags & CE_VALID))) || + (new_entry && (!S_ISREG(new_entry->ce_mode) || ce_stage(new_entry) || + ce_skip_worktree(new_entry) || + ce_intent_to_add(new_entry) || + (new_entry->ce_flags & CE_VALID))) || + (old && new_entry && + (strcmp(old->name, new_entry->name) || old->ce_mode != new_entry->ce_mode))) + return CHECKOUT_POLICY_NONE; + base = strrchr(entry->name, '/'); + base = base ? base + 1 : entry->name; + if (!fspathcmp(base, GITATTRIBUTES_FILE)) + return CHECKOUT_POLICY_ATTRIBUTES; + if (!fspathcmp(base, ".gitignore")) + return CHECKOUT_POLICY_IGNORE; + return CHECKOUT_POLICY_NONE; +} + +static int record_checkout_policy_change( + enum checkout_policy_change *policy_changes, + const struct cache_entry *old, const struct cache_entry *new_entry) +{ + enum checkout_policy_change change = + checkout_policy_change_kind(old, new_entry); + + if (!change) + return 0; + *policy_changes |= change; + return 1; +} + +static int transfer_current_proof_if_semantically_same_index( + struct index_state *dst, const struct index_state *src, + int allow_checkout_policy_changes, + int *manifest_refresh_required) { const unsigned int semantic_flags = CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; unsigned int src_pos = 0, dst_pos = 0; + enum checkout_policy_change policy_changes = CHECKOUT_POLICY_NONE; int transferred; + if (manifest_refresh_required) + *manifest_refresh_required = 0; + if (!current_proof_is_writable(src) || src->repo != dst->repo || src->split_index || dst->split_index || src->sparse_index || dst->sparse_index || @@ -2050,12 +2101,18 @@ int clean_status_transfer_current_proof_if_semantically_same_index( cmp = strcmp(old->name, new_entry->name); if (cmp < 0) { if (!clean_status_index_entry_is_semantically_safe( - src, old, NULL)) + src, old, NULL) && + (!allow_checkout_policy_changes || + !record_checkout_policy_change(&policy_changes, + old, NULL))) return 0; src_pos++; } else if (cmp > 0) { if (!clean_status_index_entry_is_semantically_safe( - src, NULL, new_entry)) + src, NULL, new_entry) && + (!allow_checkout_policy_changes || + !record_checkout_policy_change(&policy_changes, + NULL, new_entry))) return 0; dst_pos++; } else { @@ -2063,12 +2120,18 @@ int clean_status_transfer_current_proof_if_semantically_same_index( !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)) + src, old, new_entry) && + (!allow_checkout_policy_changes || + !record_checkout_policy_change(&policy_changes, + old, new_entry))) return 0; src_pos++; dst_pos++; } } + if (manifest_refresh_required && + (policy_changes & CHECKOUT_POLICY_ATTRIBUTES)) + *manifest_refresh_required = 1; if (current_proof_is_writable(dst)) { const struct clean_status_state *src_state = src->clean_status; @@ -2102,6 +2165,26 @@ int clean_status_transfer_current_proof_if_semantically_same_index( return transferred; } +int clean_status_transfer_current_proof_if_semantically_same_index( + struct index_state *dst, const struct index_state *src) +{ + return transfer_current_proof_if_semantically_same_index( + dst, src, 0, NULL); +} + +int clean_status_transfer_current_proof_after_checkout( + struct index_state *dst, const struct index_state *src, + int *manifest_refresh_required) +{ + /* + * A successful checkout owns these worktree writes. It may therefore + * retain history across policy-file changes, but the caller must refresh + * changed attribute sources before pairing the transferred proof. + */ + return transfer_current_proof_if_semantically_same_index( + dst, src, 1, manifest_refresh_required); +} + struct clean_status_commit_checkpoint { struct repository *repo; struct lock_file *lock; diff --git a/clean-status.h b/clean-status.h index 1d17885cbc6d19..d871117b95bf0a 100644 --- a/clean-status.h +++ b/clean-status.h @@ -162,6 +162,9 @@ 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); +int clean_status_transfer_current_proof_after_checkout( + struct index_state *dst, const struct index_state *src, + int *manifest_refresh_required); /* * A canonical main-index source may lend suspended historical state to an diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index aa6bd3165539dd..d0d9297fc03c89 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2875,7 +2875,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 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" && + 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 pull-proof-filter" && git init --bare pull-proof-origin.git && test_create_repo pull-proof-seed && ( @@ -2934,11 +2934,11 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ case "$role" in main) worktree="$repo" && - invalidated=96 + invalidated=98 ;; linked) worktree="$linked" && - invalidated=192 + invalidated=194 ;; esac && if test "$mode" != ff @@ -2969,9 +2969,14 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ do test_write_lines "$mode-$role-$file" \ >"upstream-$mode-$role/nested/$file" || - return 1 + return 1 done && - git add "upstream-$mode-$role" + test_write_lines "# $mode-$role" \ + >.gitattributes && + test_write_lines "*.ignored" "# $mode-$role" \ + >.gitignore && + git add "upstream-$mode-$role" \ + .gitattributes .gitignore else test_write_lines "$mode-$role" \ >"upstream-$mode-$role" && @@ -3008,14 +3013,21 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ <"$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" && if test "$mode" = ff then + test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/pull.trace" && + test_trace2_data fsmonitor \ + history/checkout-manifest-refreshed 1 \ + <"$gitdir/pull.trace" && test_trace2_data fsmonitor \ history/untracked-paired-new-directory-invalidated \ "$invalidated" <"$gitdir/pull.trace" + else + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/pull.trace" fi && perl "$PWD/.git/check-pull-proof.pl" \ <"$gitdir/index" && @@ -3041,7 +3053,51 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ semantic/manifest-scan-count 1 \ <"$gitdir/status.trace" || return 1 done || return 1 - done + done && + git clone --quiet "$PWD/../pull-proof-origin.git" \ + "$PWD/../pull-proof-filter" && + filter="$PWD/../pull-proof-filter" && + git -C "$filter" config pull.ff only && + git -C "$filter" config core.untrackedCache true && + git -C "$filter" config core.fsmonitor true && + git -C "$filter" config filter.pullproof.clean false && + git -C "$filter" config filter.pullproof.required true && + filter_gitdir=$(git -C "$filter" rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C "$filter" update-index --fsmonitor && + GIT_INDEX_FILE="$filter_gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C "$filter" status --porcelain=v2 \ + >"$filter_gitdir/prime" && + test_must_be_empty "$filter_gitdir/prime" && + test_write_lines "tracked filter=pullproof" >.gitattributes && + git add .gitattributes && + git commit -qm "upstream-filter" && + git push --quiet origin main && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$filter_gitdir/pull.trace" \ + git -C "$filter" pull --quiet && + test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$filter_gitdir/pull.trace" && + test_trace2_data fsmonitor history/checkout-manifest-refreshed 1 \ + <"$filter_gitdir/pull.trace" && + test_trace2_data fsmonitor semantic/manifest-invalidated 1 \ + <"$filter_gitdir/pull.trace" && + test_trace2_data fsmonitor history/untracked-paired-transfer 1 \ + <"$filter_gitdir/pull.trace" && + cp "$filter_gitdir/index" "$filter_gitdir/status.before" && + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$filter_gitdir/status.trace" \ + git -C "$filter" status --porcelain=v2 \ + --untracked-files=no -- tracked \ + >"$filter_gitdir/status" \ + 2>"$filter_gitdir/status.err" && + test_grep "clean filter .pullproof. failed" \ + "$filter_gitdir/status.err" && + test_cmp_bin "$filter_gitdir/status.before" \ + "$filter_gitdir/index" ) ' diff --git a/unpack-trees.c b/unpack-trees.c index 9c180eda3178ff..72847c7d8f6d6f 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -2165,6 +2165,7 @@ 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) { int history_transferred = 0; + int manifest_refresh_required = 0; int new_indexed_directory = 0; if (!ret) { @@ -2173,14 +2174,44 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options &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); + clean_status_transfer_current_proof_after_checkout( + &o->internal.result, o->src_index, + &manifest_refresh_required); 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 && history_transferred && manifest_refresh_required) { + int manifest_refreshed = 0; + + /* + * The checkout has installed the new attribute sources. Refresh + * them before allowing the old provider boundary to authenticate + * the resulting index and paired untracked cache. + */ + if (clean_status_refresh_worktree_manifest( + &o->internal.result) < 0 || + clean_status_manifest_global_fallback( + &o->internal.result)) { + clean_status_invalidate_current_proof( + &o->internal.result); + history_transferred = 0; + } else { + manifest_refreshed = 1; + clean_status_mark_fsmonitor_config_valid( + &o->internal.result, + o->internal.result.fsmonitor_last_update); + history_transferred = + clean_status_has_current_full_fsmonitor_proof( + &o->internal.result); + } + trace2_data_intmax( + "fsmonitor", repo, + "history/checkout-manifest-refreshed", + manifest_refreshed); + } if (!ret && o->internal.backoff_transfer) clean_status_transfer_backoff_history( o->internal.backoff_transfer, From 049a82099208da519f508d60e1ca1a7268871ee6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 03:00:10 -0500 Subject: [PATCH 412/432] status: keep clean proofs current after worktree updates A clean status proof can survive a pull only when its configuration, tracked-file state, FSMonitor token, and paired untracked cache still describe the resulting worktree. Command-scoped push transport settings were included in the configuration fingerprint. Checkout could also discard the untracked proof for policy-file changes or leave events from its own worktree writes outside the proof. The next diff, write-tree, or status then repeated tracked and untracked work. With optional locks disabled, status could not publish the repair, so each invocation paid the same cost. Treat push.negotiate and remote.*.pushurl like other command-scoped transport settings. Preserve the paired untracked cache across checkout, invalidate only affected policy scopes, and authenticate distinct attribute-source directories before transferring semantic history. For checkout, reset, merge, and sequencer worktree updates, write a provisional index under the existing lock, consume the daemon events caused by the update, and certify the result against that locked index before the final write. This also covers stash cleanup through its hard reset. Alternate indexes, split or sparse indexes, unsafe filter or manifest state, and incomplete stat data still fall back. Cover configured pulls, root and nested policy changes, main and linked worktrees, rebase, reset, stash, checkout, and repeated read-only status. --- builtin/checkout.c | 20 +- builtin/reset.c | 13 + builtin/stash.c | 6 - clean-status-config.c | 5 + clean-status-epoch.c | 37 +- clean-status-history.c | 146 +++++- clean-status-index.c | 29 +- clean-status-index.h | 6 + clean-status.c | 10 + clean-status.h | 6 + fsmonitor.c | 18 + fsmonitor.h | 3 + merge.c | 17 +- read-cache-ll.h | 3 + read-cache.c | 12 +- reset.c | 15 +- sequencer.c | 11 +- t/t7519-status-fsmonitor.sh | 738 +++++++++++++++++++++++++-- t/t7527-builtin-fsmonitor.sh | 22 +- t/unit-tests/u-clean-status-config.c | 14 + unpack-trees.c | 57 ++- unpack-trees.h | 1 + wt-status.c | 165 +++++- wt-status.h | 12 + 24 files changed, 1271 insertions(+), 95 deletions(-) diff --git a/builtin/checkout.c b/builtin/checkout.c index fbc324b8b7dee7..b9b355d7f3ced2 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -554,6 +554,7 @@ static int checkout_paths(const struct checkout_opts *opts, int checkout_index; int preserve_source_tree_history = 0; int source_tree_index_changed = 0; + int repair_after_checkout = 0; trace2_cmd_mode(opts->patch_mode ? "patch" : "path"); @@ -678,6 +679,9 @@ static int checkout_paths(const struct checkout_opts *opts, } if (repo_read_index_preload(the_repository, &opts->pathspec, 0) < 0) return error(_("index file corrupt")); + repair_after_checkout = opts->checkout_worktree && + clean_status_has_current_full_fsmonitor_proof( + the_repository->index); if (preserve_source_tree_history && (the_repository->index->split_index || @@ -764,6 +768,10 @@ static int checkout_paths(const struct checkout_opts *opts, if (!the_repository->index->cache_changed && !hook_exists(the_repository, "post-index-change")) flags |= SKIP_IF_UNCHANGED; + if (wt_status_repair_fsmonitor_proof_after_worktree_update( + the_repository, &lock_file, + repair_after_checkout) < 0) + die(_("unable to repair new index file")); if (write_locked_index(the_repository->index, &lock_file, flags)) die(_("unable to write new index file")); } else { @@ -836,6 +844,9 @@ static int reset_tree(struct tree *tree, const struct checkout_opts *o, opts.verbose_update = o->show_progress; opts.src_index = the_repository->index; opts.dst_index = the_repository->index; + opts.preserve_semantic_history = worktree && + clean_status_revalidated_token_matches(the_repository->index); + opts.preserve_untracked_history = opts.preserve_semantic_history; init_checkout_metadata(&opts.meta, info->refname, info->commit ? &info->commit->object.oid : null_oid(the_hash_algo), NULL); @@ -907,7 +918,7 @@ static int merge_working_tree(const struct checkout_opts *opts, bool quiet, int *writeout_error) { - int ret; + int ret, repair_after_checkout; struct lock_file lock_file = LOCK_INIT; struct tree *new_tree; @@ -927,6 +938,9 @@ static int merge_working_tree(const struct checkout_opts *opts, rollback_lock_file(&lock_file); return error(_("index file corrupt")); } + repair_after_checkout = + clean_status_has_current_full_fsmonitor_proof( + the_repository->index); resolve_undo_clear_index(the_repository->index); if (opts->new_orphan_branch && opts->orphan_from_empty_tree) { @@ -969,6 +983,7 @@ static int merge_working_tree(const struct checkout_opts *opts, init_topts(&topts, opts->show_progress, opts->overwrite_ignore, quiet); topts.preserve_semantic_history = 1; + topts.preserve_untracked_history = 1; init_checkout_metadata(&topts.meta, new_branch_info->refname, new_branch_info->commit ? &new_branch_info->commit->object.oid : @@ -1001,6 +1016,9 @@ static int merge_working_tree(const struct checkout_opts *opts, if (!cache_tree_fully_valid(the_repository->index->cache_tree)) cache_tree_update(the_repository->index, WRITE_TREE_SILENT | WRITE_TREE_REPAIR); + if (wt_status_repair_fsmonitor_proof_after_worktree_update( + the_repository, &lock_file, repair_after_checkout) < 0) + die(_("unable to repair new index file")); if (write_locked_index(the_repository->index, &lock_file, COMMIT_LOCK)) die(_("unable to write new index file")); diff --git a/builtin/reset.c b/builtin/reset.c index 0d8660fa3b9f46..0f28886d02fa6a 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -41,6 +41,7 @@ #include "trace2.h" #include "dir.h" #include "add-interactive.h" +#include "wt-status.h" #define REFRESH_INDEX_DELAY_WARNING_IN_MS (2 * 1000) @@ -100,6 +101,11 @@ static int reset_index(const char *ref, const struct object_id *oid, int reset_t } repo_read_index_unmerged(the_repository); + if (reset_type == HARD && + clean_status_revalidated_token_matches(the_repository->index)) { + opts.preserve_semantic_history = 1; + opts.preserve_untracked_history = 1; + } if (reset_type == KEEP) { struct object_id head_oid; @@ -529,6 +535,9 @@ int cmd_reset(int argc, if (reset_type != SOFT) { struct lock_file lock = LOCK_INIT; unsigned int write_flags = COMMIT_LOCK; + int repair_after_reset = reset_type == HARD && + clean_status_has_current_full_fsmonitor_proof( + the_repository->index); repo_hold_locked_index(the_repository, &lock, LOCK_DIE_ON_ERROR); @@ -580,6 +589,10 @@ int cmd_reset(int argc, !the_repository->index->cache_changed && !hook_exists(the_repository, "post-index-change")) write_flags |= SKIP_IF_UNCHANGED; + if (reset_type == HARD && + wt_status_repair_fsmonitor_proof_after_worktree_update( + the_repository, &lock, repair_after_reset) < 0) + die(_("Could not repair new index file.")); if (write_locked_index(the_repository->index, &lock, write_flags)) die(_("Could not write new index file.")); } diff --git a/builtin/stash.c b/builtin/stash.c index 60a63ef004435a..f91160b7e92529 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -1804,12 +1804,6 @@ 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 && !(patch_mode || only_staged)) { - 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")); diff --git a/clean-status-config.c b/clean-status-config.c index 8589becffce312..5c52bbca1c240c 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -92,10 +92,15 @@ static int config_is_command_transport(const char *key, return 0; if (!strcmp(key, "protocol.version") || !strcmp(key, "fetch.uriprotocols") || + !strcmp(key, "push.negotiate") || starts_with(key, "http.")) return 1; if (starts_with(key, "credential.")) return 1; + if (!parse_config_key(key, "remote", &subsection, &subsection_len, + &subkey) && subsection && subsection_len && + !strcmp(subkey, "pushurl")) + return 1; if (parse_config_key(key, "url", &subsection, &subsection_len, &subkey) || !subsection || !subsection_len) return 0; diff --git a/clean-status-epoch.c b/clean-status-epoch.c index f78bf8fb6bd78f..1041ba45d794db 100644 --- a/clean-status-epoch.c +++ b/clean-status-epoch.c @@ -22,6 +22,7 @@ struct clean_status_proof_epoch { unsigned char attr_hash[GIT_MAX_RAWSZ]; unsigned char attr_namespace_hash[GIT_MAX_RAWSZ]; unsigned char manifest_hash[GIT_MAX_RAWSZ]; + char *index_path; uint32_t manifest_flags; unsigned semantic_explicit : 1; unsigned attr_sources_present : 1; @@ -54,10 +55,10 @@ static int config_matches_epoch( algo->rawsz); } -struct clean_status_proof_epoch *clean_status_capture_proof_epoch( +static struct clean_status_proof_epoch *capture_proof_epoch( struct index_state *istate, const struct attr_source_snapshot *attrs, - int validate_filter_scope) + int validate_filter_scope, const char *index_path) { struct clean_status_state *state = istate->clean_status; struct clean_status_proof_epoch *epoch; @@ -97,12 +98,17 @@ struct clean_status_proof_epoch *clean_status_capture_proof_epoch( 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)) + (index_path ? + clean_status_index_snapshot_pin_path_proof_epoch( + &index, istate, index_path) : + clean_status_index_snapshot_pin_proof_epoch(&index, istate))) return NULL; CALLOC_ARRAY(epoch, 1); epoch->istate = istate; epoch->index = index; + epoch->index_path = xstrdup(index_path ? index_path : + istate->repo->index_file); epoch->scan_start_token = xstrdup(istate->fsmonitor_last_update_pending); memcpy(epoch->config_hash, state->current_config_hash, istate->repo->hash_algo->rawsz); @@ -127,6 +133,26 @@ struct clean_status_proof_epoch *clean_status_capture_proof_epoch( return epoch; } +struct clean_status_proof_epoch *clean_status_capture_proof_epoch( + struct index_state *istate, + const struct attr_source_snapshot *attrs, + int validate_filter_scope) +{ + return capture_proof_epoch( + istate, attrs, validate_filter_scope, NULL); +} + +struct clean_status_proof_epoch *clean_status_capture_proof_epoch_at_path( + struct index_state *istate, + const struct attr_source_snapshot *attrs, + int validate_filter_scope, const char *index_path) +{ + if (!index_path || !*index_path) + return NULL; + return capture_proof_epoch( + istate, attrs, validate_filter_scope, index_path); +} + int clean_status_proof_epoch_start_token_matches( struct index_state *istate, const struct clean_status_proof_epoch *epoch) @@ -174,8 +200,8 @@ static int proof_epoch_matches( 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)) + !clean_status_index_snapshot_still_matches_path_proof_epoch( + &epoch->index, istate, epoch->index_path)) goto done; matched = 1; done: @@ -217,6 +243,7 @@ void clean_status_release_proof_epoch( if (!epoch) return; clean_status_index_snapshot_release(&epoch->index); + free(epoch->index_path); free(epoch->scan_start_token); free(epoch); } diff --git a/clean-status-history.c b/clean-status-history.c index 80909a59a985b0..434ff3f7b9c8f2 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -19,6 +19,7 @@ #include "repository.h" #include "semantic-verify-internal.h" #include "strbuf.h" +#include "strmap.h" #include "trace2.h" #include "ewah/ewok.h" @@ -2024,6 +2025,11 @@ enum checkout_policy_change { CHECKOUT_POLICY_ATTRIBUTES = 1 << 1, }; +struct checkout_policy_change_context { + enum checkout_policy_change kinds; + struct strset attribute_directories; +}; + static enum checkout_policy_change checkout_policy_change_kind( const struct cache_entry *old, const struct cache_entry *new_entry) { @@ -2051,18 +2057,119 @@ static enum checkout_policy_change checkout_policy_change_kind( } static int record_checkout_policy_change( - enum checkout_policy_change *policy_changes, + struct checkout_policy_change_context *context, const struct cache_entry *old, const struct cache_entry *new_entry) { enum checkout_policy_change change = checkout_policy_change_kind(old, new_entry); + const struct cache_entry *entry = old ? old : new_entry; if (!change) return 0; - *policy_changes |= change; + context->kinds |= change; + if (change == CHECKOUT_POLICY_ATTRIBUTES) { + const char *slash = strrchr(entry->name, '/'); + char *directory = slash ? + xmemdupz(entry->name, slash - entry->name + 1) : + xstrdup(""); + + strset_add(&context->attribute_directories, directory); + free(directory); + } return 1; } +static void collect_checkout_policy_changes( + struct checkout_policy_change_context *context, + const struct index_state *dst, const struct index_state *src) +{ + const unsigned int semantic_flags = + CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; + unsigned int src_pos = 0, dst_pos = 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) { + record_checkout_policy_change(context, old, NULL); + src_pos++; + } else if (cmp > 0) { + record_checkout_policy_change(context, NULL, new_entry); + 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)) + record_checkout_policy_change(context, old, + new_entry); + src_pos++; + dst_pos++; + } + } +} + +static int checkout_policy_scope_entry_has_safe_shape( + const struct cache_entry *old, const struct cache_entry *new_entry) +{ + const struct cache_entry *entry = old ? old : new_entry; + + if (!entry || + (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))) || + (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))) || + (old && new_entry && + (strcmp(old->name, new_entry->name) || old->ce_mode != new_entry->ce_mode))) + return 0; + return 1; +} + +static int checkout_entry_is_in_changed_attribute_scope( + struct checkout_policy_change_context *context, + const struct cache_entry *old, const struct cache_entry *new_entry) +{ + const struct cache_entry *entry = old ? old : new_entry; + struct strbuf directory = STRBUF_INIT; + const char *slash = entry->name; + int found = strset_contains(&context->attribute_directories, ""); + + while (!found && (slash = strchr(slash, '/')) != NULL) { + strbuf_reset(&directory); + strbuf_add(&directory, entry->name, slash - entry->name + 1); + found = strset_contains(&context->attribute_directories, + directory.buf); + slash++; + } + strbuf_release(&directory); + return found; +} + +static int checkout_policy_change_allows_entry( + struct checkout_policy_change_context *context, + const struct cache_entry *old, const struct cache_entry *new_entry) +{ + if (record_checkout_policy_change(context, old, new_entry)) + return 1; + return (context->kinds & CHECKOUT_POLICY_ATTRIBUTES) && + checkout_policy_scope_entry_has_safe_shape(old, new_entry) && + checkout_entry_is_in_changed_attribute_scope(context, old, + new_entry); +} + static int transfer_current_proof_if_semantically_same_index( struct index_state *dst, const struct index_state *src, int allow_checkout_policy_changes, @@ -2071,8 +2178,10 @@ static int transfer_current_proof_if_semantically_same_index( const unsigned int semantic_flags = CE_STAGEMASK | CE_VALID | CE_EXTENDED_FLAGS; unsigned int src_pos = 0, dst_pos = 0; - enum checkout_policy_change policy_changes = CHECKOUT_POLICY_NONE; - int transferred; + struct checkout_policy_change_context policy_changes = { + .attribute_directories = STRSET_INIT, + }; + int transferred = 0; if (manifest_refresh_required) *manifest_refresh_required = 0; @@ -2085,6 +2194,8 @@ static int transfer_current_proof_if_semantically_same_index( !src->fsmonitor_last_update || !dst->fsmonitor_last_update || strcmp(src->fsmonitor_last_update, dst->fsmonitor_last_update)) return 0; + if (allow_checkout_policy_changes) + collect_checkout_policy_changes(&policy_changes, dst, src); while (src_pos < src->cache_nr || dst_pos < dst->cache_nr) { const struct cache_entry *old = src_pos < src->cache_nr ? @@ -2103,17 +2214,17 @@ static int transfer_current_proof_if_semantically_same_index( if (!clean_status_index_entry_is_semantically_safe( src, old, NULL) && (!allow_checkout_policy_changes || - !record_checkout_policy_change(&policy_changes, - old, NULL))) - return 0; + !checkout_policy_change_allows_entry(&policy_changes, + old, NULL))) + goto done; src_pos++; } else if (cmp > 0) { if (!clean_status_index_entry_is_semantically_safe( src, NULL, new_entry) && (!allow_checkout_policy_changes || - !record_checkout_policy_change(&policy_changes, - NULL, new_entry))) - return 0; + !checkout_policy_change_allows_entry(&policy_changes, + NULL, new_entry))) + goto done; dst_pos++; } else { if ((old->ce_mode != new_entry->ce_mode || @@ -2122,15 +2233,15 @@ static int transfer_current_proof_if_semantically_same_index( !clean_status_index_entry_is_semantically_safe( src, old, new_entry) && (!allow_checkout_policy_changes || - !record_checkout_policy_change(&policy_changes, - old, new_entry))) - return 0; + !checkout_policy_change_allows_entry(&policy_changes, + old, new_entry))) + goto done; src_pos++; dst_pos++; } } if (manifest_refresh_required && - (policy_changes & CHECKOUT_POLICY_ATTRIBUTES)) + (policy_changes.kinds & CHECKOUT_POLICY_ATTRIBUTES)) *manifest_refresh_required = 1; if (current_proof_is_writable(dst)) { @@ -2151,10 +2262,11 @@ static int transfer_current_proof_if_semantically_same_index( memcmp(src_state->manifest.current.buf, dst_state->manifest.current.buf, src_state->manifest.current.len)) - return 0; + goto done; trace2_data_intmax("fsmonitor", dst->repo, "history/semantic-transferred", 1); - return 1; + transferred = 1; + goto done; } transferred = replace_current_fsmonitor_proof(dst, src); @@ -2162,6 +2274,8 @@ static int transfer_current_proof_if_semantically_same_index( trace2_data_intmax("fsmonitor", dst->repo, "history/semantic-transferred", 1); +done: + strset_clear(&policy_changes.attribute_directories); return transferred; } diff --git a/clean-status-index.c b/clean-status-index.c index 50cb6d870d8ecc..858fd90160cfa7 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -215,11 +215,12 @@ static int snapshot_matches_index_state( snapshot, state, istate->repo->hash_algo); } -static int snapshot_pin( +static int snapshot_pin_path( struct clean_status_index_snapshot *snapshot, - struct index_state *istate, int allow_process_local_source) + struct index_state *istate, const char *path, + int allow_process_local_source) { - if (snapshot_open(snapshot, istate->repo->index_file, + if (snapshot_open(snapshot, path, istate->repo->hash_algo, 1)) return -1; if (snapshot_matches_index_state( @@ -233,7 +234,8 @@ int clean_status_index_snapshot_pin( struct clean_status_index_snapshot *snapshot, struct index_state *istate) { - return snapshot_pin(snapshot, istate, 0); + return snapshot_pin_path( + snapshot, istate, istate->repo->index_file, 0); } int clean_status_index_snapshot_pin_proof_epoch( @@ -245,7 +247,15 @@ int clean_status_index_snapshot_pin_proof_epoch( * 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); + return snapshot_pin_path( + snapshot, istate, istate->repo->index_file, 1); +} + +int clean_status_index_snapshot_pin_path_proof_epoch( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate, const char *path) +{ + return snapshot_pin_path(snapshot, istate, path, 1); } static int snapshot_still_matches( @@ -273,6 +283,15 @@ int clean_status_index_snapshot_still_matches_proof_epoch( return snapshot_still_matches(snapshot, istate, 1); } +int clean_status_index_snapshot_still_matches_path_proof_epoch( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate, const char *path) +{ + return snapshot_matches_index_state(snapshot, istate, 1) && + clean_status_index_snapshot_still_matches_path( + snapshot, path, istate->repo->hash_algo); +} + void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot) { diff --git a/clean-status-index.h b/clean-status-index.h index ddc177cef5436e..4cb60df0dedd40 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -71,12 +71,18 @@ int clean_status_index_snapshot_pin( int clean_status_index_snapshot_pin_proof_epoch( struct clean_status_index_snapshot *snapshot, struct index_state *istate); +int clean_status_index_snapshot_pin_path_proof_epoch( + struct clean_status_index_snapshot *snapshot, + struct index_state *istate, const char *path); 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); +int clean_status_index_snapshot_still_matches_path_proof_epoch( + const struct clean_status_index_snapshot *snapshot, + const struct index_state *istate, const char *path); void clean_status_index_snapshot_release( struct clean_status_index_snapshot *snapshot); int clean_status_index_entries_are_certifiable( diff --git a/clean-status.c b/clean-status.c index 5c38011c9271bb..4772e870a1010d 100644 --- a/clean-status.c +++ b/clean-status.c @@ -832,6 +832,16 @@ int clean_status_worktree_manifest_needs_refresh( state->manifest.current_invalidated; } +int clean_status_changed_worktree_manifest_has_filters( + const struct index_state *istate) +{ + const struct clean_status_state *state = istate->clean_status; + + return state && state->config_enforced && state->filter_configured && + state->manifest.current_valid && state->manifest.checked && + state->manifest.changed; +} + void clean_status_invalidate_current_manifest(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index d871117b95bf0a..dab6c824abec58 100644 --- a/clean-status.h +++ b/clean-status.h @@ -46,6 +46,10 @@ 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_proof_epoch *clean_status_capture_proof_epoch_at_path( + struct index_state *istate, + const struct attr_source_snapshot *attrs, + int validate_filter_scope, const char *index_path); int clean_status_proof_epoch_start_token_matches( struct index_state *istate, const struct clean_status_proof_epoch *epoch); @@ -96,6 +100,8 @@ int clean_status_has_authenticated_bootstrap_manifest( const struct index_state *istate); int clean_status_worktree_manifest_needs_refresh( const struct index_state *istate); +int clean_status_changed_worktree_manifest_has_filters( + 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 4b8a52939ee227..1725b06d34363f 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -1795,6 +1795,24 @@ void refresh_fsmonitor(struct index_state *istate) } } +void fsmonitor_refresh_after_worktree_update(struct index_state *istate) +{ + if (!istate->fsmonitor_has_run_once || + fsm_settings__get_mode(istate->repo) != FSMONITOR_MODE_IPC || + !istate->fsmonitor_token_valid || !istate->fsmonitor_last_update) + return; + + /* + * refresh_fsmonitor() is normally once-per-process. An owned checkout + * performed after that query creates a new event interval, so consume it + * before a writer closes and persists the repaired proof. + */ + istate->fsmonitor_has_run_once = 0; + refresh_fsmonitor(istate); + trace2_data_intmax("fsmonitor", istate->repo, + "history/post-worktree-refresh", 1); +} + int fsmonitor_has_pending_token(const struct index_state *istate) { return !!istate->fsmonitor_last_update_pending; diff --git a/fsmonitor.h b/fsmonitor.h index 6d5f3d3bc34c29..ce2e987a64ed12 100644 --- a/fsmonitor.h +++ b/fsmonitor.h @@ -66,6 +66,9 @@ static inline int fsmonitor_stat_can_be_valid(const struct stat *st) void fsmonitor_invalidate_semantics(struct index_state *istate); +/* Query changes created after an owned worktree update in this process. */ +void fsmonitor_refresh_after_worktree_update(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); diff --git a/merge.c b/merge.c index ac37e84ad87465..9de38c739a00a9 100644 --- a/merge.c +++ b/merge.c @@ -5,6 +5,7 @@ #include "clean-status.h" #include "hash.h" #include "hex.h" +#include "fsmonitor.h" #include "lockfile.h" #include "merge.h" #include "commit.h" @@ -14,6 +15,7 @@ #include "tree.h" #include "tree-walk.h" #include "unpack-trees.h" +#include "wt-status.h" static const char *merge_argument(struct commit *commit) { @@ -58,10 +60,17 @@ int checkout_fast_forward(struct repository *r, struct tree *trees[MAX_UNPACK_TREES]; struct unpack_trees_options opts; struct tree_desc t[MAX_UNPACK_TREES]; - int i, nr_trees = 0; + int i, nr_trees = 0, repair_after_checkout; struct lock_file lock_file = LOCK_INIT; refresh_index(r->index, REFRESH_QUIET, NULL, NULL, NULL); + repair_after_checkout = + clean_status_has_current_full_fsmonitor_proof(r->index); + if (!repair_after_checkout && + wt_status_fsmonitor_proof_needs_repair(r) && + wt_status_repair_fsmonitor_proof(r)) + repair_after_checkout = + clean_status_has_current_full_fsmonitor_proof(r->index); if (repo_hold_locked_index(r, &lock_file, LOCK_REPORT_ON_ERROR) < 0) return -1; @@ -99,6 +108,7 @@ int checkout_fast_forward(struct repository *r, opts.merge = 1; opts.preserve_semantic_history = clean_status_revalidated_token_matches(r->index); + opts.preserve_untracked_history = opts.preserve_semantic_history; opts.fn = twoway_merge; init_checkout_metadata(&opts.meta, NULL, remote, NULL); setup_unpack_trees_porcelain(&opts, "merge"); @@ -109,6 +119,11 @@ int checkout_fast_forward(struct repository *r, return -1; } clear_unpack_trees_porcelain(&opts); + if (wt_status_repair_fsmonitor_proof_after_worktree_update( + r, &lock_file, repair_after_checkout) < 0) { + rollback_lock_file(&lock_file); + return error(_("unable to repair new index file")); + } if (write_locked_index(r->index, &lock_file, COMMIT_LOCK)) return error(_("unable to write new index file")); diff --git a/read-cache-ll.h b/read-cache-ll.h index da72f6e2fc4b6a..62616b02be58d3 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -340,6 +340,7 @@ int is_index_unborn(struct index_state *); /* For use with `write_locked_index()`. */ #define COMMIT_LOCK (1 << 0) #define SKIP_IF_UNCHANGED (1 << 1) +#define PROVISIONAL_LOCK (1 << 2) /* * Write the index while holding an already-taken lock. Close the lock, @@ -359,6 +360,8 @@ int is_index_unborn(struct index_state *); * * If `SKIP_IF_UNCHANGED` is given and the index is unchanged, nothing * is written (and the lock is rolled back if `COMMIT_LOCK` is given). + * `PROVISIONAL_LOCK` writes a close-only witness which the lock owner will + * reopen and replace before commit; it therefore defers post-index-change. */ int write_locked_index(struct index_state *, struct lock_file *lock, unsigned flags); diff --git a/read-cache.c b/read-cache.c index 6c1270306438ba..d10a9d66288f02 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3931,11 +3931,13 @@ static int do_write_locked_index( 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", - istate->updated_skipworktree ? "1" : "0", NULL); - istate->updated_workdir = 0; - istate->updated_skipworktree = 0; + if (!(flags & PROVISIONAL_LOCK)) { + run_hooks_l(the_repository, "post-index-change", + istate->updated_workdir ? "1" : "0", + istate->updated_skipworktree ? "1" : "0", NULL); + istate->updated_workdir = 0; + istate->updated_skipworktree = 0; + } return ret; } diff --git a/reset.c b/reset.c index 6d284f80c622ef..e26a2a76da8b1b 100644 --- a/reset.c +++ b/reset.c @@ -11,6 +11,7 @@ #include "tree.h" #include "unpack-trees.h" #include "hook.h" +#include "wt-status.h" static int update_refs(struct repository *repo, const struct reset_working_tree_options *opts, @@ -104,7 +105,7 @@ int reset_working_tree(struct repository *r, struct index_state scratch_index = INDEX_STATE_INIT(r); struct index_state *istate; const char *action; - int ret = 0, nr = 0; + int ret = 0, nr = 0, repair_after_reset = 0; if (switch_to_branch && !starts_with(switch_to_branch, "refs/")) BUG("Not a fully qualified branch: '%s'", switch_to_branch); @@ -171,7 +172,11 @@ int reset_working_tree(struct repository *r, !dry_run && (!reset_hard || (opts->flags & RESET_WORKING_TREE_PRESERVE_SEMANTIC_HISTORY)) && - clean_status_revalidated_token_matches(istate); + clean_status_revalidated_token_matches(istate); + unpack_tree_opts.preserve_untracked_history = + unpack_tree_opts.preserve_semantic_history; + repair_after_reset = unpack_tree_opts.update && + clean_status_has_current_full_fsmonitor_proof(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) { @@ -206,6 +211,12 @@ int reset_working_tree(struct repository *r, if (reset_hard) prime_cache_tree(r, r->index, tree); + if (unpack_tree_opts.update && + wt_status_repair_fsmonitor_proof_after_worktree_update( + r, &lock, repair_after_reset) < 0) { + ret = error(_("could not repair index")); + goto leave_reset_head; + } if (write_locked_index(r->index, &lock, COMMIT_LOCK) < 0) { ret = error(_("could not write index")); diff --git a/sequencer.c b/sequencer.c index 0751ae0f5bf7e2..7d119ebb779569 100644 --- a/sequencer.c +++ b/sequencer.c @@ -22,6 +22,7 @@ #include "hook.h" #include "utf8.h" #include "cache-tree.h" +#include "clean-status.h" #include "diff.h" #include "path.h" #include "revision.h" @@ -752,7 +753,7 @@ static int do_recursive_merge(struct repository *r, struct merge_options o; struct merge_result result; struct tree *next_tree, *base_tree, *head_tree; - int clean, show_output; + int clean, show_output, repair_after_merge; int i; struct lock_file index_lock = LOCK_INIT; @@ -760,6 +761,8 @@ static int do_recursive_merge(struct repository *r, return -1; repo_read_index(r); + repair_after_merge = + clean_status_has_current_full_fsmonitor_proof(r->index); init_ui_merge_options(&o, r); o.ancestor = base ? base_label : "(empty tree)"; @@ -795,6 +798,12 @@ static int do_recursive_merge(struct repository *r, rollback_lock_file(&index_lock); return clean; } + if (wt_status_repair_fsmonitor_proof_after_worktree_update( + r, &index_lock, repair_after_merge) < 0) { + rollback_lock_file(&index_lock); + return error(_("%s: Unable to repair new index file"), + _(action_name(opts))); + } if (write_locked_index(r->index, &index_lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index d0d9297fc03c89..e4737716e141d1 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1377,7 +1377,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'index writers report missing authenticated untracked proofs' ' + 'index writers preserve authenticated untracked proofs' ' test_when_finished "rm -rf missing-untracked-proof" && test_create_repo missing-untracked-proof && ( @@ -1407,19 +1407,24 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 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 \ + ! 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 && + test_grep FSUC .git/index && + test_fsmonitor_full_proof .git/index paired && + cp .git/index .git/reset.index && + GIT_OPTIONAL_LOCKS=0 \ 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 && - test_must_be_empty .git/repair && - test_grep FSMN .git/index && - test_grep FSUC .git/index && + GIT_TRACE2_EVENT="$PWD/.git/reset-status.trace" \ + git status --porcelain=v2 >.git/reset-status && + test_must_be_empty .git/reset-status && + test_cmp_bin .git/reset.index .git/index && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/reset-status.trace && + ! test_region index do_write_index .git/reset-status.trace && cp .git/index .git/alternate.index && GIT_INDEX_FILE="$PWD/.git/alternate.index" \ @@ -1443,11 +1448,12 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 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 \ + ! 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_grep FSUC .git/index && + test_fsmonitor_full_proof .git/index paired ) ' @@ -2875,7 +2881,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 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 pull-proof-filter" && + 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 pull-proof-delete pull-proof-delete-linked pull-proof-filter" && git init --bare pull-proof-origin.git && test_create_repo pull-proof-seed && ( @@ -2938,7 +2944,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ;; linked) worktree="$linked" && - invalidated=194 + invalidated=196 ;; esac && if test "$mode" != ff @@ -2972,11 +2978,10 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ return 1 done && test_write_lines "# $mode-$role" \ - >.gitattributes && + >"upstream-$mode-$role/nested/.gitattributes" && test_write_lines "*.ignored" "# $mode-$role" \ - >.gitignore && - git add "upstream-$mode-$role" \ - .gitattributes .gitignore + >"upstream-$mode-$role/nested/.gitignore" && + git add "upstream-$mode-$role" else test_write_lines "$mode-$role" \ >"upstream-$mode-$role" && @@ -3031,27 +3036,100 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ fi && perl "$PWD/.git/check-pull-proof.pl" \ <"$gitdir/index" && - cp "$gitdir/index" "$gitdir/readonly.index" && + for pass in first second + do + cp "$gitdir/index" \ + "$gitdir/readonly-$pass.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/status-$pass.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status-$pass" && + if test "$mode" = autostash + then + test_grep "^1 \\.M .* tracked$" \ + "$gitdir/status-$pass" || return 1 + else + test_must_be_empty \ + "$gitdir/status-$pass" || return 1 + fi && + test_cmp_bin "$gitdir/readonly-$pass.index" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/status-$pass.trace" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/status-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/status-$pass.trace" || return 1 + done || return 1 + done || return 1 + done && + git clone --quiet "$PWD/../pull-proof-origin.git" \ + "$PWD/../pull-proof-delete" && + delete_repo="$PWD/../pull-proof-delete" && + delete_linked="$PWD/../pull-proof-delete-linked" && + git -C "$delete_repo" worktree add --quiet -b linked-delete \ + "$delete_linked" origin/main && + git -C "$delete_linked" branch --quiet \ + --set-upstream-to=origin/main && + git -C "$delete_repo" config pull.ff only && + git -C "$delete_repo" config core.untrackedCache true && + git -C "$delete_repo" config core.fsmonitor true && + for worktree in "$delete_repo" "$delete_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-pull-proof.pl" \ + <"$gitdir/index" || return 1 + done && + test_write_lines "# root attributes" >.gitattributes && + test_write_lines "# root ignore" >.gitignore && + git rm --quiet \ + upstream-ff-main/nested/.gitattributes \ + upstream-ff-main/nested/.gitignore && + git add .gitattributes .gitignore && + git commit -qm "change policy sources" && + git push --quiet origin main && + for worktree in "$delete_repo" "$delete_linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ + GIT_TRACE2_EVENT="$gitdir/pull.trace" \ + git -C "$worktree" pull --quiet && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/pull.trace" && + perl "$PWD/.git/check-pull-proof.pl" \ + <"$gitdir/index" && + for pass in first second + do + cp "$gitdir/index" \ + "$gitdir/delete-$pass.index" && GIT_OPTIONAL_LOCKS=0 \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ - GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ - GIT_TRACE2_EVENT="$gitdir/status.trace" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/delete-$pass.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/delete-$pass" && + test_must_be_empty "$gitdir/delete-$pass" && + test_cmp_bin "$gitdir/delete-$pass.index" \ "$gitdir/index" && test_trace2_data fsmonitor config/coherent 1 \ - <"$gitdir/status.trace" && + <"$gitdir/delete-$pass.trace" && ! test_trace2_data fsmonitor \ semantic/manifest-scan-count 1 \ - <"$gitdir/status.trace" || return 1 + <"$gitdir/delete-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/delete-$pass.trace" || return 1 done || return 1 done && git clone --quiet "$PWD/../pull-proof-origin.git" \ @@ -3101,6 +3179,348 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'provider restarts preserve authenticated pull proofs' ' + test_when_finished "rm -rf daemon-pull-origin.git daemon-pull-seed daemon-pull-fast daemon-pull daemon-pull-linked daemon-pull-root daemon-pull-root-linked daemon-pull-filter" && + test_when_finished \ + "git -C daemon-pull-fast fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-pull fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-pull-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-pull-root fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-pull-root-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-pull-filter fsmonitor--daemon stop 2>/dev/null || :" && + git init --bare daemon-pull-origin.git && + test_create_repo daemon-pull-seed && + ( + cd daemon-pull-seed && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir stable && + mkdir -p policy-main/nested policy-linked/nested && + for file in $(test_seq 1 128) + do + test_write_lines "stable-$file" >"stable/$file" || + return 1 + done && + test_write_lines "# base main" \ + >policy-main/nested/.gitattributes && + test_write_lines "# base main" \ + >policy-main/nested/.gitignore && + test_write_lines "# base linked" \ + >policy-linked/nested/.gitattributes && + test_write_lines "# base linked" \ + >policy-linked/nested/.gitignore && + git add stable policy-main policy-linked && + test_commit base tracked && + git branch -M main && + git remote add origin "$PWD/../daemon-pull-origin.git" && + git push --quiet -u origin main && + git --git-dir="$PWD/../daemon-pull-origin.git" \ + symbolic-ref HEAD refs/heads/main && + git clone --quiet "$PWD/../daemon-pull-origin.git" \ + "$PWD/../daemon-pull-fast" && + fast="$PWD/../daemon-pull-fast" && + fast_gitdir=$(git -C "$fast" rev-parse --absolute-git-dir) && + git -C "$fast" config pull.ff only && + git -C "$fast" config core.untrackedCache true && + git -C "$fast" config core.fsmonitor true && + git -C "$fast" fsmonitor--daemon start --start-timeout=10 && + git -C "$fast" update-index --fsmonitor && + GIT_INDEX_FILE="$fast_gitdir/index" \ + git -C "$fast" status --porcelain=v2 \ + >"$fast_gitdir/prime" && + test_must_be_empty "$fast_gitdir/prime" && + test_write_lines normal >normal-fast-path && + git add normal-fast-path && + git commit -qm "normal fast path" && + git push --quiet origin main && + GIT_TRACE2_EVENT="$fast_gitdir/pull.trace" \ + git -C "$fast" pull --quiet && + test_trace2_data fsmonitor history/post-worktree-refresh 1 \ + <"$fast_gitdir/pull.trace" && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$fast_gitdir/pull.trace" && + test_trace2_data index refresh/sum_lstat 1 \ + <"$fast_gitdir/pull.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count 1 \ + <"$fast_gitdir/pull.trace" && + test_fsmonitor_full_proof "$fast_gitdir/index" paired && + git -C "$fast" fsmonitor--daemon stop && + git clone --quiet "$PWD/../daemon-pull-origin.git" \ + "$PWD/../daemon-pull" && + repo="$PWD/../daemon-pull" && + linked="$PWD/../daemon-pull-linked" && + git -C "$repo" worktree add --quiet -b daemon-linked \ + "$linked" origin/main && + git -C "$linked" branch --quiet \ + --set-upstream-to=origin/main && + git -C "$repo" config pull.ff only && + git -C "$repo" config core.untrackedCache true && + git -C "$repo" config core.fsmonitor true && + write_script "$repo/.git/hooks/post-index-change" <<-\EOF && + gitdir=$(git rev-parse --absolute-git-dir) || exit 1 + test ! -f "$gitdir/index.lock" || exit 1 + test -f "$gitdir/index" || exit 1 + printf "%s %s\n" "$1" "$2" >>"$gitdir/post-index-change.log" + EOF + for role in main linked + do + if test "$role" = main + then + worktree="$repo" && + affected=98 && + total=230 + else + worktree="$linked" && + affected=196 && + total=326 + fi && + 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_fsmonitor_full_proof "$gitdir/index" paired && + git -C "$worktree" fsmonitor--daemon stop && + git -C "$worktree" fsmonitor--daemon start \ + --start-timeout=10 && + rm -f "$gitdir/post-index-change.log" && + mkdir -p "policy-$role/nested/new" && + for file in $(test_seq 1 96) + do + test_write_lines "$role-$file" \ + >"policy-$role/nested/new/$file" || return 1 + done && + if test "$role" = main + then + test_write_lines "# changed main" \ + >policy-main/nested/.gitattributes && + test_write_lines "*.ignored" "# changed main" \ + >policy-main/nested/.gitignore + else + test_write_lines "* text" \ + >policy-linked/nested/.gitattributes && + test_write_lines "*.ignored" "!keep.ignored" \ + >policy-linked/nested/.gitignore + fi && + git add "policy-$role" && + git commit -qm "upstream-$role" && + git push --quiet origin main && + GIT_TRACE2_EVENT="$gitdir/pull.trace" \ + git -C "$worktree" \ + -c protocol.version=2 \ + -c fetch.uriprotocols=https \ + -c http.https://example.invalid.extraHeader=header \ + -c http.https://example.invalid.proactiveAuth=basic \ + -c http.https://example.invalid.sslVerify=true \ + pull --quiet >"$gitdir/pull" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/pull.trace" && + test_trace2_data index refresh/sum_lstat "$affected" \ + <"$gitdir/pull.trace" && + ! test_trace2_data index refresh/sum_lstat "$total" \ + <"$gitdir/pull.trace" && + test_write_lines "1 0" \ + >"$gitdir/post-index-change.expect" && + test_cmp "$gitdir/post-index-change.expect" \ + "$gitdir/post-index-change.log" && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/pull.trace" && + ! test_trace2_data fsmonitor history/writer-proof-repaired 0 \ + <"$gitdir/pull.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + 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_must_be_empty "$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_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/readonly-$pass.trace" && + ! test_trace2_data read_directory \ + directories-visited "[1-9][0-9]*" \ + <"$gitdir/readonly-$pass.trace" && + ! test_trace2_data read_directory paths-visited \ + "[1-9][0-9]*" \ + <"$gitdir/readonly-$pass.trace" && + ! test_trace2_data read_directory opendir \ + "[1-9][0-9]*" \ + <"$gitdir/readonly-$pass.trace" && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" \ + <"$gitdir/readonly-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/readonly-$pass.trace" || return 1 + done && + git -C "$worktree" fsmonitor--daemon stop || return 1 + done && + mkdir -p root-tree && + for file in $(test_seq 1 96) + do + mkdir -p "root-tree/$file/nested" && + test_write_lines "root-$file" \ + >"root-tree/$file/nested/tracked.txt" || return 1 + done && + test_write_lines "*.ignored" "# root ignore base" \ + >.gitignore && + test_write_lines "*.txt text" "# root attributes base" \ + >.gitattributes && + git add root-tree .gitignore .gitattributes && + git commit -qm "root policy base" && + git push --quiet origin main && + git clone --quiet "$PWD/../daemon-pull-origin.git" \ + "$PWD/../daemon-pull-root" && + root_repo="$PWD/../daemon-pull-root" && + root_linked="$PWD/../daemon-pull-root-linked" && + git -C "$root_repo" worktree add --quiet -b daemon-root-linked \ + "$root_linked" origin/main && + git -C "$root_linked" branch --quiet \ + --set-upstream-to=origin/main && + git -C "$root_repo" config pull.ff only && + git -C "$root_repo" config core.untrackedCache true && + git -C "$root_repo" config core.fsmonitor true && + for worktree in "$root_repo" "$root_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/root-prime" && + test_must_be_empty "$gitdir/root-prime" && + test_fsmonitor_full_proof "$gitdir/index" paired || return 1 + done && + for role in main linked + do + if test "$role" = main + then + worktree="$root_repo" && + policy_dir=1 + else + worktree="$root_linked" && + policy_dir=2 + fi && + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + test_write_lines "*.ignored" \ + "# root ignore $role" >.gitignore && + test_write_lines "*.txt text" \ + "# root attributes $role" >.gitattributes && + test_write_lines "*.ignored" \ + "# nested ignore $role" \ + >"root-tree/$policy_dir/nested/.gitignore" && + test_write_lines "*.txt text" \ + "# nested attributes $role" \ + >"root-tree/$policy_dir/nested/.gitattributes" && + git add .gitignore .gitattributes \ + "root-tree/$policy_dir/nested/.gitignore" \ + "root-tree/$policy_dir/nested/.gitattributes" && + git commit -qm "root policy $role" && + git push --quiet origin main && + GIT_TRACE2_EVENT="$gitdir/root-pull.trace" \ + git -C "$worktree" pull --quiet && + test_trace2_data fsmonitor \ + checkout/untracked-policy-targeted 1 \ + <"$gitdir/root-pull.trace" && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/root-pull.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false ls-files --debug -- \ + .gitattributes .gitignore \ + "root-tree/$policy_dir/nested/.gitattributes" \ + "root-tree/$policy_dir/nested/.gitignore" \ + >"$gitdir/root-policy-stat" && + test_grep ! "ctime: 0:0" "$gitdir/root-policy-stat" && + test_grep ! "mtime: 0:0" "$gitdir/root-policy-stat" && + test_grep ! "size: 0" "$gitdir/root-policy-stat" && + for pass in first second + do + cp "$gitdir/index" "$gitdir/root-$pass.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/root-$pass.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/root-$pass" && + test_must_be_empty "$gitdir/root-$pass" && + test_cmp_bin "$gitdir/root-$pass.index" \ + "$gitdir/index" && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 \ + <"$gitdir/root-$pass.trace" && + ! test_trace2_data read_directory directories-visited \ + "[1-9][0-9]*" <"$gitdir/root-$pass.trace" && + ! test_trace2_data read_directory opendir \ + "[1-9][0-9]*" <"$gitdir/root-$pass.trace" && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" <"$gitdir/root-$pass.trace" && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <"$gitdir/root-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/root-$pass.trace" || return 1 + done && + git -C "$worktree" fsmonitor--daemon stop || return 1 + done && + git clone --quiet "$PWD/../daemon-pull-origin.git" \ + "$PWD/../daemon-pull-filter" && + filter="$PWD/../daemon-pull-filter" && + filter_gitdir=$(git -C "$filter" rev-parse --absolute-git-dir) && + git -C "$filter" config pull.ff only && + git -C "$filter" config core.untrackedCache true && + git -C "$filter" config core.fsmonitor true && + git -C "$filter" config filter.daemonpull.clean false && + git -C "$filter" config filter.daemonpull.required true && + git -C "$filter" fsmonitor--daemon start --start-timeout=10 && + git -C "$filter" update-index --fsmonitor && + GIT_INDEX_FILE="$filter_gitdir/index" \ + git -C "$filter" status --porcelain=v2 \ + >"$filter_gitdir/prime" && + test_must_be_empty "$filter_gitdir/prime" && + git -C "$filter" fsmonitor--daemon stop && + git -C "$filter" fsmonitor--daemon start --start-timeout=10 && + test_write_lines "tracked filter=daemonpull" >.gitattributes && + git add .gitattributes && + git commit -qm "require unavailable filter" && + git push --quiet origin main && + GIT_TRACE2_EVENT="$filter_gitdir/pull.trace" \ + git -C "$filter" pull --quiet && + test_trace2_data fsmonitor semantic/manifest-invalidated 1 \ + <"$filter_gitdir/pull.trace" && + ! test_trace2_data fsmonitor history/post-worktree-refresh 1 \ + <"$filter_gitdir/pull.trace" && + cp "$filter_gitdir/index" "$filter_gitdir/status.before" && + test_must_fail env GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$filter_gitdir/status.trace" \ + git -C "$filter" status --porcelain=v2 \ + --untracked-files=no -- tracked \ + >"$filter_gitdir/status" \ + 2>"$filter_gitdir/status.err" && + test_grep "clean filter .daemonpull. failed" \ + "$filter_gitdir/status.err" && + test_cmp_bin "$filter_gitdir/status.before" \ + "$filter_gitdir/index" && + git -C "$filter" fsmonitor--daemon stop + ) +' + 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" && @@ -3181,6 +3601,262 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'routed push and owned writers preserve authenticated clean proofs' ' + test_when_finished "rm -rf daemon-writers daemon-writers-linked" && + test_when_finished \ + "git -C daemon-writers fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C daemon-writers-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo daemon-writers && + ( + cd daemon-writers && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir stable && + for file in $(test_seq 1 16) + do + test_write_lines "stable-$file" >"stable/$file" || + return 1 + done && + test_write_lines base >stable/anchor && + git add stable && + git commit -qm base && + base=$(git rev-parse HEAD) && + git checkout -q -b upstream && + for file in $(test_seq 1 16) + do + mkdir -p "incoming/package-$file/nested" && + test_write_lines "incoming-$file" \ + >"incoming/package-$file/nested/tracked" || + return 1 + done && + git add incoming && + git commit -qm upstream && + git checkout -q -b owned-main "$base" && + test_write_lines base topic >stable/anchor && + git add stable/anchor && + git commit -qm topic && + git branch owned-linked && + git worktree add -q ../daemon-writers-linked owned-linked && + repo=$PWD && + linked=$PWD/../daemon-writers-linked && + 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 && + + assert_no_full_worktree_scan () { + trace=$1 && + ! test_trace2_data fsmonitor \ + semantic/manifest-scan-count 1 <"$trace" && + ! test_trace2_data read_directory directories-visited \ + "[1-9][0-9]*" <"$trace" && + ! test_trace2_data read_directory opendir \ + "[1-9][0-9]*" <"$trace" && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" <"$trace" && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <"$trace" && + ! test_region index do_write_index "$trace" + } && + + assert_clean_status_fast () { + trace=$1 && + { + test_trace2_data status clean-proof/hit 1 \ + <"$trace" || + test_trace2_data fsmonitor config/coherent 1 \ + <"$trace" + } + } && + + assert_owned_writer_clean () { + worktree=$1 && + label=$2 && + shift 2 && + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + test_fsmonitor_full_proof "$gitdir/index" paired && + git --no-optional-locks -C "$worktree" \ + -c core.fsmonitor=false ls-files --debug -- "$@" \ + >"$gitdir/$label-stat" && + test_grep ! "ctime: 0:0" "$gitdir/$label-stat" && + test_grep ! "mtime: 0:0" "$gitdir/$label-stat" && + test_grep ! "size: 0" "$gitdir/$label-stat" && + for pass in first second + do + cp "$gitdir/index" \ + "$gitdir/$label-$pass.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/$label-$pass.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$label-$pass" && + test_must_be_empty "$gitdir/$label-$pass" && + test_cmp_bin "$gitdir/$label-$pass.index" \ + "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$label-$pass.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/$label-$pass.trace" && + assert_no_full_worktree_scan \ + "$gitdir/$label-$pass.trace" || return 1 + done + } && + + assert_routed_push_fast () { + worktree=$1 && + label=$2 && + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + for route in https ssh + do + case "$route" in + https) + pushurl_key=remote.https.pushurl && + pushurl=https://example.invalid/repository.git + ;; + ssh) + pushurl_key=remote.origin.pushurl && + pushurl=ssh://git@example.invalid/repository.git + ;; + esac && + for pass in first second + do + prefix="$gitdir/$label-$route-$pass" && + cp "$gitdir/index" "$prefix.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$prefix.diff.trace" \ + git -C "$worktree" \ + -c push.negotiate=true \ + -c "$pushurl_key=$pushurl" \ + diff --quiet --no-ext-diff \ + --no-textconv --ignore-submodules && + test_cmp_bin "$prefix.index" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$prefix.diff.trace" && + assert_no_full_worktree_scan \ + "$prefix.diff.trace" && + GIT_TRACE2_EVENT="$prefix.write-tree.trace" \ + git -C "$worktree" \ + -c push.negotiate=true \ + -c "$pushurl_key=$pushurl" \ + write-tree >"$prefix.tree" && + test_file_not_empty "$prefix.tree" && + test_cmp_bin "$prefix.index" "$gitdir/index" && + assert_no_full_worktree_scan \ + "$prefix.write-tree.trace" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$prefix.status.trace" \ + git -C "$worktree" \ + -c push.negotiate=true \ + -c "$pushurl_key=$pushurl" \ + status --porcelain=v2 \ + >"$prefix.status" && + test_must_be_empty "$prefix.status" && + test_cmp_bin "$prefix.index" "$gitdir/index" && + assert_clean_status_fast \ + "$prefix.status.trace" && + assert_no_full_worktree_scan \ + "$prefix.status.trace" || return 1 + done && + prefix="$gitdir/$label-$route-writable" && + cp "$gitdir/index" "$prefix.index" && + GIT_TRACE2_EVENT="$prefix.status.trace" \ + git -C "$worktree" \ + -c push.negotiate=true \ + -c "$pushurl_key=$pushurl" \ + status --porcelain=v2 >"$prefix.status" && + test_must_be_empty "$prefix.status" && + assert_clean_status_fast \ + "$prefix.status.trace" && + assert_no_full_worktree_scan \ + "$prefix.status.trace" && + GIT_TRACE2_EVENT="$prefix.tracked.trace" \ + git -C "$worktree" status --porcelain=v2 \ + --untracked-files=no >"$prefix.tracked" && + test_must_be_empty "$prefix.tracked" && + test_cmp_bin "$prefix.index" "$gitdir/index" && + assert_clean_status_fast \ + "$prefix.tracked.trace" && + assert_no_full_worktree_scan \ + "$prefix.tracked.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired || + return 1 + done + } && + + for worktree in "$repo" "$linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + test-tool chmtime =-60 "$worktree"/stable/* && + git -C "$worktree" -c core.fsmonitor=false \ + update-index --refresh && + 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_fsmonitor_full_proof "$gitdir/index" paired || + return 1 + done && + assert_routed_push_fast "$repo" main && + assert_routed_push_fast "$linked" linked && + + for worktree in "$repo" "$linked" + do + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + GIT_TRACE2_EVENT="$gitdir/rebase.trace" \ + git -C "$worktree" rebase upstream && + test_trace2_data fsmonitor \ + history/writer-proof-repaired 1 \ + <"$gitdir/rebase.trace" && + ! test_trace2_data fsmonitor \ + history/writer-proof-repaired 0 \ + <"$gitdir/rebase.trace" && + assert_owned_writer_clean "$worktree" rebase incoming || + return 1 + done && + + rebased=$(git rev-parse HEAD) && + gitdir=$(git rev-parse --absolute-git-dir) && + GIT_TRACE2_EVENT="$gitdir/reset.trace" \ + git reset --hard -q upstream && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/reset.trace" && + assert_owned_writer_clean "$repo" reset incoming && + GIT_TRACE2_EVENT="$gitdir/reset-back.trace" \ + git reset --hard -q "$rebased" && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/reset-back.trace" && + assert_owned_writer_clean "$repo" reset-back incoming && + + test_write_lines base topic stashed >stable/anchor && + GIT_TRACE2_EVENT="$gitdir/stash.trace" \ + git stash push -qm writer-proof && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/stash.trace" && + assert_owned_writer_clean "$repo" stash incoming && + + GIT_TRACE2_EVENT="$gitdir/checkout-upstream.trace" \ + git checkout -q upstream && + assert_owned_writer_clean "$repo" checkout-upstream incoming && + GIT_TRACE2_EVENT="$gitdir/checkout-topic.trace" \ + git checkout -q owned-main && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/checkout-topic.trace" && + assert_owned_writer_clean "$repo" checkout-topic incoming && + + git -C "$repo" fsmonitor--daemon stop && + git -C "$linked" fsmonitor--daemon stop + ) +' + 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" && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 80dac65081a508..5bdcb73ac7e581 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -4948,7 +4948,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'dirty stash push drops closed semantic history' ' + 'dirty stash push preserves closed semantic history' ' test_when_finished "rm -rf stash-dirty-history" && test_create_repo stash-dirty-history && ( @@ -4970,17 +4970,19 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ git stash push >.git/stash && test_grep "Saved working directory" .git/stash && + test_grep FSUC .git/index && + test_grep FSCF .git/index && 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 0 \ + test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace ) ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ - 'dirty stash cannot resurrect an invalidated external checkpoint' ' + 'dirty stash preserves current proof without restoring a checkpoint' ' test_when_finished "rm -rf stash-checkpoint-history" && test_create_repo stash-checkpoint-history && ( @@ -5008,13 +5010,13 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,!MINGW \ GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ git stash push >.git/stash && test_grep "Saved working directory" .git/stash && + test_grep FSUC .git/index && + test_grep FSCF .git/index && 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 \ + test_trace2_data fsmonitor config/coherent 1 \ <.git/status.trace && ! test_trace2_data fsmonitor history/external-restored 1 \ <.git/status.trace @@ -6405,7 +6407,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'hard reset to a different tree drops closed semantic history' ' + 'hard reset to a different tree preserves closed semantic history' ' test_when_finished "rm -rf reset-hard-changed" && test_create_repo reset-hard-changed && ( @@ -6431,8 +6433,10 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 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 0 \ - <.git/status.trace + test_trace2_data fsmonitor config/coherent 1 \ + <.git/status.trace && + test_grep FSCF .git/index && + test_grep FSUC .git/index ) ' diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index 51ebf8785bc907..ae3984cf084419 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -72,14 +72,23 @@ void test_clean_status_config__command_transport_config_does_not_change_proof(vo static const char *const ignored_keys[] = { "protocol.version", "fetch.uriprotocols", + "push.negotiate", "http.https://Example.Invalid.extraheader", "http.https://Example.Invalid.proactiveauth", "http.https://Example.Invalid.sslverify", "credential.helper", "credential.https://Example/Team.helper", + "remote.origin.pushurl", + "remote.MixedCase.pushurl", "url.https://Proxy.Example/Team/.insteadof", "url.https://Proxy.Example/Team/.pushinsteadof", }; + static const char *const retained_command_keys[] = { + "push.default", + "remote.origin.url", + "remote.pushurl", + "remote.origin.fetch", + }; static const enum config_scope persistent_scopes[] = { CONFIG_SCOPE_GLOBAL, CONFIG_SCOPE_LOCAL, @@ -112,6 +121,11 @@ void test_clean_status_config__command_transport_config_does_not_change_proof(vo digest_one(&digest, ignored_keys[i], "transport", NULL); cl_assert(!hashes_equal(digest.hash, baseline.hash)); } + + for (size_t i = 0; i < ARRAY_SIZE(retained_command_keys); i++) { + digest_one(&digest, retained_command_keys[i], "transport", &ctx); + cl_assert(!hashes_equal(digest.hash, baseline.hash)); + } } void test_clean_status_config__command_preload_config_does_not_change_proof(void) diff --git a/unpack-trees.c b/unpack-trees.c index 72847c7d8f6d6f..9adee9f640ba72 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -1915,7 +1915,7 @@ static int checkout_introduces_new_indexed_directory( } static unsigned int checkout_invalidate_new_index_entries( - struct index_state *source, struct index_state *result) + struct index_state *source, struct index_state *result, int safe_path) { unsigned int invalidated = 0, source_pos = 0; @@ -1952,7 +1952,7 @@ static unsigned int checkout_invalidate_new_index_entries( * are built. Replay additions now so a newly tracked directory is * represented by invalid cache nodes rather than dropping FSUC. */ - untracked_cache_invalidate_path(result, entry->name, 0); + untracked_cache_invalidate_path(result, entry->name, safe_path); invalidated++; } return invalidated; @@ -2167,6 +2167,9 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options int history_transferred = 0; int manifest_refresh_required = 0; int new_indexed_directory = 0; + int source_untracked_fully_valid = o->src_index->untracked && + o->src_index->untracked->root && + o->src_index->untracked->root->valid_recursive; if (!ret) { history_transferred = @@ -2249,11 +2252,13 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options "history/untracked-paired-new-directory-invalidated", checkout_invalidate_new_index_entries( o->src_index, - &o->internal.result)); + &o->internal.result, + !source_untracked_fully_valid)); } else if (new_indexed_directory) { trace2_data_intmax( "fsmonitor", repo, - "history/untracked-paired-new-directory-deferred", 1); + "history/untracked-paired-new-directory-deferred", + 1); } if (!ret) { if (git_env_bool("GIT_TEST_CHECK_CACHE_TREE", 0) && @@ -2488,6 +2493,30 @@ static void invalidate_ce_path(const struct cache_entry *ce, untracked_cache_invalidate_path(o->src_index, ce->name, 1); } +static void invalidate_added_ce_path(const struct cache_entry *ce, + struct unpack_trees_options *o) +{ + const char *basename; + int policy_file; + + if (!ce) + return; + cache_tree_invalidate_path(o->src_index, ce->name); + basename = find_last_dir_sep(ce->name); + basename = basename ? basename + 1 : ce->name; + policy_file = !fspathcmp(basename, ".gitattributes") || + !fspathcmp(basename, ".gitignore"); + if (o->preserve_untracked_history && policy_file) { + untracked_cache_invalidate_path(o->src_index, ce->name, 0); + trace2_data_intmax("fsmonitor", o->src_index->repo, + "checkout/untracked-policy-targeted", 1); + return; + } + clean_status_release_backoff_transfer(o->internal.backoff_transfer); + o->internal.backoff_transfer = NULL; + 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) @@ -2495,6 +2524,19 @@ static void invalidate_replaced_ce_path(const struct cache_entry *old, const unsigned int unsafe_flags = CE_SKIP_WORKTREE | CE_NEW_SKIP_WORKTREE | CE_INTENT_TO_ADD | CE_CONFLICTED; const char *basename; + int policy_file; + + basename = find_last_dir_sep(old->name); + basename = basename ? basename + 1 : old->name; + policy_file = !fspathcmp(basename, ".gitattributes") || + !fspathcmp(basename, ".gitignore"); + if (o->preserve_untracked_history && policy_file) { + 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-policy-targeted", 1); + return; + } if (o->internal.backoff_transfer && clean_status_backoff_transfer_entry_is_safe( @@ -2518,10 +2560,7 @@ static void invalidate_replaced_ce_path(const struct cache_entry *old, ((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")) + if (policy_file) goto rooted; cache_tree_invalidate_path(o->src_index, old->name); @@ -2821,7 +2860,7 @@ static int merged_entry(const struct cache_entry *ce, discard_cache_entry(merge); return -1; } - invalidate_ce_path(merge, o); + invalidate_added_ce_path(merge, o); if (submodule_from_ce(ce) && file_exists(ce->name)) { int ret = check_submodule_move_head(ce, NULL, diff --git a/unpack-trees.h b/unpack-trees.h index 105896d0e4c853..e08ef55db9765f 100644 --- a/unpack-trees.h +++ b/unpack-trees.h @@ -73,6 +73,7 @@ struct unpack_trees_options { dry_run, skip_cache_tree_update, preserve_semantic_history, + preserve_untracked_history, preserve_backoff_history; enum unpack_trees_reset_type reset; const char *prefix; diff --git a/wt-status.c b/wt-status.c index 061c75d51f1c67..395d6277ac1ecf 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1910,8 +1910,12 @@ static void wt_status_refresh_for_token( struct index_state *istate = s->repo->index; if (!*epoch) - *epoch = clean_status_capture_proof_epoch( - istate, s->attr_source_snapshot, 0); + *epoch = s->proof_index_path ? + clean_status_capture_proof_epoch_at_path( + istate, s->attr_source_snapshot, 0, + s->proof_index_path) : + clean_status_capture_proof_epoch( + istate, s->attr_source_snapshot, 0); if (*epoch && use_bulk_provider) istate->preload_bulk_proof_epoch = *epoch; if (*epoch) { @@ -1951,8 +1955,12 @@ static int wt_status_close_ordinary_fsmonitor_token( !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)) && + (scan_epoch = s->proof_index_path ? + clean_status_capture_proof_epoch_at_path( + istate, s->attr_source_snapshot, 0, + s->proof_index_path) : + 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)) { @@ -2411,6 +2419,155 @@ int wt_status_refresh_index(struct wt_status *s, return ret; } +static int fsmonitor_proof_repair_is_eligible(struct repository *repo) +{ + struct index_state *istate = repo->index; + const char *test_sequence = + getenv("GIT_TEST_FSMONITOR_QUERY_SEQUENCE"); + + if ((test_sequence && *test_sequence) || !fstat_is_reliable() || + 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 || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC || + !istate->untracked || !istate->untracked->root || + !istate->fsmonitor_token_valid) + return 0; + return 1; +} + +static int locked_index_entries_are_certifiable( + const struct index_state *istate) +{ + const unsigned int allowed = CE_UPTODATE | CE_ADDED | CE_HASHED | + CE_FSMONITOR_VALID | CE_NEW_SKIP_WORKTREE | CE_UPDATE_IN_BASE; + const struct stat_data empty = { 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_FSMONITOR_VALID) || + (ce->ce_flags & ~allowed) || + !memcmp(&ce->ce_stat_data, &empty, sizeof(empty))) + return 0; + } + return 1; +} + +static int locked_index_entries_have_stat_data( + const struct index_state *istate) +{ + const struct stat_data empty = { 0 }; + + for (size_t i = 0; i < istate->cache_nr; i++) + if (!memcmp(&istate->cache[i]->ce_stat_data, + &empty, sizeof(empty))) + return 0; + return 1; +} + +int wt_status_fsmonitor_proof_needs_repair(struct repository *repo) +{ + struct index_state *istate = repo->index; + + if (!fsmonitor_proof_repair_is_eligible(repo)) + return 0; + return fsmonitor_pending_token_from_provider(istate) || + !istate->fsmonitor_untracked_valid || + !istate->untracked->root->valid_recursive; +} + +static int repair_fsmonitor_proof( + struct repository *repo, const char *index_path, int force_refresh) +{ + struct index_state *istate = repo->index; + struct wt_status status; + int no_pending, paired_untracked, valid_root, certifiable_index; + int full_proof, repaired; + + if (!fsmonitor_proof_repair_is_eligible(repo)) + return 0; + if (!force_refresh && !wt_status_fsmonitor_proof_needs_repair(repo)) + return 1; + + wt_status_prepare(repo, &status); + status.proof_index_path = index_path; + status.allow_clean_status_shortcuts = 1; + status.certify_clean_status = 1; + wt_status_start_untracked_cache_preload(&status); + wt_status_refresh_index( + &status, + REFRESH_QUIET | REFRESH_UNMERGED | REFRESH_DEFER_BULK_DIRTY, + 1); + untracked_cache_recompute_fsmonitor_valid_recursive(istate->untracked); + no_pending = !fsmonitor_has_pending_token(istate); + paired_untracked = istate->fsmonitor_untracked_valid && + istate->fsmonitor_last_update && + istate->fsmonitor_untracked_token && + !strcmp(istate->fsmonitor_last_update, + istate->fsmonitor_untracked_token); + valid_root = istate->untracked->root->valid_recursive; + certifiable_index = clean_status_index_entries_are_certifiable(istate) || + (index_path && locked_index_entries_are_certifiable(istate)); + full_proof = clean_status_has_current_full_fsmonitor_proof(istate); + repaired = !status.certify_untracked_scan_failed && no_pending && + paired_untracked && valid_root && certifiable_index && full_proof; + + wt_status_collect_free_buffers(&status); + string_list_clear(&status.change, 1); + string_list_clear(&status.untracked, 0); + string_list_clear(&status.ignored, 0); + free(status.branch); + trace2_data_intmax("fsmonitor", repo, + "history/writer-proof-repaired", repaired); + return repaired; +} + +int wt_status_repair_fsmonitor_proof(struct repository *repo) +{ + return repair_fsmonitor_proof(repo, NULL, 0); +} + +int wt_status_repair_fsmonitor_proof_at_path( + struct repository *repo, const char *index_path) +{ + if (!index_path || !*index_path) + return 0; + return repair_fsmonitor_proof(repo, index_path, 0); +} + +int wt_status_repair_fsmonitor_proof_after_worktree_update( + struct repository *repo, struct lock_file *lock, int had_full_proof) +{ + const char *proof_index_path; + int repaired; + + if (!had_full_proof || !fsmonitor_proof_repair_is_eligible(repo) || + clean_status_worktree_manifest_needs_refresh(repo->index) || + clean_status_filter_scope_needs_validation(repo->index) || + clean_status_changed_worktree_manifest_has_filters(repo->index)) + return 0; + if (!wt_status_fsmonitor_proof_needs_repair(repo) && + clean_status_has_current_full_fsmonitor_proof(repo->index) && + locked_index_entries_have_stat_data(repo->index)) + return 1; + + /* Consume paths written by this process before publishing its index. */ + fsmonitor_refresh_after_worktree_update(repo->index); + if (write_locked_index(repo->index, lock, PROVISIONAL_LOCK)) + return -1; + proof_index_path = get_lock_file_path(lock); + repaired = repair_fsmonitor_proof(repo, proof_index_path, 1); + if (reopen_lock_file(lock) < 0) + return -1; + return repaired; +} + static void wt_status_release_attr_snapshot(struct wt_status *s) { if (s->attr_source_snapshot) diff --git a/wt-status.h b/wt-status.h index 5106c02384dda4..16ed1810cacf90 100644 --- a/wt-status.h +++ b/wt-status.h @@ -8,6 +8,7 @@ struct repository; struct stat; +struct lock_file; struct attr_source_snapshot; struct exclude_source_proof; struct wt_status_exclude_context; @@ -151,6 +152,7 @@ struct wt_status { unsigned untracked_from_preload : 1; unsigned bulk_update_index_stat : 1; const char *index_file; + const char *proof_index_path; FILE *fp; const char *prefix; struct string_list change; @@ -182,6 +184,16 @@ 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); +/* + * Re-establish a complete, writable fsmonitor proof after a provider reset or + * an owned worktree update invalidates part of an authenticated proof. + */ +int wt_status_repair_fsmonitor_proof(struct repository *repo); +int wt_status_repair_fsmonitor_proof_at_path( + struct repository *repo, const char *index_path); +int wt_status_fsmonitor_proof_needs_repair(struct repository *repo); +int wt_status_repair_fsmonitor_proof_after_worktree_update( + struct repository *repo, struct lock_file *lock, int had_full_proof); void wt_status_invalidate_refresh(struct wt_status *s); int wt_status_certified_excludes_digest( struct wt_status *s, struct object_id *digest, From bb555fe6e432e78e4b6e83f6242f978564d71e4f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 03:18:41 -0500 Subject: [PATCH 413/432] fsmonitor: retain directory identity on Linux The Linux listener queued every inotify event as a file pathname. Directory events therefore lacked the trailing slash used by semantic invalidation. After an owned worktree update retained a clean proof, a following read-only status could not close those events against it. The command scanned all tracked entries. With core.preloadIndexBulk enabled, this work appears as statx calls instead of lstat counters. Format Linux worktree events through fsmonitor_format_worktree_paths() and use IN_ISDIR to preserve their directory identity. Advertise directory metadata support and mark Linux tokens so clients replace daemons using the old event format. Concurrent clients can see an expected connection reset while one client replaces a stale daemon. Silence that diagnostic only for gentle IPC reads, which already reconnect, without changing ordinary IPC error handling. Cover both bulk preload modes plus single and concurrent daemon replacement. --- builtin/fsmonitor--daemon.c | 8 ++++--- compat/fsmonitor/fsm-listen-linux.c | 23 ++++++++++++++------ compat/simple-ipc/ipc-unix-socket.c | 8 +++++-- compat/simple-ipc/ipc-win32.c | 8 +++++-- fsmonitor-ipc.c | 11 +++++----- fsmonitor-ipc.h | 13 ++++++++++++ pkt-line.c | 3 +++ pkt-line.h | 5 +++++ t/helper/test-simple-ipc.c | 33 ++++++++++++++++++++--------- t/t7519-status-fsmonitor.sh | 11 ++++++++-- t/t7527-builtin-fsmonitor.sh | 22 +++++++++++++------ 11 files changed, 109 insertions(+), 36 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 9ba1d77f30e657..701eefaa5fdbad 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -457,10 +457,8 @@ 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 + FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX); strbuf_addstr(&token->token_id, FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX); @@ -919,7 +917,11 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY "\n" #ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" +#endif +#if FSMONITOR_IPC_HAS_DIR_METADATA FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" +#endif +#ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n" #endif ; diff --git a/compat/fsmonitor/fsm-listen-linux.c b/compat/fsmonitor/fsm-listen-linux.c index 6181dcba51472d..4d660e7df16a1f 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.h" #include "fsmonitor-ipc.h" #include "fsmonitor-ll.h" #include "fsm-listen.h" @@ -490,7 +491,6 @@ static int process_event(const char *path, struct string_list *cookie_list, struct fsmonitor_daemon_state *state) { - const char *rel; const char *last_sep; switch (fsmonitor_classify_path_absolute(state, path)) { @@ -529,11 +529,22 @@ static int process_event(const char *path, if (trace_pass_fl(&trace_fsmonitor)) log_mask_set(path, event->mask); - if (!*batch) - *batch = fsmonitor_batch__new(); - - rel = path + state->path_worktree_watch.len + 1; - fsmonitor_batch__add_path(*batch, rel); + { + struct strbuf paths = STRBUF_INIT; + + fsmonitor_format_worktree_paths( + &paths, path, state->path_worktree_watch.len, + !(event->mask & IN_ISDIR), + !!(event->mask & IN_ISDIR)); + for (const char *relative = paths.buf; + relative < paths.buf + paths.len; + relative += strlen(relative) + 1) { + if (!*batch) + *batch = fsmonitor_batch__new(); + fsmonitor_batch__add_path(*batch, relative); + } + strbuf_release(&paths); + } if (em_dir_deleted(event->mask)) break; diff --git a/compat/simple-ipc/ipc-unix-socket.c b/compat/simple-ipc/ipc-unix-socket.c index d27747bc1d0b63..400187f2f7de43 100644 --- a/compat/simple-ipc/ipc-unix-socket.c +++ b/compat/simple-ipc/ipc-unix-socket.c @@ -194,8 +194,13 @@ static int ipc_client_send_command_to_connection_1( const char *message, size_t message_len, struct strbuf *answer, int gentle) { + int read_options = PACKET_READ_GENTLE_ON_EOF | + PACKET_READ_GENTLE_ON_READ_ERROR; int ret = 0; + if (gentle) + read_options |= PACKET_READ_SILENT_ON_READ_ERROR; + strbuf_setlen(answer, 0); trace2_region_enter("ipc-client", "send-command", NULL); @@ -208,8 +213,7 @@ static int ipc_client_send_command_to_connection_1( } if (read_packetized_to_strbuf( - connection->fd, answer, - PACKET_READ_GENTLE_ON_EOF | PACKET_READ_GENTLE_ON_READ_ERROR) < 0) { + connection->fd, answer, read_options) < 0) { ret = gentle ? -1 : error(_("could not read IPC response")); goto done; } diff --git a/compat/simple-ipc/ipc-win32.c b/compat/simple-ipc/ipc-win32.c index f1b4124d3ae8df..8edc138fd2f99d 100644 --- a/compat/simple-ipc/ipc-win32.c +++ b/compat/simple-ipc/ipc-win32.c @@ -240,8 +240,13 @@ static int ipc_client_send_command_to_connection_1( const char *message, size_t message_len, struct strbuf *answer, int gentle) { + int read_options = PACKET_READ_GENTLE_ON_EOF | + PACKET_READ_GENTLE_ON_READ_ERROR; int ret = 0; + if (gentle) + read_options |= PACKET_READ_SILENT_ON_READ_ERROR; + strbuf_setlen(answer, 0); trace2_region_enter("ipc-client", "send-command", NULL); @@ -256,8 +261,7 @@ static int ipc_client_send_command_to_connection_1( FlushFileBuffers((HANDLE)_get_osfhandle(connection->fd)); if (read_packetized_to_strbuf( - connection->fd, answer, - PACKET_READ_GENTLE_ON_EOF | PACKET_READ_GENTLE_ON_READ_ERROR) < 0) { + connection->fd, answer, read_options) < 0) { ret = gentle ? -1 : error(_("could not read IPC response")); goto done; } diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 5ddd7503ca60f4..b73e5de09f1fe4 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -449,10 +449,13 @@ static int server_supports_required_capabilities(void) 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 +#if FSMONITOR_IPC_HAS_DIR_METADATA + ret = ret && + has_capability(&answer, + FSMONITOR_IPC_DIR_METADATA_CAPABILITY); #endif strbuf_release(&answer); return ret; @@ -463,9 +466,7 @@ static int response_identifies_cookie_retiring_daemon( { static const char prefix[] = "builtin:" -#ifdef __APPLE__ - FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX -#endif + FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX; const char *end = memchr(answer->buf, '\0', answer->len); diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index ba1c05eea5c065..e00af0f30d21df 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -21,6 +21,19 @@ struct repository; #define FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX "cookie-v1." #define FSMONITOR_IPC_WORKTREE_ID_HEX 64 +#ifdef __APPLE__ +#define FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX \ + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX +#define FSMONITOR_IPC_HAS_DIR_METADATA 1 +#elif defined(__linux__) +#define FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX \ + FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX +#define FSMONITOR_IPC_HAS_DIR_METADATA 1 +#else +#define FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX "" +#define FSMONITOR_IPC_HAS_DIR_METADATA 0 +#endif + /* Hash the canonical worktree root and its stable filesystem identity. */ int fsmonitor_ipc__get_worktree_identity(struct repository *r, struct strbuf *identity); diff --git a/pkt-line.c b/pkt-line.c index 3fc3e9ea7059be..c2323e920fd496 100644 --- a/pkt-line.c +++ b/pkt-line.c @@ -353,6 +353,9 @@ static int get_packet_data(int fd, char **src_buf, size_t *src_size, } else { ssize_t ret = read_in_full(fd, dst, size); if (ret < 0) { + if ((options & PACKET_READ_GENTLE_ON_READ_ERROR) && + (options & PACKET_READ_SILENT_ON_READ_ERROR)) + return -1; if (options & PACKET_READ_GENTLE_ON_READ_ERROR) return error_errno(_("read error")); die_errno(_("read error")); diff --git a/pkt-line.h b/pkt-line.h index e6cf85e34ee3c4..c7130f28bb0f87 100644 --- a/pkt-line.h +++ b/pkt-line.h @@ -78,6 +78,10 @@ void packet_fflush(FILE *f); * If options contains PACKET_READ_GENTLE_ON_READ_ERROR, we will not die * on read errors, but instead return -1. However, we may still die on an * ERR packet (if requested). + * + * If options also contains PACKET_READ_SILENT_ON_READ_ERROR, an operating + * system read error is returned without first reporting it. This is useful + * when a caller expects a peer to disappear and will reconnect. */ #define PACKET_READ_GENTLE_ON_EOF (1u<<0) #define PACKET_READ_CHOMP_NEWLINE (1u<<1) @@ -85,6 +89,7 @@ void packet_fflush(FILE *f); #define PACKET_READ_GENTLE_ON_READ_ERROR (1u<<3) #define PACKET_READ_REDACT_URI_PATH (1u<<4) #define PACKET_READ_USE_SIDEBAND (1u<<5) +#define PACKET_READ_SILENT_ON_READ_ERROR (1u<<6) int packet_read(int fd, char *buffer, unsigned size, int options); /* diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index a7e4750fc9be2a..169f8bcdbbd886 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -179,8 +179,10 @@ static int app__fsmonitor_capability_superset( FSMONITOR_IPC_QUERY_VERSION "\n" FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY "\n" -#ifdef __APPLE__ +#if FSMONITOR_IPC_HAS_DIR_METADATA FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" +#endif +#ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n" #endif ; @@ -188,7 +190,11 @@ static int app__fsmonitor_capability_superset( FSMONITOR_IPC_QUERY_VERSION "\n" #ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" +#endif +#if FSMONITOR_IPC_HAS_DIR_METADATA FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" +#endif +#ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n" #endif ; @@ -196,15 +202,19 @@ static int app__fsmonitor_capability_superset( FSMONITOR_IPC_QUERY_VERSION "\n"; static const char current_token[] = "builtin:" -#ifdef __APPLE__ - FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX -#endif + FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX "test-capable:0"; - static const char old_token[] = + static const char pre_dir_metadata_token[] = "builtin:" -#ifdef __APPLE__ - FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX +#ifdef __linux__ + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX +#else + FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX #endif + "test-pre-dir:0"; + static const char old_token[] = + "builtin:" + FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX "test-pre-cookie:0"; const char *token; const char *query; @@ -227,9 +237,12 @@ static int app__fsmonitor_capability_superset( sizeof(capabilities) - 1); } - token = fsmonitor_pre_dir_metadata || - fsmonitor_pre_cookie_retirement || fsmonitor_unmarked_response ? - old_token : current_token; + if (fsmonitor_pre_dir_metadata) + token = pre_dir_metadata_token; + else if (fsmonitor_pre_cookie_retirement || fsmonitor_unmarked_response) + token = old_token; + else + token = current_token; token_len = strlen(token); query = memchr(command, '\n', command_len); query_len = query ? command_len - (query + 1 - command) : 0; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index e4737716e141d1..a50e28e780505f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3655,6 +3655,10 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP "[1-9][0-9]*" <"$trace" && ! test_trace2_data read_directory opendir \ "[1-9][0-9]*" <"$trace" && + ! test_trace2_data index preload/bulk_dirs \ + "[1-9][0-9]*" <"$trace" && + ! test_trace2_data index preload/bulk_entries \ + "[1-9][0-9]*" <"$trace" && ! test_trace2_data index preload/sum_lstat \ "[1-9][0-9]*" <"$trace" && ! test_trace2_data index refresh/sum_lstat \ @@ -3685,13 +3689,16 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP test_grep ! "ctime: 0:0" "$gitdir/$label-stat" && test_grep ! "mtime: 0:0" "$gitdir/$label-stat" && test_grep ! "size: 0" "$gitdir/$label-stat" && - for pass in first second + for bulk in false true do + pass=bulk-$bulk && cp "$gitdir/index" \ "$gitdir/$label-$pass.index" && GIT_OPTIONAL_LOCKS=0 \ GIT_TRACE2_EVENT="$gitdir/$label-$pass.trace" \ - git -C "$worktree" status --porcelain=v2 \ + git -C "$worktree" \ + -c core.preloadIndexBulk=$bulk \ + status --porcelain=v2 \ >"$gitdir/$label-$pass" && test_must_be_empty "$gitdir/$label-$pass" && test_cmp_bin "$gitdir/$label-$pass.index" \ diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 5bdcb73ac7e581..5ae5307f92d4a0 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -59,6 +59,10 @@ test_lazy_prereq HARDLINKS ' ln hardlink-a hardlink-b ' +test_lazy_prereq FSMONITOR_DIR_METADATA ' + test "$uname_s" = Darwin || test "$uname_s" = Linux +' + test_lazy_prereq FOREIGN_FSMONITOR_GIT ' test -x /opt/homebrew/bin/git && /opt/homebrew/bin/git version @@ -74,12 +78,17 @@ then test_done fi -if test_have_prereq MACOS -then +case "$uname_s" in +Darwin) fsmonitor_pre_cookie_token_prefix=dirmeta-v1.inode-v1. -else + ;; +Linux) + fsmonitor_pre_cookie_token_prefix=dirmeta-v1. + ;; +*) fsmonitor_pre_cookie_token_prefix= -fi + ;; +esac fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. stop_daemon_delete_repo () { @@ -2171,7 +2180,8 @@ test_expect_success MACOS \ linked.fsmonitor ' -test_expect_success MACOS 'bound query upgrades stale directory event daemon' ' +test_expect_success FSMONITOR_DIR_METADATA \ + 'bound query upgrades stale directory event daemon' ' test_when_finished \ "stop_daemon_delete_repo directory-daemon-upgrade" && test_create_repo directory-daemon-upgrade && @@ -2451,7 +2461,7 @@ test_expect_success MACOS,UNTRACKED_CACHE \ ) ' -test_expect_success MACOS,UNTRACKED_CACHE \ +test_expect_success FSMONITOR_DIR_METADATA,UNTRACKED_CACHE \ 'concurrent clients share one stale directory daemon upgrade' ' test_when_finished \ "stop_daemon_delete_repo concurrent-directory-daemon-upgrade" && From d591d97bf085831fa266a201832cf47ba4f14023 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 03:27:36 -0500 Subject: [PATCH 414/432] status: finish tracked checks during writer proof repair Bulk index preload can defer content and conversion checks to the diff that normally follows refresh_index(). repair_fsmonitor_proof() only refreshes the index before deciding whether to persist a clean proof; it does not run that diff. With core.preloadIndexBulk enabled, a pull or rebase that changes .gitattributes or .gitignore can therefore leave tracked entries dirty after the writer reports a successful repair. Repeated read-only status calls cannot persist the missing repairs. Do not request deferred bulk results in the writer-repair path. This keeps the ordinary status and diff bulk path unchanged while forcing the exceptional repair to finish its tracked checks before certifying and writing the proof. Enable bulk preload in the existing fast-forward, policy-file, and sequencer writer tests. They verify targeted refreshes and two subsequent read-only status calls without scans or index writes. --- t/t7519-status-fsmonitor.sh | 5 +++++ wt-status.c | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index a50e28e780505f..f6587525c468d0 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3228,6 +3228,7 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP git -C "$fast" config pull.ff only && git -C "$fast" config core.untrackedCache true && git -C "$fast" config core.fsmonitor true && + git -C "$fast" config core.preloadIndexBulk true && git -C "$fast" fsmonitor--daemon start --start-timeout=10 && git -C "$fast" update-index --fsmonitor && GIT_INDEX_FILE="$fast_gitdir/index" \ @@ -3261,6 +3262,7 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP git -C "$repo" config pull.ff only && git -C "$repo" config core.untrackedCache true && git -C "$repo" config core.fsmonitor true && + git -C "$repo" config core.preloadIndexBulk true && write_script "$repo/.git/hooks/post-index-change" <<-\EOF && gitdir=$(git rev-parse --absolute-git-dir) || exit 1 test ! -f "$gitdir/index.lock" || exit 1 @@ -3396,6 +3398,7 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP git -C "$root_repo" config pull.ff only && git -C "$root_repo" config core.untrackedCache true && git -C "$root_repo" config core.fsmonitor true && + git -C "$root_repo" config core.preloadIndexBulk true && for worktree in "$root_repo" "$root_linked" do gitdir=$(git -C "$worktree" \ @@ -3486,6 +3489,7 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP git -C "$filter" config pull.ff only && git -C "$filter" config core.untrackedCache true && git -C "$filter" config core.fsmonitor true && + git -C "$filter" config core.preloadIndexBulk true && git -C "$filter" config filter.daemonpull.clean false && git -C "$filter" config filter.daemonpull.required true && git -C "$filter" fsmonitor--daemon start --start-timeout=10 && @@ -3537,6 +3541,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git worktree add --detach ../sequencer-linked HEAD && git config core.untrackedCache true && git config core.fsmonitor true && + git config core.preloadIndexBulk true && for worktree in "$PWD" "$PWD/../sequencer-linked" do gitdir=$(git -C "$worktree" \ diff --git a/wt-status.c b/wt-status.c index 395d6277ac1ecf..4d645317869fe4 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2500,9 +2500,10 @@ static int repair_fsmonitor_proof( status.allow_clean_status_shortcuts = 1; status.certify_clean_status = 1; wt_status_start_untracked_cache_preload(&status); + /* There is no subsequent diff to consume deferred bulk results. */ wt_status_refresh_index( &status, - REFRESH_QUIET | REFRESH_UNMERGED | REFRESH_DEFER_BULK_DIRTY, + REFRESH_QUIET | REFRESH_UNMERGED, 1); untracked_cache_recompute_fsmonitor_valid_recursive(istate->untracked); no_pending = !fsmonitor_has_pending_token(istate); From 1293167e4603c24c1a45a955cef963e7e71016d1 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 06:32:30 -0500 Subject: [PATCH 415/432] status: complete clean-proof repair for native writers A writable Git command can leave a complete FSMonitor proof in a repairable state when it changes policy files, adds or removes an intent-to-add entry, or delegates the final index write to a child process. The existing repair path assumed that the manifest and untracked cache remained closed. The sequencer also kept its stale in-memory index after git commit rewrote the canonical index. Stash operations and completed rebases could therefore drop FSUC or overwrite the child's newer token. Read-only status could not persist the repair and repeated tracked or directory work. Let index-only writers refresh changed manifests and rebuild the paired untracked cache against the provisional locked index. Preserve unrelated history for safe intent-to-add changes and unmerged non-attribute paths, then reload the canonical index after child writers before repairing it. Active filters and unresolved structural indexes still fall back. Linux can report an event for a watched directory without a child name. Keep the watched directory in that case, encode its token capabilities in the order understood by Linux clients, and serialize incompatible daemon replacement on Linux as on macOS. Cover stash creation and application, policy-file updates, ordinary and --rebase-merges conflict completion, cherry-pick's deliberately weaker tracked-only proof, nameless inotify events, and primary and linked worktrees. Repeated optional-lock-free status calls must not rewrite the index or rescan tracked entries. --- builtin/add.c | 12 +- builtin/fsmonitor--daemon.c | 4 +- builtin/rm.c | 9 +- builtin/stash.c | 62 ++++ clean-status.c | 54 +++- clean-status.h | 6 +- compat/fsmonitor/fsm-listen-linux.c | 18 +- fsmonitor-ipc.c | 7 +- fsmonitor-ipc.h | 8 + read-cache.c | 5 +- semantic-verify.c | 11 +- semantic-verify.h | 1 + sequencer.c | 49 +++ t/helper/test-simple-ipc.c | 3 +- t/t7519-status-fsmonitor.sh | 166 +++++++++- t/t7527-builtin-fsmonitor.sh | 416 ++++++++++++++++++++++++- t/t7533-status-scoped-stash.sh | 4 +- t/t7537-fsmonitor-cookie-compat.sh | 7 +- t/unit-tests/u-attr-manifest.c | 60 +++- t/unit-tests/u-clean-status-manifest.c | 2 + worktree-attr-manifest.c | 11 +- wt-status.c | 100 ++++-- wt-status.h | 3 + 23 files changed, 953 insertions(+), 65 deletions(-) diff --git a/builtin/add.c b/builtin/add.c index d9fe038f7a9061..aaba815a386d44 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -29,6 +29,7 @@ #include "submodule.h" #include "add-interactive.h" #include "merge-ll.h" +#include "wt-status.h" static const char * const builtin_add_usage[] = { N_("git add [] [--] ..."), @@ -467,6 +468,7 @@ int cmd_add(int argc, struct dir_struct dir = DIR_INIT; int flags; int add_new_files; + int had_full_proof = 0; int preserve_add_history = 0; int require_pathspec; char *seen = NULL; @@ -591,7 +593,7 @@ int cmd_add(int argc, 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 && + } else if (!show_only && !add_renormalize && !chmod_arg && !include_sparse && !ignore_add_errors) { preserve_add_history = 1; flags |= ADD_CACHE_TRACK_CLEAN_HISTORY; @@ -601,6 +603,8 @@ int cmd_add(int argc, if (repo_read_index_preload(repo, &pathspec, 0) < 0) die(_("index file corrupt")); + had_full_proof = + clean_status_has_persistent_fsmonitor_semantic_history(repo->index); if (preserve_add_history && (repo->index->split_index || repo->index->sparse_index)) clean_status_invalidate_current_proof(repo->index); @@ -717,6 +721,12 @@ int cmd_add(int argc, finish: if (preserve_add_history && exit_status) clean_status_invalidate_current_proof(repo->index); + else if (preserve_add_history && !show_only && !intent_to_add && + (!clean_status_has_current_full_fsmonitor_proof(repo->index) || + !repo->index->fsmonitor_untracked_valid) && + wt_status_repair_fsmonitor_proof_after_index_update( + repo, &lock_file, had_full_proof) < 0) + die(_("unable to repair new index file")); if (show_only) rollback_lock_file(&lock_file); else if (write_locked_index(repo->index, &lock_file, diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index 701eefaa5fdbad..fe61dfb700b88a 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -458,9 +458,7 @@ static struct fsmonitor_token_data *fsmonitor_new_token_data(void) test_env_value = git_env_bool("GIT_TEST_FSMONITOR_TOKEN", 0); strbuf_addstr(&token->token_id, - FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX); - strbuf_addstr(&token->token_id, - FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX); + FSMONITOR_IPC_COOKIE_TOKEN_PREFIX); if (!test_env_value) { struct timeval tv; diff --git a/builtin/rm.c b/builtin/rm.c index 39636abb93aedd..20a5d41707de68 100644 --- a/builtin/rm.c +++ b/builtin/rm.c @@ -423,9 +423,12 @@ int cmd_rm(int argc, 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_index_entry_is_semantically_safe( + the_repository->index, + the_repository->index->cache[pos], NULL) && + !clean_status_intent_to_add_change_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)) diff --git a/builtin/stash.c b/builtin/stash.c index f91160b7e92529..87006050bcc409 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-ll.h" #include "fsmonitor-settings.h" #include "gettext.h" #include "hash.h" @@ -29,6 +30,7 @@ #include "revision.h" #include "setup.h" #include "sparse-index.h" +#include "wt-status.h" #include "log-tree.h" #include "diffcore.h" #include "reflog.h" @@ -397,6 +399,44 @@ static int reset_tree(struct object_id *i_tree, int update, int reset, return 0; } +static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, + int worktree_updated) +{ + struct lock_file lock = LOCK_INIT; + int repaired; + + if (!had_full_proof) + return 0; + if (repo_hold_locked_index(the_repository, &lock, + LOCK_REPORT_ON_ERROR) < 0) + return error(_("could not write index")); + + /* Child commands and canonical publications may have replaced the inode. */ + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) { + rollback_lock_file(&lock); + return error(_("could not read index")); + } + if (!the_repository->index->fsmonitor_token_valid) { + the_repository->index->fsmonitor_has_run_once = 0; + refresh_fsmonitor(the_repository->index); + } + repaired = worktree_updated ? + wt_status_repair_fsmonitor_proof_after_worktree_update( + the_repository, &lock, had_full_proof) : + wt_status_repair_fsmonitor_proof_after_index_update( + the_repository, &lock, had_full_proof); + if (repaired < 0) { + rollback_lock_file(&lock); + return error(_("could not repair index")); + } + if (write_locked_index(the_repository->index, &lock, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) + return error(_("could not write index")); + + return 0; +} + static int create_index_from_tree(const struct object_id *tree_id, const char *index_path) { @@ -678,6 +718,7 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, const char *label_base) { int clean, ret; + int had_full_proof; int has_index = index; struct merge_options o; struct object_id c_tree; @@ -688,6 +729,8 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, clean_status_prepare_main_index_history(the_repository); repo_read_index_preload(the_repository, NULL, 0); + had_full_proof = clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index); if (repo_refresh_and_write_index(the_repository, REFRESH_QUIET, 0, 0, NULL, NULL, NULL)) return error(_("could not write index")); @@ -794,6 +837,9 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, restore_untracked: if (info->has_u && restore_untracked(&info->u_tree)) ret = error(_("could not restore untracked files from stash")); + if (!ret && repair_stash_fsmonitor_proof_after_update( + had_full_proof, 1)) + ret = -1; if (!quiet) { struct child_process cp = CHILD_PROCESS_INIT; @@ -1706,6 +1752,7 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED, struct repository *repo UNUSED) { int ret; + int had_full_proof; struct strbuf stash_msg_buf = STRBUF_INIT; struct stash_info info = STASH_INFO_INIT; struct pathspec ps; @@ -1716,11 +1763,17 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED, memset(&ps, 0, sizeof(ps)); 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); + had_full_proof = clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index); if (!check_changes_tracked_files(&ps)) return 0; ret = do_create_stash(&ps, &stash_msg_buf, 0, 0, NULL, 0, &info, NULL, 0); + if (!ret && repair_stash_fsmonitor_proof_after_update( + had_full_proof, 0)) + ret = -1; if (!ret) printf_ln("%s", oid_to_hex(&info.w_commit)); @@ -1736,6 +1789,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 had_full_proof; struct lock_file index_lock = LOCK_INIT; struct stash_info info = STASH_INFO_INIT; struct strbuf patch = STRBUF_INIT; @@ -1772,6 +1826,8 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q */ clean_status_prepare_main_index_history(the_repository); repo_read_index_preload(the_repository, NULL, 0); + had_full_proof = clean_status_has_persistent_fsmonitor_semantic_history( + the_repository->index); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); @@ -1996,6 +2052,12 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q goto done; } } + if (preserve_clean_history && + repair_stash_fsmonitor_proof_after_update( + had_full_proof, 1)) { + ret = -1; + goto done; + } goto done; } diff --git a/clean-status.c b/clean-status.c index 4772e870a1010d..edcd627d9d06de 100644 --- a/clean-status.c +++ b/clean-status.c @@ -520,6 +520,50 @@ int clean_status_index_entry_is_semantically_safe( old->ce_mode == new_entry->ce_mode; } +int clean_status_intent_to_add_change_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; + struct conv_attrs attrs; + const char *base; + + /* + * An intent-to-add entry is deliberately dirty, so adding or removing + * one does not invalidate the clean bits of unrelated tracked entries. + * Keep the paired untracked cache after invalidating this path, but do + * not retain a proof when the placeholder can affect policy semantics. + */ + if (!state || !!old == !!new_entry || !entry || + (old && !ce_intent_to_add(old)) || + (new_entry && !ce_intent_to_add(new_entry)) || + !S_ISREG(entry->ce_mode) || ce_stage(entry) || + ce_skip_worktree(entry) || (entry->ce_flags & CE_VALID) || + !clean_status_has_current_full_fsmonitor_proof(istate) || + !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; + base = strrchr(entry->name, '/'); + base = base ? base + 1 : entry->name; + 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; + } + return path_has_no_new_attribute_sources( + istate, entry->name, old && !new_entry); +} + void clean_status_clear_authenticated_new_directories( struct index_state *istate) { @@ -832,16 +876,6 @@ int clean_status_worktree_manifest_needs_refresh( state->manifest.current_invalidated; } -int clean_status_changed_worktree_manifest_has_filters( - const struct index_state *istate) -{ - const struct clean_status_state *state = istate->clean_status; - - return state && state->config_enforced && state->filter_configured && - state->manifest.current_valid && state->manifest.checked && - state->manifest.changed; -} - void clean_status_invalidate_current_manifest(struct index_state *istate) { if (!istate->clean_status) diff --git a/clean-status.h b/clean-status.h index dab6c824abec58..dacaaf28debfc3 100644 --- a/clean-status.h +++ b/clean-status.h @@ -100,8 +100,6 @@ int clean_status_has_authenticated_bootstrap_manifest( const struct index_state *istate); int clean_status_worktree_manifest_needs_refresh( const struct index_state *istate); -int clean_status_changed_worktree_manifest_has_filters( - 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); @@ -133,6 +131,10 @@ int clean_status_index_entry_is_semantically_safe( const struct index_state *istate, const struct cache_entry *old, const struct cache_entry *new_entry); +int clean_status_intent_to_add_change_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); diff --git a/compat/fsmonitor/fsm-listen-linux.c b/compat/fsmonitor/fsm-listen-linux.c index 4d660e7df16a1f..681bcebc04cf28 100644 --- a/compat/fsmonitor/fsm-listen-linux.c +++ b/compat/fsmonitor/fsm-listen-linux.c @@ -651,7 +651,23 @@ static void handle_events(struct fsmonitor_daemon_state *state) } strbuf_reset(&path); - strbuf_addf(&path, "%s/%s", w->dir, event->name); + strbuf_addstr(&path, w->dir); + if (event->len) { + size_t name_len = + strnlen(event->name, event->len); + + if (name_len == event->len) { + error(_("unterminated inotify event name")); + state->listen_data->shutdown = + SHUTDOWN_ERROR; + goto done; + } + if (name_len) { + strbuf_addch(&path, '/'); + strbuf_add(&path, event->name, + name_len); + } + } p = fsmonitor__resolve_alias(path.buf, &state->alias); if (!p) diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index b73e5de09f1fe4..49ff20be9d0563 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -466,8 +466,7 @@ static int response_identifies_cookie_retiring_daemon( { static const char prefix[] = "builtin:" - FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX - FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX; + FSMONITOR_IPC_COOKIE_TOKEN_PREFIX; const char *end = memchr(answer->buf, '\0', answer->len); return end && @@ -854,7 +853,7 @@ static int restart_incompatible_daemon(void) return ret; } -#ifdef __APPLE__ +#if defined(__APPLE__) || defined(__linux__) static int spawn_daemon_serialized(void) { struct strbuf lock_path = STRBUF_INIT; @@ -992,7 +991,7 @@ int fsmonitor_ipc__send_query(const char *since_token, if (lifecycle_attempts++ >= FSMONITOR_RESTART_ATTEMPTS) goto done; -#ifdef __APPLE__ +#if defined(__APPLE__) || defined(__linux__) if (spawn_daemon_serialized()) #else if (spawn_daemon()) diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index e00af0f30d21df..3800b51c897d9c 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -24,13 +24,21 @@ struct repository; #ifdef __APPLE__ #define FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX \ FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX +#define FSMONITOR_IPC_COOKIE_TOKEN_PREFIX \ + FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX \ + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX #define FSMONITOR_IPC_HAS_DIR_METADATA 1 #elif defined(__linux__) #define FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX \ FSMONITOR_IPC_DIR_METADATA_TOKEN_PREFIX +#define FSMONITOR_IPC_COOKIE_TOKEN_PREFIX \ + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX \ + FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX #define FSMONITOR_IPC_HAS_DIR_METADATA 1 #else #define FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX "" +#define FSMONITOR_IPC_COOKIE_TOKEN_PREFIX \ + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX #define FSMONITOR_IPC_HAS_DIR_METADATA 0 #endif diff --git a/read-cache.c b/read-cache.c index d10a9d66288f02..551823805f5b1b 100644 --- a/read-cache.c +++ b/read-cache.c @@ -945,7 +945,10 @@ int add_to_index(struct index_state *istate, const char *path, struct stat *st, 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); + istate, alias, ce) || + (intent_only && + clean_status_intent_to_add_change_is_semantically_safe( + istate, alias, ce)); if (!pretend && (flags & ADD_CACHE_TRACK_CLEAN_HISTORY) && !logical_same && !semantic_same) diff --git a/semantic-verify.c b/semantic-verify.c index dc6af79c2ce796..31b3ab8f60ac26 100644 --- a/semantic-verify.c +++ b/semantic-verify.c @@ -134,9 +134,14 @@ int semantic_verify_prepare(struct index_state *istate, return -1; } if (proof->epoch_required) { - proof->epoch = clean_status_capture_proof_epoch( - istate, options->attr_snapshot, - proof->filter_scope_checked); + proof->epoch = options->index_path ? + clean_status_capture_proof_epoch_at_path( + istate, options->attr_snapshot, + proof->filter_scope_checked, + options->index_path) : + 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; diff --git a/semantic-verify.h b/semantic-verify.h index 1895a384e28f2d..a08f447eaa016f 100644 --- a/semantic-verify.h +++ b/semantic-verify.h @@ -7,6 +7,7 @@ struct semantic_verify_proof; struct semantic_verify_options { unsigned int nr_threads; + const char *index_path; const struct attr_source_snapshot *attr_snapshot; unsigned int require_proof_epoch : 1; unsigned int validate_filter_scope : 1; diff --git a/sequencer.c b/sequencer.c index 7d119ebb779569..295df2eb383a23 100644 --- a/sequencer.c +++ b/sequencer.c @@ -7,6 +7,7 @@ #include "config.h" #include "copy.h" #include "environment.h" +#include "fsmonitor-ll.h" #include "gettext.h" #include "hex.h" #include "lockfile.h" @@ -5357,6 +5358,47 @@ static int continue_single_pick(struct repository *r, struct replay_opts *opts) return run_command(&cmd); } +static int reload_index_after_commit(struct repository *r, + int repair_fsmonitor_proof) +{ + struct lock_file lock = LOCK_INIT; + int repaired; + + /* git commit may have rewritten the index in the child process. */ + discard_index(r->index); + if (repo_read_index(r) < 0) + return error(_("could not read index")); + if (!repair_fsmonitor_proof || + (clean_status_has_current_full_fsmonitor_proof(r->index) && + !wt_status_fsmonitor_proof_needs_repair(r))) + return 0; + + if (repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) + return error(_("could not write index")); + + /* Recheck the child result while holding the canonical index lock. */ + discard_index(r->index); + if (repo_read_index(r) < 0) { + rollback_lock_file(&lock); + return error(_("could not read index")); + } + if (!r->index->fsmonitor_token_valid) { + r->index->fsmonitor_has_run_once = 0; + refresh_fsmonitor(r->index); + } + repaired = wt_status_repair_fsmonitor_proof_after_index_update( + r, &lock, repair_fsmonitor_proof); + if (repaired < 0) { + rollback_lock_file(&lock); + return error(_("could not repair index")); + } + if (write_locked_index(r->index, &lock, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) + return error(_("could not write index")); + + return 0; +} + static int commit_staged_changes(struct repository *r, struct replay_opts *opts, struct todo_list *todo_list) @@ -5366,6 +5408,9 @@ static int commit_staged_changes(struct repository *r, unsigned int final_fixup = 0, is_clean; struct strbuf rev = STRBUF_INIT; const char *reflog_action = reflog_message(opts, "continue", NULL); + int repair_fsmonitor_proof = + clean_status_has_persistent_fsmonitor_semantic_history(r->index) || + clean_status_has_worktree_manifest_history(r->index); int ret; if (has_unstaged_changes(r, 1)) { @@ -5535,6 +5580,10 @@ static int commit_staged_changes(struct repository *r, ret = error(_("could not commit staged changes.")); goto out; } + if (reload_index_after_commit(r, repair_fsmonitor_proof)) { + ret = -1; + goto out; + } unlink(rebase_path_amend()); unlink(git_path_merge_head(r)); diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index 169f8bcdbbd886..43a18d0f2213b4 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -202,8 +202,7 @@ static int app__fsmonitor_capability_superset( FSMONITOR_IPC_QUERY_VERSION "\n"; static const char current_token[] = "builtin:" - FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX - FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX "test-capable:0"; + FSMONITOR_IPC_COOKIE_TOKEN_PREFIX "test-capable:0"; static const char pre_dir_metadata_token[] = "builtin:" #ifdef __linux__ diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index f6587525c468d0..6b04326cc81cc2 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -793,6 +793,16 @@ test_fsmonitor_full_proof () { EOF } +test_fsmonitor_clean_bitmap () { + bitmap=$(test-tool -C "$1" dump-fsmonitor | tail -n 1) && + case "$bitmap" in + ""|*[!-]*) + echo "dirty fsmonitor bitmap: $bitmap" >&2 && + return 1 + ;; + esac +} + wait_for_fsmonitor_query_barrier () { for attempt in $(test_seq 1 500) do @@ -3399,6 +3409,13 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP git -C "$root_repo" config core.untrackedCache true && git -C "$root_repo" config core.fsmonitor true && git -C "$root_repo" config core.preloadIndexBulk true && + git -C "$root_repo" config filter.lfs.clean \ + "git-lfs clean -- %f" && + git -C "$root_repo" config filter.lfs.smudge \ + "git-lfs smudge -- %f" && + git -C "$root_repo" config filter.lfs.process \ + "git-lfs filter-process" && + git -C "$root_repo" config filter.lfs.required true && for worktree in "$root_repo" "$root_linked" do gitdir=$(git -C "$worktree" \ @@ -3456,6 +3473,13 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP test_grep ! "ctime: 0:0" "$gitdir/root-policy-stat" && test_grep ! "mtime: 0:0" "$gitdir/root-policy-stat" && test_grep ! "size: 0" "$gitdir/root-policy-stat" && + sleep 2 && + GIT_TRACE2_EVENT="$gitdir/root-delayed.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/root-delayed" && + test_must_be_empty "$gitdir/root-delayed" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_fsmonitor_clean_bitmap "$worktree" && for pass in first second do cp "$gitdir/index" "$gitdir/root-$pass.index" && @@ -3508,8 +3532,13 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP git -C "$filter" pull --quiet && test_trace2_data fsmonitor semantic/manifest-invalidated 1 \ <"$filter_gitdir/pull.trace" && - ! test_trace2_data fsmonitor history/post-worktree-refresh 1 \ + test_trace2_data status \ + semantic_verify/writer-repair-filtered 1 \ <"$filter_gitdir/pull.trace" && + ! test_fsmonitor_full_proof "$filter_gitdir/index" paired \ + 2>"$filter_gitdir/proof.err" && + test_grep "missing FSUC extension" \ + "$filter_gitdir/proof.err" && cp "$filter_gitdir/index" "$filter_gitdir/status.before" && test_must_fail env GIT_OPTIONAL_LOCKS=0 \ GIT_TRACE2_EVENT="$filter_gitdir/status.trace" \ @@ -3525,6 +3554,141 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP ) ' +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'index writers preserve authenticated policy and intent-to-add proofs' ' + test_when_finished "rm -rf writer-proof writer-proof-linked" && + test_when_finished \ + "git -C writer-proof fsmonitor--daemon stop 2>/dev/null || :" && + test_when_finished \ + "git -C writer-proof-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo writer-proof && + ( + cd writer-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p policy/nested && + test_write_lines "*.txt text" "# root base" \ + >.gitattributes && + test_write_lines "*.ignored" "# root base" >.gitignore && + test_write_lines "*.txt text" "# nested base" \ + >policy/nested/.gitattributes && + test_write_lines "*.ignored" "# nested base" \ + >policy/nested/.gitignore && + test_write_lines tracked >tracked.txt && + git add . && + git commit -qm base && + git worktree add --quiet --detach ../writer-proof-linked HEAD && + git config core.untrackedCache true && + git config core.fsmonitor true && + git config core.preloadIndexBulk 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 role in main linked + do + if test "$role" = main + then + worktree=$PWD + else + worktree=$PWD/../writer-proof-linked + fi && + gitdir=$(git -C "$worktree" \ + rev-parse --absolute-git-dir) && + commondir=$(git -C "$worktree" \ + rev-parse --path-format=absolute --git-common-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_fsmonitor_full_proof "$gitdir/index" paired && + test_fsmonitor_clean_bitmap "$worktree" && + + test_write_lines ordinary >"$worktree/ordinary-$role.txt" && + GIT_TRACE2_EVENT="$gitdir/ordinary-add.trace" \ + git -C "$worktree" add "ordinary-$role.txt" && + ! test_trace2_data fsmonitor history/writer-proof-repaired \ + <"$gitdir/ordinary-add.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_fsmonitor_clean_bitmap "$worktree" && + git -C "$worktree" commit -qm ordinary && + test_fsmonitor_full_proof "$gitdir/index" paired && + + test_write_lines "*.txt text" "# root staged $role" \ + >"$worktree/.gitattributes" && + test_write_lines "*.ignored" "# nested staged $role" \ + >"$worktree/policy/nested/.gitignore" && + GIT_TRACE2_EVENT="$gitdir/policy-add.trace" \ + git -C "$worktree" add .gitattributes \ + policy/nested/.gitignore && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/policy-add.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_fsmonitor_clean_bitmap "$worktree" && + git -C "$worktree" commit -qm "staged policy" && + test_fsmonitor_full_proof "$gitdir/index" paired && + + write_script "$commondir/hooks/pre-commit" <<-\EOF && + gitdir=$(git rev-parse --absolute-git-dir) || exit 1 + test -n "${GIT_INDEX_FILE-}" || exit 1 + printf "%s\n" "$GIT_INDEX_FILE" >"$gitdir/hook-index" + git status --porcelain=v2 >/dev/null || exit 1 + EOF + test_write_lines "*.ignored" "# root partial $role" \ + >"$worktree/.gitignore" && + test_write_lines "*.txt text" "# nested partial $role" \ + >"$worktree/policy/nested/.gitattributes" && + git -C "$worktree" add .gitignore \ + policy/nested/.gitattributes && + git -C "$worktree" commit --only \ + .gitignore policy/nested/.gitattributes \ + -m "partial policy" >/dev/null && + test_grep "next-index.*\\.lock$" "$gitdir/hook-index" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_fsmonitor_clean_bitmap "$worktree" && + + test_write_lines intent >"$worktree/intent-$role.txt" && + GIT_TRACE2_EVENT="$gitdir/intent-add.trace" \ + git -C "$worktree" add --intent-to-add \ + "intent-$role.txt" && + ! test_trace2_data fsmonitor history/writer-proof-repaired \ + <"$gitdir/intent-add.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_fsmonitor_clean_bitmap "$worktree" && + cp "$gitdir/index" "$gitdir/intent.before" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/intent" && + test_grep "intent-$role\\.txt$" "$gitdir/intent" && + test_cmp_bin "$gitdir/intent.before" "$gitdir/index" && + git -C "$worktree" rm --cached "intent-$role.txt" \ + >/dev/null && + test_fsmonitor_full_proof "$gitdir/index" paired && + test_fsmonitor_clean_bitmap "$worktree" && + cp "$gitdir/index" "$gitdir/removed.before" && + for pass in first second + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/removed-$pass.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/removed-$pass" && + test_grep "^? intent-$role\\.txt$" \ + "$gitdir/removed-$pass" && + test_cmp_bin "$gitdir/removed.before" \ + "$gitdir/index" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/removed-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/removed-$pass.trace" || return 1 + done && + test_fsmonitor_full_proof "$gitdir/index" paired && + git -C "$worktree" fsmonitor--daemon stop || return 1 + done + ) +' + 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" && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 5ae5307f92d4a0..b1dc6d4bb517b1 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -63,6 +63,10 @@ test_lazy_prereq FSMONITOR_DIR_METADATA ' test "$uname_s" = Darwin || test "$uname_s" = Linux ' +test_lazy_prereq FSMONITOR_LINUX ' + test "$uname_s" = Linux +' + test_lazy_prereq FOREIGN_FSMONITOR_GIT ' test -x /opt/homebrew/bin/git && /opt/homebrew/bin/git version @@ -81,15 +85,17 @@ fi case "$uname_s" in Darwin) fsmonitor_pre_cookie_token_prefix=dirmeta-v1.inode-v1. + fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. ;; Linux) fsmonitor_pre_cookie_token_prefix=dirmeta-v1. + fsmonitor_cookie_token_prefix=cookie-v1.dirmeta-v1. ;; *) fsmonitor_pre_cookie_token_prefix= + fsmonitor_cookie_token_prefix=cookie-v1. ;; esac -fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. stop_daemon_delete_repo () { r=$1 && @@ -170,6 +176,63 @@ have_t2_data_event () { grep -e '"event":"data".*"category":"'"$c"'".*"key":"'"$k"'"' } +native_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 +} + +native_tracked_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", "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{"FSCF"}; + EOF +} + test_expect_success 'explicit daemon start and stop' ' test_when_finished "stop_daemon_delete_repo test_explicit" && @@ -1957,6 +2020,32 @@ test_expect_success MACOS,UNTRACKED_CACHE \ ) ' +test_expect_success FSMONITOR_LINUX \ + 'nameless inotify events use the watched directory' ' + test_when_finished \ + "git -C inotify-nameless fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo inotify-nameless && + ( + cd inotify-nameless && + mkdir -p existing/inner && + test_write_lines tracked >existing/inner/tracked && + git add existing/inner/tracked && + git commit -qm base && + git config core.fsmonitor true && + start_daemon --tf "$PWD/.git/daemon.trace" && + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + chmod 750 existing/inner && + test-tool fsmonitor-client query >.git/chmod.raw && + nul_to_q <.git/chmod.raw >.git/chmod.response && + test_grep "Qexisting/inner/Q" .git/chmod.response && + test_grep ! "Qexisting/inner/[^Q][^Q]*Q" \ + .git/chmod.response && + chmod 755 existing/inner + ) +' + test_expect_success MACOS 'implicit daemon reuses the invoking Git executable' ' test_create_repo same-executable-spawn && mkdir fake-exec-path && @@ -2210,7 +2299,7 @@ test_expect_success FSMONITOR_DIR_METADATA \ "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ .git/upgrade.trace && test-tool dump-fsmonitor >.git/fsmonitor && - test_grep "fsmonitor last update builtin:dirmeta-v1\\." \ + test_grep "fsmonitor last update builtin:${fsmonitor_cookie_token_prefix}" \ .git/fsmonitor && GIT_TRACE2_EVENT="$PWD/.git/repeat.trace" \ @@ -2443,7 +2532,7 @@ test_expect_success MACOS,UNTRACKED_CACHE \ ! 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\\." \ + test_grep "fsmonitor last update builtin:${fsmonitor_cookie_token_prefix}" \ .git/fsmonitor && test_grep FSCF .git/index && test_grep FSUC .git/index && @@ -2517,7 +2606,7 @@ test_expect_success FSMONITOR_DIR_METADATA,UNTRACKED_CACHE \ .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\\." \ + test_grep "fsmonitor last update builtin:${fsmonitor_cookie_token_prefix}" \ .git/fsmonitor && test_grep FSCF .git/index && test_grep FSUC .git/index && @@ -2553,6 +2642,12 @@ test_expect_success 'bound daemon also serves legacy token queries' ' token=$(sed -n "s/^fsmonitor last update //p" \ .git/fsmonitor) && test -n "$token" && + if test_have_prereq FSMONITOR_LINUX + then + test_grep \ + "^fsmonitor last update builtin:cookie-v1\\.dirmeta-v1\\." \ + .git/fsmonitor || return 1 + fi && ipc_path=$(git rev-parse --path-format=absolute \ --git-path fsmonitor--daemon.ipc) && test-tool simple-ipc send --name="$ipc_path" \ @@ -4921,6 +5016,317 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +native_stash_setup () { + repo=$1 && + mode=$2 && + 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 "# baseline" >.gitattributes && + test_write_lines "# baseline" >.gitignore && + git add tracked sibling .gitattributes .gitignore && + git commit -qm base && + test-tool chmtime -120 tracked sibling \ + .gitattributes .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 && + if test "$mode" = lfs + then + 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 + fi && + start_daemon && + git update-index --fsmonitor && + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + native_stash_full_proof .git/index + ) +} + +native_stash_reset () { + git reset --hard -q HEAD && + git clean -fdq && + git stash clear && + git status --porcelain=v2 >.git/stash.reset && + test_must_be_empty .git/stash.reset && + native_stash_full_proof .git/index +} + +native_stash_create_policy_file () { + source=$1 && + test_write_lines staged >tracked && + git add -- tracked && + native_stash_full_proof .git/index && + test_write_lines "# baseline" "# harmless" >"$source" && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/create.before && + git stash create >.git/create.oid && + test_file_not_empty .git/create.oid && + git cat-file -e "$(cat .git/create.oid)^{commit}" && + native_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/create.after && + test_cmp .git/create.before .git/create.after && + test_grep "^1 M\\. .* tracked$" .git/create.after && + test_grep "^1 \\.M .* $source$" .git/create.after && + test_must_fail git rev-parse --verify refs/stash +} + +native_stash_staged_existing () { + test_write_lines staged >tracked && + git add -- tracked && + native_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/staged.before && + git stash push --staged -q -m staged && + native_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/staged.pushed && + test_must_be_empty .git/staged.pushed && + git stash apply --index -q stash@{0} && + native_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/staged.after && + test_cmp .git/staged.before .git/staged.after +} + +native_stash_staged_new () { + test_write_lines staged >new-file && + git add -- new-file && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/indexed.expect && + git stash push --staged -q -m indexed && + native_stash_full_proof .git/index && + git status --porcelain=v2 >.git/indexed.clean && + test_must_be_empty .git/indexed.clean && + git stash apply --index -q stash@{0} && + native_stash_full_proof .git/index && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false \ + -c core.untrackedCache=false \ + status --porcelain=v2 >.git/indexed.actual && + test_cmp .git/indexed.expect .git/indexed.actual && + test_write_lines new-file >.git/indexed.expect-paths && + git diff --cached --name-only >.git/indexed.actual-paths && + test_cmp .git/indexed.expect-paths .git/indexed.actual-paths +} + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'native stash updates retain a full proof' ' + for mode in plain lfs + do + repo="native-stash-$mode" && + test_when_finished "stop_daemon_delete_repo $repo" && + native_stash_setup "$repo" "$mode" && + ( + cd "$repo" && + native_stash_create_policy_file .gitattributes && + native_stash_reset && + native_stash_create_policy_file .gitignore && + native_stash_reset && + native_stash_staged_existing && + native_stash_reset && + native_stash_staged_new + ) || return 1 + done +' + +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS,!MINGW \ + 'completed native replay retains a full proof' ' + for mode in plain lfs + do + for location in primary linked + do + repo="native-replay-$mode-$location" && + linked="$repo-linked" && + if test "$location" = linked + then + test_when_finished \ + "stop_daemon_delete_linked_repo $repo $linked" + else + test_when_finished "stop_daemon_delete_repo $repo" + fi && + test_create_repo "$repo" && + ( + cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir unrelated && + for i in $(test_seq 1 96) + do + d=$(( (i - 1) % 32 + 1 )) && + mkdir -p "unrelated/d$d" && + test_write_lines "stable-$i" \ + >"unrelated/d$d/file-$i" || return 1 + done && + test_write_lines base >conflict && + test_write_lines "# baseline" >.gitattributes && + test_write_lines "# baseline" >.gitignore && + git add unrelated conflict .gitattributes .gitignore && + git commit -qm base && + base=$(git rev-parse HEAD) && + git switch -qc upstream && + test_write_lines upstream >conflict && + git add conflict && + git commit -qm upstream && + git switch -qc topic "$base" && + test_write_lines topic >conflict && + git add conflict && + git commit -qm topic && + 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 config core.preloadIndex true && + git config core.preloadIndexBulk true && + if test "$mode" = lfs + then + 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 + fi && + if test "$location" = linked + then + git switch -q upstream && + git worktree add -q "../$linked" topic + fi + ) && + if test "$location" = linked + then + worktree="$linked" + else + worktree="$repo" + fi && + ( + cd "$worktree" && + git fsmonitor--daemon status >/dev/null 2>&1 || + start_daemon && + index=$(git rev-parse --git-path index) && + scratch=$(git rev-parse --path-format=absolute \ + --git-path replay-test) && + mkdir -p "$scratch" && + topic=$(git rev-parse topic) && + upstream=$(git rev-parse upstream) && + test-tool chmtime -120 $(git ls-files) && + git -c core.fsmonitor=false update-index --refresh && + git update-index --fsmonitor && + git status --porcelain=v2 >"$scratch/prime.actual" && + test_must_be_empty "$scratch/prime.actual" && + native_stash_full_proof "$index" && + for replay in rebase rebase-merges cherry-pick + do + artifact="$scratch/$replay" && + if test "$replay" = cherry-pick + then + git reset --hard -q "$upstream" + else + git reset --hard -q "$topic" + fi && + git status --porcelain=v2 \ + >"$artifact.prime" && + test_must_be_empty "$artifact.prime" && + native_stash_full_proof "$index" && + case "$replay" in + rebase) + test_must_fail git rebase upstream + ;; + rebase-merges) + test_must_fail git rebase \ + --rebase-merges upstream + ;; + cherry-pick) + test_must_fail git cherry-pick "$topic" + ;; + esac && + test -n "$(git ls-files -u)" && + test_grep ! FSUC "$index" && + test_write_lines resolved >conflict && + GIT_TRACE2_EVENT="$artifact.add.trace" \ + git add conflict && + ! have_t2_data_event fsmonitor \ + semantic/manifest-scan-failed \ + <"$artifact.add.trace" && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" \ + <"$artifact.add.trace" && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" \ + <"$artifact.add.trace" && + if test "$replay" = cherry-pick + then + GIT_EDITOR=true git cherry-pick --continue + else + GIT_EDITOR=true git rebase --continue + fi && + if test "$replay" = cherry-pick + then + native_tracked_full_proof "$index" && + test_grep ! FSUC "$index" + else + native_stash_full_proof "$index" + fi && + cp "$index" "$artifact.index.before" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$artifact.status.trace" \ + git status --porcelain=v2 \ + >"$artifact.status" && + test_must_be_empty "$artifact.status" && + test_cmp "$artifact.index.before" "$index" && + test-tool dump-fsmonitor | tail -n 1 \ + >"$artifact.bitmap" && + test_grep ! + "$artifact.bitmap" && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" \ + <"$artifact.status.trace" && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" \ + <"$artifact.status.trace" && + if test "$replay" = cherry-pick + then + test_trace2_data read_directory \ + directories-visited \ + "[1-9][0-9]*" \ + <"$artifact.status.trace" + else + ! test_trace2_data read_directory \ + directories-visited "[2-9]" \ + <"$artifact.status.trace" && + ! test_trace2_data read_directory \ + directories-visited \ + "[1-9][0-9][0-9]*" \ + <"$artifact.status.trace" + fi || return 1 + done + ) || return 1 + done + done +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'clean stash push preserves closed semantic history' ' test_when_finished "rm -rf stash-clean-history" && @@ -7635,7 +8041,7 @@ test_expect_success UNTRACKED_CACHE,!MINGW,!CYGWIN \ test-tool chmtime =$mtime cached/hook-tracked EOF GIT_EDITOR=: \ - GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCDC \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCDC \ GIT_TEST_FSMONITOR_QUERY_PATH=cached/hook-tracked \ GIT_TRACE2_EVENT="$PWD/.git/commit.trace" \ git commit --allow-empty --edit -m adoption && diff --git a/t/t7533-status-scoped-stash.sh b/t/t7533-status-scoped-stash.sh index 67bf189c6e7d31..5201d798d46fd1 100755 --- a/t/t7533-status-scoped-stash.sh +++ b/t/t7533-status-scoped-stash.sh @@ -472,7 +472,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ - 'whole-worktree stash retains its deliberate proof invalidation' ' + 'whole-worktree stash preserves its authenticated proof' ' test_when_finished "rm -rf scoped-stash-whole" && scoped_stash_setup scoped-stash-whole && ( @@ -485,7 +485,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ! test_trace2_data fsmonitor \ apply/untracked-replacement-preserved 1 \ <.git/whole.trace && - ! scoped_stash_full_proof .git/index && + scoped_stash_full_proof .git/index && GIT_OPTIONAL_LOCKS=0 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCCCCCC \ git status --porcelain=v2 >.git/actual && diff --git a/t/t7537-fsmonitor-cookie-compat.sh b/t/t7537-fsmonitor-cookie-compat.sh index 29d1eca87ec641..e3ae98de78ea3d 100755 --- a/t/t7537-fsmonitor-cookie-compat.sh +++ b/t/t7537-fsmonitor-cookie-compat.sh @@ -13,10 +13,15 @@ fi if test_have_prereq MACOS then fsmonitor_pre_cookie_token_prefix=dirmeta-v1.inode-v1. + fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. +elif test "$uname_s" = Linux +then + fsmonitor_pre_cookie_token_prefix=dirmeta-v1. + fsmonitor_cookie_token_prefix=cookie-v1.dirmeta-v1. else fsmonitor_pre_cookie_token_prefix= + fsmonitor_cookie_token_prefix=cookie-v1. fi -fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. stop_cookie_compat_daemon () { cookie_compat_repo=$1 && diff --git a/t/unit-tests/u-attr-manifest.c b/t/unit-tests/u-attr-manifest.c index e8d62bad0b5d07..6ea8467c2983bd 100644 --- a/t/unit-tests/u-attr-manifest.c +++ b/t/unit-tests/u-attr-manifest.c @@ -383,6 +383,60 @@ void test_attr_manifest__falls_back_to_index_source(void) #endif } +void test_attr_manifest__ignores_unmerged_non_attribute_entries(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, 5); + istate.cache_alloc = istate.cache_nr = 5; + attributes = add_index_path(&istate, 0, GITATTRIBUTES_FILE, 0); + add_index_path(&istate, 1, "conflict", 1); + add_index_path(&istate, 2, "conflict", 2); + add_index_path(&istate, 3, "conflict", 3); + add_index_path(&istate, 4, "nested/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, 2); + 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_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 @@ -803,7 +857,7 @@ void test_attr_manifest__thread_failure_completes_remaining_ranges(void) #endif } -void test_attr_manifest__builder_rejects_structural_indexes(void) +void test_attr_manifest__builder_accepts_unmerged_but_rejects_sparse_indexes(void) { #if !SEMANTIC_VERIFY_HAS_ANCHORED_OPEN cl_skip(); @@ -820,8 +874,8 @@ void test_attr_manifest__builder_rejects_structural_indexes(void) 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, &manifest, hash, &stats), 0); + cl_assert(attr_manifest_valid(manifest.buf, manifest.len, algo)); istate.cache[0]->ce_flags = create_ce_flags(0); istate.sparse_index = INDEX_COLLAPSED; cl_assert_equal_i(worktree_attr_manifest_build( diff --git a/t/unit-tests/u-clean-status-manifest.c b/t/unit-tests/u-clean-status-manifest.c index 971b4bea973c0c..06ea0d08f110e0 100644 --- a/t/unit-tests/u-clean-status-manifest.c +++ b/t/unit-tests/u-clean-status-manifest.c @@ -199,11 +199,13 @@ void test_clean_status_manifest__invalidates_only_changed_scopes(void) clean_status_manifest_invalidate(&state); cl_assert(state.current_invalidated); istate.cache[0]->ce_flags = create_ce_flags(1); + istate.sparse_index = INDEX_COLLAPSED; 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); + istate.sparse_index = INDEX_EXPANDED; 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; diff --git a/worktree-attr-manifest.c b/worktree-attr-manifest.c index f22efb927a02fc..29a713f98eb45f 100644 --- a/worktree-attr-manifest.c +++ b/worktree-attr-manifest.c @@ -65,7 +65,7 @@ static int collect_candidates(struct index_state *istate, const char *slash = ce->name; const char *basename = ce->name; - if (ce_stage(ce) || S_ISSPARSEDIR(ce->ce_mode)) + if (S_ISSPARSEDIR(ce->ce_mode)) goto done; while ((slash = strchr(slash, '/')) != NULL) { size_t len = slash - ce->name; @@ -87,7 +87,14 @@ static int collect_candidates(struct index_state *istate, } basename = ++slash; } - if (!fspathcmp(basename, GITATTRIBUTES_FILE)) { + /* + * Unmerged entries still identify every ancestor directory that + * can contain an attribute source. Do not use an unmerged + * .gitattributes entry as the index source; callers separately + * reject an unmerged index when deciding whether to issue a proof. + */ + if (!ce_stage(ce) && + !fspathcmp(basename, GITATTRIBUTES_FILE)) { strbuf_reset(&candidate); strbuf_add(&candidate, ce->name, basename - ce->name); strbuf_addstr(&candidate, GITATTRIBUTES_FILE); diff --git a/wt-status.c b/wt-status.c index 4d645317869fe4..83497130d6548f 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1707,6 +1707,7 @@ static struct semantic_verify_proof *wt_status_prepare_semantic_verify( options.require_proof_epoch = 1; options.validate_filter_scope = clean_status_filter_scope_needs_validation(istate); + options.index_path = s->proof_index_path; options.attr_snapshot = s->attr_source_snapshot; trace2_region_enter("status", "semantic_verify", s->repo); ret = semantic_verify_prepare(istate, &options, &proof); @@ -2135,7 +2136,8 @@ wt_status_close_semantic_fsmonitor_token( return WT_STATUS_TOKEN_CLOSURE_FALLBACK; } closure->refresh_result |= refresh_index( - istate, closure->refresh_flags, &s->pathspec, NULL, NULL); + istate, closure->refresh_flags | REFRESH_IN_PROOF_EPOCH, + &s->pathspec, NULL, NULL); trace2_data_intmax("status", s->repo, "fsmonitor_token/semantic-closed", 1); if (!semantic_verify_proof_is_current(istate, *proof)) { @@ -2146,8 +2148,6 @@ 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 = @@ -2160,13 +2160,19 @@ wt_status_close_semantic_fsmonitor_token( closure->queries >= FSMONITOR_TOKEN_MAX_QUERIES) return WT_STATUS_TOKEN_CLOSURE_FALLBACK; - /* A second query closes the subsequent untracked scan. */ - closure->queries++; + } + + /* A second query closes the ordinary refresh tail and untracked scan. */ + closure->queries++; + if (defer_untracked) clean_status_manifest_begin_directory_delta(istate, *proof); - result = wt_status_query_pending_token( - closure, wt_status_untracked_cache_valid(closure)); - directory_delta_reused = + result = wt_status_query_pending_token( + closure, defer_untracked ? + wt_status_untracked_cache_valid(closure) : 0); + if (defer_untracked) { + int 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 = @@ -2199,12 +2205,20 @@ wt_status_close_semantic_fsmonitor_token( 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; - } + } else if (result != FSMONITOR_TOKEN_CLEAN) { + closure->untracked_ready = 0; + closure->untracked_proof_complete = !closure->require_untracked; + 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( @@ -2407,6 +2421,22 @@ 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, refresh_flags); + if (proof && s->proof_index_path) { + struct semantic_verify_stats stats; + + semantic_verify_get_stats(proof, &stats); + if (stats.active_filters) { + s->certify_active_filter_found = 1; + trace2_data_intmax( + "status", s->repo, + "semantic_verify/writer-repair-filtered", 1); + semantic_verify_proof_clear(proof); + git_attr_invalidate_all(); + if (fsmonitor_has_pending_token(istate)) + fsmonitor_reject_pending_token(istate); + return 0; + } + } ret = wt_status_close_fsmonitor_token( s, proof, refresh_flags, require_untracked, 0); istate->preload_bulk_recovery_requested = 0; @@ -2433,7 +2463,8 @@ static int fsmonitor_proof_repair_is_eligible(struct repository *repo) istate->sparse_index != INDEX_EXPANDED || fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC || !istate->untracked || !istate->untracked->root || - !istate->fsmonitor_token_valid) + (!istate->fsmonitor_token_valid && + !fsmonitor_pending_token_from_provider(istate))) return 0; return 1; } @@ -2488,7 +2519,7 @@ static int repair_fsmonitor_proof( struct index_state *istate = repo->index; struct wt_status status; int no_pending, paired_untracked, valid_root, certifiable_index; - int full_proof, repaired; + int full_proof, repaired = 0; if (!fsmonitor_proof_repair_is_eligible(repo)) return 0; @@ -2505,6 +2536,18 @@ static int repair_fsmonitor_proof( &status, REFRESH_QUIET | REFRESH_UNMERGED, 1); + if (status.certify_active_filter_found) + goto done; + /* A policy-file update can invalidate the cache during token closure. */ + wt_status_collect_untracked(&status); + /* Bind the rebuilt cache and refreshed entries to a fresh token. */ + if (fsmonitor_reopen_token(istate)) + wt_status_refresh_index( + &status, + REFRESH_QUIET | REFRESH_UNMERGED, + 1); + if (status.certify_active_filter_found) + goto done; untracked_cache_recompute_fsmonitor_valid_recursive(istate->untracked); no_pending = !fsmonitor_has_pending_token(istate); paired_untracked = istate->fsmonitor_untracked_valid && @@ -2519,6 +2562,7 @@ static int repair_fsmonitor_proof( repaired = !status.certify_untracked_scan_failed && no_pending && paired_untracked && valid_root && certifiable_index && full_proof; +done: wt_status_collect_free_buffers(&status); string_list_clear(&status.change, 1); string_list_clear(&status.untracked, 0); @@ -2542,16 +2586,16 @@ int wt_status_repair_fsmonitor_proof_at_path( return repair_fsmonitor_proof(repo, index_path, 0); } -int wt_status_repair_fsmonitor_proof_after_worktree_update( - struct repository *repo, struct lock_file *lock, int had_full_proof) +static int repair_fsmonitor_proof_after_update( + struct repository *repo, struct lock_file *lock, int had_full_proof, + int allow_manifest_refresh) { const char *proof_index_path; int repaired; if (!had_full_proof || !fsmonitor_proof_repair_is_eligible(repo) || - clean_status_worktree_manifest_needs_refresh(repo->index) || - clean_status_filter_scope_needs_validation(repo->index) || - clean_status_changed_worktree_manifest_has_filters(repo->index)) + (!allow_manifest_refresh && + clean_status_worktree_manifest_needs_refresh(repo->index))) return 0; if (!wt_status_fsmonitor_proof_needs_repair(repo) && clean_status_has_current_full_fsmonitor_proof(repo->index) && @@ -2569,6 +2613,20 @@ int wt_status_repair_fsmonitor_proof_after_worktree_update( return repaired; } +int wt_status_repair_fsmonitor_proof_after_worktree_update( + struct repository *repo, struct lock_file *lock, int had_full_proof) +{ + return repair_fsmonitor_proof_after_update( + repo, lock, had_full_proof, 0); +} + +int wt_status_repair_fsmonitor_proof_after_index_update( + struct repository *repo, struct lock_file *lock, int had_full_proof) +{ + return repair_fsmonitor_proof_after_update( + repo, lock, had_full_proof, 1); +} + static void wt_status_release_attr_snapshot(struct wt_status *s) { if (s->attr_source_snapshot) diff --git a/wt-status.h b/wt-status.h index 16ed1810cacf90..3d44db8c24c6ff 100644 --- a/wt-status.h +++ b/wt-status.h @@ -168,6 +168,7 @@ struct wt_status { unsigned attr_snapshot_failed : 1; unsigned certify_exclude_digest_valid : 1; unsigned certify_untracked_scan_failed : 1; + unsigned certify_active_filter_found : 1; }; size_t wt_status_locate_end(const char *s, size_t len); @@ -194,6 +195,8 @@ int wt_status_repair_fsmonitor_proof_at_path( int wt_status_fsmonitor_proof_needs_repair(struct repository *repo); int wt_status_repair_fsmonitor_proof_after_worktree_update( struct repository *repo, struct lock_file *lock, int had_full_proof); +int wt_status_repair_fsmonitor_proof_after_index_update( + struct repository *repo, struct lock_file *lock, int had_full_proof); void wt_status_invalidate_refresh(struct wt_status *s); int wt_status_certified_excludes_digest( struct wt_status *s, struct object_id *digest, From 754631ae5207e67fcdda78bd4b586cd84fa6b5ec Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 16:57:43 -0500 Subject: [PATCH 416/432] clean-status-index: reject missing snapshot paths snapshot_open() initializes its output before passing the path to open_nofollow(). The manifest builder can ask it to pin a synthetic repository while rejecting a sparse index. Such a repository need not have an index path. Ordinary Linux happened to return EFAULT from open(NULL), but passing NULL violates the contract of open(2) and aborts under UBSan. Reject NULL and empty paths after initializing the snapshot. The existing clean-status-manifest sparse-index test exercises the fail-closed result. --- clean-status-index.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clean-status-index.c b/clean-status-index.c index 858fd90160cfa7..813bb92829fdb3 100644 --- a/clean-status-index.c +++ b/clean-status-index.c @@ -115,6 +115,8 @@ static int snapshot_open( memset(snapshot, 0, sizeof(*snapshot)); snapshot->fd = -1; + if (!path || !*path) + return -1; #ifdef O_NONBLOCK flags |= O_NONBLOCK; #endif From a3c4461038688af4e3b842dee7f7e28e611346d7 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 16:57:47 -0500 Subject: [PATCH 417/432] simple-ipc: harden daemon replacement transitions 766fce69e9 (simple-ipc: split async server initialization and running, 2024-10-08) separated server initialization from startup so owners could finish setup before accepting clients. But start and stop still inspect lifecycle flags without shared synchronization. A late start can therefore release workers after cleanup requested shutdown, and concurrent cleanup paths can repeat the stop sequence. Two transport races compound that problem during daemon replacement. An interrupted shutdown wake can publish the shutdown transition without waking accept(), leaving the stop path hung. A client whose accepted socket is closed before its request write can die from SIGPIPE before the gentle EPIPE recovery runs. Serialize the first start and stop, queue the complete shutdown wake before publishing that transition, and contain SIGPIPE within gentle client writes while preserving the caller's signal state. Exercise late startup, repeated stop, interrupted wakeups, and concurrent writes to a peer that closes immediately after accept(). --- compat/simple-ipc/ipc-unix-socket.c | 119 +++++++++++-- compat/simple-ipc/ipc-win32.c | 9 +- pkt-line.c | 46 ++++- pkt-line.h | 5 + t/helper/test-simple-ipc.c | 263 ++++++++++++++++++++++++++++ t/t0052-simple-ipc.sh | 11 ++ 6 files changed, 431 insertions(+), 22 deletions(-) diff --git a/compat/simple-ipc/ipc-unix-socket.c b/compat/simple-ipc/ipc-unix-socket.c index 400187f2f7de43..7c2ec2cb638d14 100644 --- a/compat/simple-ipc/ipc-unix-socket.c +++ b/compat/simple-ipc/ipc-unix-socket.c @@ -100,6 +100,18 @@ static enum ipc_active_state connect_to_server( int fd = unix_stream_connect(path, options->uds_disallow_chdir); if (fd != -1) { +#ifdef SO_NOSIGPIPE + int value = 1; + + if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, + &value, sizeof(value)) < 0) { + int saved_errno = errno; + + close(fd); + errno = saved_errno; + return IPC_STATE__OTHER_ERROR; + } +#endif *pfd = fd; return IPC_STATE__LISTENING; } @@ -189,6 +201,75 @@ void ipc_client_close_connection(struct ipc_client_connection *connection) free(connection); } +/* + * A server can close a connection after accept() but before the client has + * finished writing its request. Keep SIGPIPE local to this write sequence so + * that the caller can recover from the resulting EPIPE. + */ +static int ipc_client_write_command(struct ipc_client_connection *connection, + const char *message, size_t message_len, + int gentle) +{ + unsigned write_options = gentle ? + PACKET_WRITE_SILENT_ON_WRITE_ERROR : 0; +#ifndef SO_NOSIGPIPE + sigset_t old_set, pending, sigpipe; + int mask_error, saved_errno, was_pending; +#endif + int ret = -1; + +#ifndef SO_NOSIGPIPE + sigemptyset(&sigpipe); + sigaddset(&sigpipe, SIGPIPE); + mask_error = pthread_sigmask(SIG_BLOCK, &sigpipe, &old_set); + if (mask_error) { + errno = mask_error; + return -1; + } + + if (sigpending(&pending) < 0) { + saved_errno = errno; + goto restore; + } + was_pending = sigismember(&pending, SIGPIPE) == 1; +#endif + + ret = write_packetized_from_buf_no_flush_with_options( + message, message_len, connection->fd, write_options); + if (!ret) + ret = packet_flush_gently_with_options( + connection->fd, write_options); +#ifndef SO_NOSIGPIPE + saved_errno = errno; + + /* + * Consume only a SIGPIPE raised by this write sequence. Otherwise, + * restoring an unblocked caller mask would deliver the pending signal + * after we had already translated the failure into EPIPE. + */ + if (ret < 0 && saved_errno == EPIPE && !was_pending && + !sigpending(&pending) && + sigismember(&pending, SIGPIPE) == 1) { + int received; + + mask_error = sigwait(&sigpipe, &received); + if (mask_error) { + saved_errno = mask_error; + ret = -1; + } + } + +restore: + mask_error = pthread_sigmask(SIG_SETMASK, &old_set, NULL); + if (mask_error) { + saved_errno = mask_error; + ret = -1; + } + errno = saved_errno; +#endif + return ret; +} + static int ipc_client_send_command_to_connection_1( struct ipc_client_connection *connection, const char *message, size_t message_len, @@ -205,9 +286,8 @@ static int ipc_client_send_command_to_connection_1( trace2_region_enter("ipc-client", "send-command", NULL); - if (write_packetized_from_buf_no_flush(message, message_len, - connection->fd) < 0 || - packet_flush_gently(connection->fd) < 0) { + if (ipc_client_write_command( + connection, message, message_len, gentle) < 0) { ret = gentle ? -1 : error(_("could not send IPC command")); goto done; } @@ -339,6 +419,7 @@ struct ipc_server_data { pthread_mutex_t work_available_mutex; pthread_cond_t work_available_cond; + pthread_mutex_t lifecycle_mutex; /* * Accepted but not yet processed client connections are kept @@ -899,6 +980,7 @@ int ipc_server_init_async(struct ipc_server_data **returned_server_data, pthread_mutex_init(&server_data->work_available_mutex, NULL); pthread_cond_init(&server_data->work_available_cond, NULL); + pthread_mutex_init(&server_data->lifecycle_mutex, NULL); server_data->queue_size = nr_threads * FIFO_SCALE; CALLOC_ARRAY(server_data->fifo_fds, server_data->queue_size); @@ -949,11 +1031,15 @@ int ipc_server_init_async(struct ipc_server_data **returned_server_data, void ipc_server_start_async(struct ipc_server_data *server_data) { - if (!server_data || server_data->started) + if (!server_data) return; - server_data->started = 1; - pthread_mutex_unlock(&server_data->work_available_mutex); + pthread_mutex_lock(&server_data->lifecycle_mutex); + if (!server_data->started && !server_data->shutdown_requested) { + server_data->started = 1; + pthread_mutex_unlock(&server_data->work_available_mutex); + } + pthread_mutex_unlock(&server_data->lifecycle_mutex); } /* @@ -970,19 +1056,28 @@ int ipc_server_stop_async(struct ipc_server_data *server_data) return 0; trace2_region_enter("ipc-server", "server-stop-async", NULL); + pthread_mutex_lock(&server_data->lifecycle_mutex); + if (server_data->shutdown_requested) { + pthread_mutex_unlock(&server_data->lifecycle_mutex); + trace2_region_leave("ipc-server", "server-stop-async", NULL); + return 0; + } /* If we haven't started yet, we are already holding lock. */ if (server_data->started) pthread_mutex_lock(&server_data->work_available_mutex); - server_data->shutdown_requested = 1; - /* * Write a byte to the shutdown socket pair to wake up the - * accept-thread. + * accept-thread. Do not publish the shutdown transition until the + * byte has been queued: a failed write must leave a later caller able + * to retry it. */ - if (write(server_data->accept_thread->fd_send_shutdown, "Q", 1) < 0) - error_errno("could not write to fd_send_shutdown"); + if (write_in_full(server_data->accept_thread->fd_send_shutdown, + "Q", 1) < 0) + die_errno(_("could not write to fd_send_shutdown")); + + server_data->shutdown_requested = 1; /* * Drain the queue of existing connections. @@ -997,6 +1092,7 @@ int ipc_server_stop_async(struct ipc_server_data *server_data) pthread_cond_broadcast(&server_data->work_available_cond); pthread_mutex_unlock(&server_data->work_available_mutex); + pthread_mutex_unlock(&server_data->lifecycle_mutex); trace2_region_leave("ipc-server", "server-stop-async", NULL); @@ -1062,6 +1158,7 @@ void ipc_server_free(struct ipc_server_data *server_data) pthread_cond_destroy(&server_data->work_available_cond); pthread_mutex_destroy(&server_data->work_available_mutex); + pthread_mutex_destroy(&server_data->lifecycle_mutex); strbuf_release(&server_data->buf_path); diff --git a/compat/simple-ipc/ipc-win32.c b/compat/simple-ipc/ipc-win32.c index 8edc138fd2f99d..341f558609246e 100644 --- a/compat/simple-ipc/ipc-win32.c +++ b/compat/simple-ipc/ipc-win32.c @@ -242,6 +242,8 @@ static int ipc_client_send_command_to_connection_1( { int read_options = PACKET_READ_GENTLE_ON_EOF | PACKET_READ_GENTLE_ON_READ_ERROR; + unsigned write_options = gentle ? + PACKET_WRITE_SILENT_ON_WRITE_ERROR : 0; int ret = 0; if (gentle) @@ -251,9 +253,10 @@ static int ipc_client_send_command_to_connection_1( trace2_region_enter("ipc-client", "send-command", NULL); - if (write_packetized_from_buf_no_flush(message, message_len, - connection->fd) < 0 || - packet_flush_gently(connection->fd) < 0) { + if (write_packetized_from_buf_no_flush_with_options( + message, message_len, connection->fd, write_options) < 0 || + packet_flush_gently_with_options( + connection->fd, write_options) < 0) { ret = gentle ? -1 : error(_("could not send IPC command")); goto done; } diff --git a/pkt-line.c b/pkt-line.c index c2323e920fd496..b6bff1220c69e1 100644 --- a/pkt-line.c +++ b/pkt-line.c @@ -111,14 +111,22 @@ void packet_response_end(int fd) die_errno(_("unable to write response end packet")); } -int packet_flush_gently(int fd) +int packet_flush_gently_with_options(int fd, unsigned options) { packet_trace("0000", 4, 1); - if (write_in_full(fd, "0000", 4) < 0) + if (write_in_full(fd, "0000", 4) < 0) { + if (options & PACKET_WRITE_SILENT_ON_WRITE_ERROR) + return -1; return error(_("flush packet write failed")); + } return 0; } +int packet_flush_gently(int fd) +{ + return packet_flush_gently_with_options(fd, 0); +} + void packet_buf_flush(struct strbuf *buf) { packet_trace("0000", 4, 1); @@ -230,12 +238,17 @@ static int do_packet_write(const int fd_out, const char *buf, size_t size, return 0; } -static int packet_write_gently(const int fd_out, const char *buf, size_t size) +static int packet_write_gently(const int fd_out, const char *buf, size_t size, + unsigned options) { struct strbuf err = STRBUF_INIT; if (do_packet_write(fd_out, buf, size, &err)) { - error("%s", err.buf); + int saved_errno = errno; + + if (!(options & PACKET_WRITE_SILENT_ON_WRITE_ERROR)) + error("%s", err.buf); strbuf_release(&err); + errno = saved_errno; return -1; } return 0; @@ -308,14 +321,15 @@ int write_packetized_from_fd_no_flush(int fd_in, int fd_out) } if (bytes_to_write == 0) break; - err = packet_write_gently(fd_out, buf, bytes_to_write); + err = packet_write_gently(fd_out, buf, bytes_to_write, 0); } free(buf); return err; } -int write_packetized_from_buf_no_flush_count(const char *src_in, size_t len, - int fd_out, int *packet_counter) +static int write_packetized_from_buf_no_flush_1(const char *src_in, size_t len, + int fd_out, int *packet_counter, + unsigned options) { int err = 0; size_t bytes_written = 0; @@ -328,7 +342,8 @@ int write_packetized_from_buf_no_flush_count(const char *src_in, size_t len, bytes_to_write = len - bytes_written; if (bytes_to_write == 0) break; - err = packet_write_gently(fd_out, src_in + bytes_written, bytes_to_write); + err = packet_write_gently(fd_out, src_in + bytes_written, + bytes_to_write, options); bytes_written += bytes_to_write; if (packet_counter) (*packet_counter)++; @@ -336,6 +351,21 @@ int write_packetized_from_buf_no_flush_count(const char *src_in, size_t len, return err; } +int write_packetized_from_buf_no_flush_count(const char *src_in, size_t len, + int fd_out, int *packet_counter) +{ + return write_packetized_from_buf_no_flush_1( + src_in, len, fd_out, packet_counter, 0); +} + +int write_packetized_from_buf_no_flush_with_options(const char *src_in, + size_t len, int fd_out, + unsigned options) +{ + return write_packetized_from_buf_no_flush_1( + src_in, len, fd_out, NULL, options); +} + static int get_packet_data(int fd, char **src_buf, size_t *src_size, void *dst, size_t size, int options) { diff --git a/pkt-line.h b/pkt-line.h index c7130f28bb0f87..620cb64cd0306b 100644 --- a/pkt-line.h +++ b/pkt-line.h @@ -27,11 +27,16 @@ void packet_buf_delim(struct strbuf *buf); void set_packet_header(char *buf, int size); void packet_write(int fd_out, const char *buf, size_t size); void packet_buf_write(struct strbuf *buf, const char *fmt, ...) __attribute__((format (printf, 2, 3))); +#define PACKET_WRITE_SILENT_ON_WRITE_ERROR (1u << 0) int packet_flush_gently(int fd); +int packet_flush_gently_with_options(int fd, unsigned options); int packet_write_fmt_gently(int fd, const char *fmt, ...) __attribute__((format (printf, 2, 3))); int write_packetized_from_fd_no_flush(int fd_in, int fd_out); int write_packetized_from_buf_no_flush_count(const char *src_in, size_t len, int fd_out, int *packet_counter); +int write_packetized_from_buf_no_flush_with_options(const char *src_in, + size_t len, int fd_out, + unsigned options); static inline int write_packetized_from_buf_no_flush(const char *src_in, size_t len, int fd_out) { diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index 43a18d0f2213b4..c312cb8e69eddc 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -11,6 +11,9 @@ #include "strvec.h" #include "run-command.h" #include "trace2.h" +#ifndef GIT_WINDOWS_NATIVE +#include "unix-stream-server.h" +#endif #ifndef SUPPORTS_SIMPLE_IPC int cmd__simple_ipc(int argc, const char **argv) @@ -389,6 +392,28 @@ static int daemon__run_server(void) return ret; } +static int daemon__stop_before_start(void) +{ + struct ipc_server_data *server_data; + struct ipc_server_opts opts = { + .nr_threads = cl_args.nr_threads, + }; + int ret; + + ret = ipc_server_init_async(&server_data, cl_args.path, &opts, + test_app_cb, (void *)&my_app_data); + if (ret) + return ret; + + /* A failed owner and its cleanup path may race before startup. */ + ipc_server_stop_async(server_data); + ipc_server_stop_async(server_data); + ipc_server_start_async(server_data); + ipc_server_await(server_data); + ipc_server_free(server_data); + return 0; +} + static start_bg_wait_cb bg_wait_cb; static int bg_wait_cb(const struct child_process *cp UNUSED, @@ -525,6 +550,234 @@ static int client__send_ipc(void) return error("failed to send '%s' to '%s'", command, cl_args.path); } +#ifndef GIT_WINDOWS_NATIVE +/* + * Close accepted connections before the client writes its request. This is + * deliberately below the simple-IPC server layer so that the peer cannot + * consume any part of the request first. + */ +struct close_peer_server_data { + struct unix_ss_socket *server_socket; + int nr_connections; + int error; +}; + +static void *close_peer_server_proc(void *data) +{ + struct close_peer_server_data *d = data; + int i; + + for (i = 0; i < d->nr_connections; i++) { + int fd; + + do { + fd = accept(d->server_socket->fd_socket, NULL, NULL); + } while (fd < 0 && errno == EINTR); + if (fd < 0) { + d->error = errno; + break; + } + close(fd); + } + return NULL; +} + +struct closed_peer_client_data { + struct ipc_client_connection **connections; + int nr_connections; + int preserve_pending_sigpipe; + int errors; +}; + +static void *closed_peer_client_proc(void *data) +{ + struct closed_peer_client_data *d = data; + sigset_t original, pending, sigpipe; + int expected_blocked, expected_pending, i, mask_error; + + sigemptyset(&sigpipe); + sigaddset(&sigpipe, SIGPIPE); + mask_error = pthread_sigmask(SIG_SETMASK, NULL, &original); + if (mask_error) { + error("could not read initial signal mask: %s", + strerror(mask_error)); + d->errors++; + return NULL; + } + if (sigpending(&pending) < 0) { + error_errno("could not read initial pending signals"); + d->errors++; + return NULL; + } + expected_blocked = sigismember(&original, SIGPIPE); + expected_pending = sigismember(&pending, SIGPIPE) == 1; + + if (d->preserve_pending_sigpipe) { + mask_error = pthread_sigmask(SIG_BLOCK, &sigpipe, NULL); + if (mask_error || raise(SIGPIPE) || sigpending(&pending) < 0 || + sigismember(&pending, SIGPIPE) != 1) { + error("could not establish pending SIGPIPE state"); + d->errors++; + return NULL; + } + expected_blocked = 1; + expected_pending = 1; + } + + for (i = 0; i < d->nr_connections; i++) { + struct strbuf answer = STRBUF_INIT; + sigset_t current; +#ifdef SO_NOSIGPIPE + int no_sigpipe; + socklen_t no_sigpipe_len = sizeof(no_sigpipe); + + if (getsockopt(d->connections[i]->fd, SOL_SOCKET, + SO_NOSIGPIPE, &no_sigpipe, + &no_sigpipe_len) < 0) { + error_errno("connection %d does not suppress SIGPIPE", i); + d->errors++; + ipc_client_close_connection(d->connections[i]); + strbuf_release(&answer); + continue; + } + if (!no_sigpipe) { + error("connection %d does not suppress SIGPIPE", i); + d->errors++; + ipc_client_close_connection(d->connections[i]); + strbuf_release(&answer); + continue; + } +#endif + + if (ipc_client_send_command_to_connection_gently( + d->connections[i], "ping", strlen("ping"), + &answer) >= 0) { + error("connection %d unexpectedly succeeded", i); + d->errors++; + } + ipc_client_close_connection(d->connections[i]); + strbuf_release(&answer); + + mask_error = pthread_sigmask(SIG_SETMASK, NULL, ¤t); + if (mask_error || + sigismember(¤t, SIGPIPE) != expected_blocked || + sigpending(&pending) < 0 || + (sigismember(&pending, SIGPIPE) == 1) != + expected_pending) { + error("connection %d changed SIGPIPE state", i); + d->errors++; + } + } + + if (d->preserve_pending_sigpipe) { + int received; + + mask_error = sigwait(&sigpipe, &received); + if (mask_error || received != SIGPIPE) { + error("could not consume preserved SIGPIPE state"); + d->errors++; + } + } + mask_error = pthread_sigmask(SIG_SETMASK, &original, NULL); + if (mask_error) { + error("could not restore signal mask: %s", strerror(mask_error)); + d->errors++; + } + + return NULL; +} + +/* + * Verify concurrently that a gentle client write reports a closed peer rather + * than dying from SIGPIPE, and that it preserves the caller's signal state. + */ +static int client__gentle_write_failure(void) +{ + struct unix_stream_listen_opts listen_opts = + UNIX_STREAM_LISTEN_OPTS_INIT; + struct ipc_client_connect_options connect_opts = + IPC_CLIENT_CONNECT_OPTIONS_INIT; + struct unix_ss_socket *server_socket = NULL; + struct close_peer_server_data server_data; + struct closed_peer_client_data pending_client; + struct ipc_client_connection *pending_connection; + pthread_t server_thread; + enum { NR_THREADS = 8, CONNECTIONS_PER_THREAD = 8 }; + struct closed_peer_client_data clients[NR_THREADS]; + pthread_t client_threads[NR_THREADS]; + int i, j, ret; + + listen_opts.listen_backlog_size = + NR_THREADS * CONNECTIONS_PER_THREAD + 1; + ret = unix_ss_create(cl_args.path, &listen_opts, -1, + &server_socket); + if (ret) + return error_errno("could not create close-peer server"); + + server_data.server_socket = server_socket; + server_data.nr_connections = + NR_THREADS * CONNECTIONS_PER_THREAD + 1; + server_data.error = 0; + connect_opts.wait_if_busy = 1; + if (ipc_client_try_connect(cl_args.path, &connect_opts, + &pending_connection) != IPC_STATE__LISTENING) + die("could not connect pending-signal client"); + for (i = 0; i < NR_THREADS; i++) { + CALLOC_ARRAY(clients[i].connections, CONNECTIONS_PER_THREAD); + clients[i].nr_connections = CONNECTIONS_PER_THREAD; + clients[i].preserve_pending_sigpipe = 0; + clients[i].errors = 0; + for (j = 0; j < CONNECTIONS_PER_THREAD; j++) + if (ipc_client_try_connect( + cl_args.path, &connect_opts, + &clients[i].connections[j]) != + IPC_STATE__LISTENING) + die("could not connect to close-peer server"); + } + + /* + * Queue every connection before accepting any of them so the client + * completes its post-connect socket setup before the peer closes. + */ + if (pthread_create(&server_thread, NULL, close_peer_server_proc, + &server_data)) + return error("could not start close-peer server"); + + if (pthread_join(server_thread, NULL)) + return error("could not join close-peer server"); + unix_ss_free(server_socket); + if (server_data.error) { + errno = server_data.error; + return error_errno("close-peer server failed"); + } + + /* + * Check preservation of an existing pending SIGPIPE without another + * thread racing to accept the process-directed signal on Darwin. + */ + pending_client.connections = &pending_connection; + pending_client.nr_connections = 1; + pending_client.preserve_pending_sigpipe = 1; + pending_client.errors = 0; + closed_peer_client_proc(&pending_client); + + for (i = 0; i < NR_THREADS; i++) + if (pthread_create(&client_threads[i], NULL, + closed_peer_client_proc, &clients[i])) + return error("could not start closed-peer client"); + for (i = 0; i < NR_THREADS; i++) + if (pthread_join(client_threads[i], NULL)) + return error("could not join closed-peer client"); + + ret = pending_client.errors; + for (i = 0; i < NR_THREADS; i++) { + ret += clients[i].errors; + free(clients[i].connections); + } + return ret ? error("closed-peer clients had %d errors", ret) : 0; +} +#endif + /* * Send an IPC command to an already-running server and ask it to * shutdown. "send quit" is an async request and queues a shutdown @@ -707,9 +960,11 @@ int cmd__simple_ipc(int argc, const char **argv) const char * const simple_ipc_usage[] = { N_("test-helper simple-ipc is-active [] []"), N_("test-helper simple-ipc run-daemon [] []"), + N_("test-helper simple-ipc stop-before-start [] []"), N_("test-helper simple-ipc start-daemon [] [] []"), N_("test-helper simple-ipc stop-daemon [] []"), N_("test-helper simple-ipc send [] []"), + N_("test-helper simple-ipc gentle-write-failure"), N_("test-helper simple-ipc sendbytes [] [] []"), N_("test-helper simple-ipc multiple [] [] [] []"), NULL @@ -797,9 +1052,17 @@ int cmd__simple_ipc(int argc, const char **argv) if (!strcmp(cl_args.subcommand, "run-daemon")) return !!daemon__run_server(); + if (!strcmp(cl_args.subcommand, "stop-before-start")) + return !!daemon__stop_before_start(); + if (!strcmp(cl_args.subcommand, "start-daemon")) return !!daemon__start_server(); +#ifndef GIT_WINDOWS_NATIVE + if (!strcmp(cl_args.subcommand, "gentle-write-failure")) + return !!client__gentle_write_failure(); +#endif + /* * Client commands follow. Ensure a server is running before * sending any data. This might be overkill, but then again diff --git a/t/t0052-simple-ipc.sh b/t/t0052-simple-ipc.sh index 14cea84920271a..d6a3470e637503 100755 --- a/t/t0052-simple-ipc.sh +++ b/t/t0052-simple-ipc.sh @@ -13,6 +13,17 @@ stop_simple_IPC_server () { test-tool simple-ipc stop-daemon } +test_expect_success !WINDOWS \ + 'pre-start IPC shutdown is one-shot and prevents late start' ' + test-tool simple-ipc stop-before-start --threads=2 && + test_must_fail test-tool simple-ipc is-active +' + +test_expect_success !WINDOWS 'gentle write survives a closed peer' ' + test-tool simple-ipc gentle-write-failure 2>actual && + test_must_be_empty actual +' + test_expect_success 'start simple command server' ' test_atexit stop_simple_IPC_server && test-tool simple-ipc start-daemon --threads=8 && From 7bed8a334f0cca715dbb53dc11c086b4bc128feb Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 16:57:50 -0500 Subject: [PATCH 418/432] fsmonitor: fence Darwin callbacks before answering queries The Darwin daemon treats delivery of its cookie-file event as proof that all earlier worktree changes have been published. That assumes the callback containing the cookie cannot overtake logically older work. A retained FSEvents trace disproves that assumption. The cookie callback completed before a later callback published removals that had happened before the cookie was created. A status query could therefore answer from incomplete event history and report a dirty worktree as clean. After the ordinary cookie wait, ask a long-lived worker to flush the FSEvents stream and then drain its serial callback queue. The flush schedules provider events; the queue drain waits for those callbacks to finish publishing. Accept the boundary only when the cookie was seen in the same token generation, and coalesce overlapping requests onto a single fence. If the bounded fence times out or intersects shutdown, return a conservative result and retire the daemon before unsafe stream teardown. Advertise the stronger boundary as a capability and token suffix so new clients replace unfenced daemons while older clients retain prefix compatibility. Exercise split and blocked callbacks, timeout replacement, generation reset, listener shutdown, concurrent coalescing, second-wave requests, rename and cache scopes, and protocol compatibility. Keep status proof tests outside the split-index matrix where that proof is deliberately disabled, and materialize externally restored tokens before raw-index helpers consume them. The provider fence adds work to each Darwin query, while overlapping queries share a fence when their cookies are already registered. --- builtin/fsmonitor--daemon.c | 194 +++++-- compat/fsmonitor/fsm-darwin-gcc.h | 1 + compat/fsmonitor/fsm-listen-darwin.c | 411 +++++++++++++- compat/fsmonitor/fsm-listen.h | 17 +- fsmonitor--daemon.h | 10 + fsmonitor-ipc.c | 5 +- fsmonitor-ipc.h | 6 +- t/helper/test-simple-ipc.c | 31 +- t/t1602-index-witness.sh | 4 +- t/t7527-builtin-fsmonitor.sh | 787 ++++++++++++++++++++++++++- t/t7533-status-scoped-stash.sh | 1 + t/t7537-fsmonitor-cookie-compat.sh | 2 +- 12 files changed, 1382 insertions(+), 87 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index fe61dfb700b88a..f45e387cf66040 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -157,6 +157,7 @@ static int do_as_client__status(void) } enum fsmonitor_cookie_item_result { + FCIR_TIMEOUT = -2, FCIR_ERROR = -1, /* could not create cookie file ? */ FCIR_INIT, FCIR_SEEN, @@ -166,6 +167,7 @@ enum fsmonitor_cookie_item_result { struct fsmonitor_cookie_item { struct hashmap_entry entry; char *name; + uint64_t token_generation; enum fsmonitor_cookie_item_result result; }; @@ -192,6 +194,10 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( struct strbuf cookie_filename = STRBUF_INIT; enum fsmonitor_cookie_item_result result; int my_cookie_seq; +#ifdef __APPLE__ + uint64_t cookie_token_generation; + enum fsm_listen_flush_result flush_result; +#endif CALLOC_ARRAY(cookie, 1); @@ -203,6 +209,7 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( strbuf_addbuf(&cookie_pathname, &cookie_filename); cookie->name = strbuf_detach(&cookie_filename, NULL); + cookie->token_generation = state->token_generation; cookie->result = FCIR_INIT; hashmap_entry_init(&cookie->entry, strhash(cookie->name)); @@ -233,11 +240,10 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( unlink(cookie_pathname.buf); /* - * Wait for the listener thread to observe the cookie file. - * Time out after a short interval so that the client - * does not hang forever if the filesystem does not deliver - * events (e.g., on certain container/overlay filesystems - * where inotify watches succeed but events never arrive). + * Wait for the provider to observe the cookie. On Darwin this is the + * first half of the boundary: a later synchronous provider fence is + * still required because FSEvents may report the cookie before a later + * callback containing logically older worktree events. */ { struct timeval now; @@ -253,42 +259,48 @@ static enum fsmonitor_cookie_item_result with_lock__wait_for_cookie( &state->main_lock, &ts); if (err == ETIMEDOUT && cookie->result == FCIR_INIT) { -#ifdef __APPLE__ - struct timeval rescue_now; - - /* - * FSEvents may be healthy but late enough that its normal - * delivery misses our bounded wait. Flush only after that - * wait expires, so successful queries pay no extra cost. - * The asynchronous flush cannot block on the listener callback, - * which needs main_lock to publish the cookie. - */ trace_printf_key(&trace_fsmonitor, - "cookie_wait: requesting FSEvents flush after initial timeout"); - fsm_listen__flush_async(state); - - /* - * Give the listener one more bounded interval to deliver and - * publish the cookie rather than falling back to a full index - * scan. A broken provider still reaches the existing error - * path instead of hanging a client indefinitely. - */ - gettimeofday(&rescue_now, NULL); - ts.tv_sec = rescue_now.tv_sec + 1; - ts.tv_nsec = rescue_now.tv_usec * 1000; - err = 0; - while (cookie->result == FCIR_INIT && !err) - err = pthread_cond_timedwait(&state->cookies_cond, - &state->main_lock, - &ts); + "cookie_wait timed out"); +#ifndef __APPLE__ + cookie->result = FCIR_ERROR; #endif } - if (err == ETIMEDOUT && cookie->result == FCIR_INIT) { + } + +#ifdef __APPLE__ + /* + * A delivered Darwin cookie is not a completeness boundary. Once the + * cookie is visible (or its ordinary wait expires), request the stronger + * provider fence without holding main_lock so callbacks can publish every + * event that preceded the fence. A successful fence may also rescue a + * late cookie, but is accepted only if that cookie is then SEEN. + */ + if (cookie->result == FCIR_INIT || cookie->result == FCIR_SEEN) { + cookie_token_generation = cookie->token_generation; + trace_printf_key(&trace_fsmonitor, + "cookie_wait: requesting Darwin provider fence after cookie wait"); + pthread_mutex_unlock(&state->main_lock); + flush_result = fsm_listen__flush_sync(state); + pthread_mutex_lock(&state->main_lock); + + if (flush_result == FSM_LISTEN_FLUSH_TIMEOUT) { trace_printf_key(&trace_fsmonitor, - "cookie_wait timed out"); + "cookie_wait: synchronous flush timed out"); + cookie->result = FCIR_TIMEOUT; + } else if (flush_result != FSM_LISTEN_FLUSH_OK) { + cookie->result = FCIR_ABORT; + } else if (state->token_generation != + cookie_token_generation) { + trace_printf_key(&trace_fsmonitor, + "cookie_wait: provider fence crossed token generation"); + cookie->result = FCIR_ABORT; + } else if (cookie->result == FCIR_INIT) { + trace_printf_key(&trace_fsmonitor, + "cookie_wait: synchronous flush missed cookie"); cookie->result = FCIR_ERROR; } } +#endif done: hashmap_remove(&state->cookies, &cookie->entry, NULL); @@ -321,11 +333,18 @@ static void with_lock__mark_cookies_seen(struct fsmonitor_daemon_state *state, hashmap_entry_init(&key.entry, strhash(key.name)); cookie = hashmap_get_entry(&state->cookies, &key, entry, NULL); - if (cookie) { + if (cookie && cookie->result == FCIR_INIT && + cookie->token_generation == state->token_generation) { trace_printf_key(&trace_fsmonitor, "cookie-seen: '%s'", cookie->name); cookie->result = FCIR_SEEN; nr_seen++; + } else if (cookie && cookie->result == FCIR_INIT) { + trace_printf_key(&trace_fsmonitor, + "cookie-abort-generation: '%s'", + cookie->name); + cookie->result = FCIR_ABORT; + nr_seen++; } } @@ -778,6 +797,9 @@ static void with_lock__do_force_resync(struct fsmonitor_daemon_state *state) if (state->current_token_data->client_ref_count == 0) free_me = state->current_token_data; state->current_token_data = new_one; + state->token_generation++; + if (!state->token_generation) + state->token_generation++; fsmonitor_free_token_data(free_me); @@ -867,6 +889,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, int do_trivial = 0; int do_flush = 0; int do_cookie = 0; + int stop_after_response = 0; int invalid_binding = 0; int hardlink_aware_query = 0; enum fsmonitor_cookie_item_result cookie_result; @@ -915,6 +938,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY "\n" #ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" + FSMONITOR_IPC_DARWIN_PROVIDER_FENCE_CAPABILITY "\n" #endif #if FSMONITOR_IPC_HAS_DIR_METADATA FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" @@ -1022,6 +1046,10 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, */ if (cookie_result == FCIR_ERROR) do_flush = 1; + else if (cookie_result == FCIR_TIMEOUT) { + do_flush = 1; + stop_after_response = 1; + } } } @@ -1221,8 +1249,7 @@ static int do_handle_client(struct fsmonitor_daemon_state *state, strbuf_release(&response_token); strbuf_release(&requested_token_id); strbuf_release(&payload); - - return 0; + return stop_after_response ? SIMPLE_IPC_QUIT : 0; } static void fsmonitor_reply_overflow_paths( @@ -1401,14 +1428,11 @@ enum fsmonitor_path_type fsmonitor_classify_path_absolute( */ #define MY_COMBINE_LIMIT (1024) -void fsmonitor_publish(struct fsmonitor_daemon_state *state, - struct fsmonitor_batch *batch, - const struct string_list *cookie_names) +static void with_lock__publish(struct fsmonitor_daemon_state *state, + struct fsmonitor_batch *batch, + const struct string_list *cookie_names) { - if (!batch && !cookie_names->nr) - return; - - pthread_mutex_lock(&state->main_lock); + /* assert current thread holding state->main_lock */ if (batch) { struct fsmonitor_batch *head; @@ -1466,8 +1490,40 @@ void fsmonitor_publish(struct fsmonitor_daemon_state *state, if (cookie_names->nr) with_lock__mark_cookies_seen(state, cookie_names); +} +void fsmonitor_publish(struct fsmonitor_daemon_state *state, + struct fsmonitor_batch *batch, + const struct string_list *cookie_names) +{ + if (!batch && !cookie_names->nr) + return; + + pthread_mutex_lock(&state->main_lock); + with_lock__publish(state, batch, cookie_names); + pthread_mutex_unlock(&state->main_lock); +} + +int fsmonitor_publish_if_current_generation( + struct fsmonitor_daemon_state *state, + struct fsmonitor_batch *batch, + uint64_t token_generation) +{ + struct string_list no_cookies = STRING_LIST_INIT_NODUP; + int current; + + if (!batch) + return 1; + + pthread_mutex_lock(&state->main_lock); + current = state->token_generation == token_generation; + if (current) + with_lock__publish(state, batch, &no_cookies); pthread_mutex_unlock(&state->main_lock); + + if (!current) + fsmonitor_batch__free_list(batch); + return current; } static void *fsm_health__thread_proc(void *_state) @@ -1496,12 +1552,14 @@ static void *fsm_listen__thread_proc(void *_state) fsm_listen__loop(state); - pthread_mutex_lock(&state->main_lock); - if (state->current_token_data && - state->current_token_data->client_ref_count == 0) - fsmonitor_free_token_data(state->current_token_data); - state->current_token_data = NULL; - pthread_mutex_unlock(&state->main_lock); + /* + * Leave token-data teardown to the main thread. The listener can + * initiate IPC shutdown while an existing client is still waiting for + * a filesystem boundary and has not taken a token-data reference yet. + * ipc_server_await() joins every IPC worker before the main thread + * reaches final state cleanup, so that is the first point where it is + * safe to release the current token unconditionally. + */ trace2_thread_exit(); return NULL; @@ -1553,6 +1611,12 @@ static int fsmonitor_run_daemon_1(struct fsmonitor_daemon_state *state) */ if (pthread_create(&state->health_thread, NULL, fsm_health__thread_proc, state)) { + /* + * The listener may still be starting the provider and IPC pool. + * Publish its stop request before stopping IPC so that it cannot + * subsequently start an already-stopped server. + */ + fsm_listen__stop_async(state); ipc_server_stop_async(state->ipc_server_data); err = error(_("could not start fsmonitor health thread")); goto cleanup; @@ -1571,6 +1635,20 @@ static int fsmonitor_run_daemon_1(struct fsmonitor_daemon_state *state) */ ipc_server_await(state->ipc_server_data); +#ifdef __APPLE__ + /* + * FlushSync has no cancellation API. If its bounded client wait + * expired, the client received a conservative response and stopped + * the IPC pool. Fail-stop before stream teardown can race the + * provider worker. Leave the socket pathname alone: a replacement + * daemon may already own it, and normal startup can steal a stale + * non-listening pathname after this process exits. + */ + if (fsm_listen__flush_failed(state)) { + _exit(1); + } +#endif + /* * The fsmonitor listener thread may have received a shutdown * event from the IPC thread pool, but it doesn't hurt to tell @@ -1578,6 +1656,17 @@ static int fsmonitor_run_daemon_1(struct fsmonitor_daemon_state *state) */ if (listener_started) { fsm_listen__stop_async(state); +#ifdef __APPLE__ + /* + * Normal shutdown can race a provider fence which started after + * the first check above. Do not join or tear down an uncancellable + * FlushSync worker; the client has already received a conservative + * response or the IPC pool has otherwise stopped accepting work. + */ + if (fsm_listen__flush_failed(state)) { + _exit(1); + } +#endif pthread_join(state->listener_thread, NULL); } @@ -1612,6 +1701,7 @@ static int fsmonitor_run_daemon(void) state.listen_error_code = 0; state.health_error_code = 0; state.current_token_data = fsmonitor_new_token_data(); + state.token_generation = 1; /* Prepare to (recursively) watch the directory. */ strbuf_init(&state.path_worktree_watch, 0); @@ -1730,6 +1820,9 @@ static int fsmonitor_run_daemon(void) err = fsmonitor_run_daemon_1(&state); done: + /* Stop provider callbacks before releasing the state they publish to. */ + fsm_listen__dtor(&state); + fsmonitor_free_token_data(state.current_token_data); state.current_token_data = NULL; pthread_cond_destroy(&state.cookies_cond); @@ -1743,7 +1836,6 @@ static int fsmonitor_run_daemon(void) hashmap_clear_and_free(&state.cookies, struct fsmonitor_cookie_item, entry); } - fsm_listen__dtor(&state); fsm_health__dtor(&state); ipc_server_free(state.ipc_server_data); diff --git a/compat/fsmonitor/fsm-darwin-gcc.h b/compat/fsmonitor/fsm-darwin-gcc.h index b749012c959ca8..c9aa2a1902d263 100644 --- a/compat/fsmonitor/fsm-darwin-gcc.h +++ b/compat/fsmonitor/fsm-darwin-gcc.h @@ -98,6 +98,7 @@ extern CFStringRef kCFRunLoopDefaultMode; void FSEventStreamSetDispatchQueue(FSEventStreamRef stream, dispatch_queue_t q); unsigned char FSEventStreamStart(FSEventStreamRef stream); FSEventStreamEventId FSEventStreamFlushAsync(FSEventStreamRef stream); +void FSEventStreamFlushSync(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 5ebc70902553c5..f8643a2eebcd60 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -33,9 +33,13 @@ #include "simple-ipc.h" #include "string-list.h" #include "trace.h" +#include "trace2.h" + +#define FSMONITOR_FLUSH_TIMEOUT_MS 1000 struct fsm_listen_data { + struct fsmonitor_daemon_state *state; CFStringRef cfsr_worktree_path; CFStringRef cfsr_gitdir_path; CFStringRef cfsr_event_path_key; @@ -50,6 +54,27 @@ struct fsm_listen_data pthread_cond_t dq_finished; pthread_mutex_t dq_lock; + pthread_t flush_thread; + pthread_cond_t flush_requested_cond; + pthread_cond_t flush_finished_cond; + pthread_mutex_t flush_lock; + uint64_t flush_requested; + uint64_t flush_finished; + enum flush_worker_state { + FLUSH_WORKER_NOT_STARTED = 0, + FLUSH_WORKER_RUNNING, + FLUSH_WORKER_STOPPING, + FLUSH_WORKER_FAILED, + FLUSH_WORKER_STOPPED, + } flush_state; + unsigned long flush_timeout_ms; + unsigned long test_flush_delay_ms; + unsigned long test_flush_coalesce_delay_ms; + char *test_defer_path; + struct fsmonitor_batch *test_deferred_batch; + uint64_t test_deferred_generation; + unsigned long test_defer_delay_ms; + enum shutdown_style { SHUTDOWN_EVENT = 0, FORCE_SHUTDOWN, @@ -58,10 +83,194 @@ struct fsm_listen_data unsigned int stream_scheduled:1; unsigned int stream_started:1; + unsigned int dq_sync_initialized:1; + unsigned int shutdown_requested:1; + unsigned int flush_sync_initialized:1; + unsigned int flush_thread_created:1; + unsigned int test_deferred_published:1; + unsigned int test_flush_bypass:1; unsigned int test_cookie_delayed:1; unsigned long test_cookie_delay_ms; }; +static void publish_test_deferred_batch(struct fsm_listen_data *data) +{ + struct fsmonitor_batch *batch; + uint64_t generation; + + if (data->test_defer_delay_ms) + sleep_millisec(data->test_defer_delay_ms); + + pthread_mutex_lock(&data->flush_lock); + batch = data->test_deferred_batch; + generation = data->test_deferred_generation; + data->test_deferred_batch = NULL; + data->test_deferred_generation = 0; + if (batch) + data->test_deferred_published = 1; + pthread_mutex_unlock(&data->flush_lock); + + if (!batch) + return; + + trace_printf_key(&trace_fsmonitor, + "test-publish-deferred-path-at-provider-fence"); + if (!fsmonitor_publish_if_current_generation(data->state, batch, + generation)) + trace_printf_key(&trace_fsmonitor, + "test-discard-deferred-path-after-token-reset"); +} + +static void discard_test_deferred_batch(struct fsm_listen_data *data) +{ + struct fsmonitor_batch *batch; + + pthread_mutex_lock(&data->flush_lock); + batch = data->test_deferred_batch; + data->test_deferred_batch = NULL; + data->test_deferred_generation = 0; + pthread_mutex_unlock(&data->flush_lock); + + fsmonitor_batch__free_list(batch); +} + +static void drain_dispatch_queue(void *ctx UNUSED) +{ +} + +static void *flush_worker_proc(void *ctx) +{ + struct fsm_listen_data *data = ctx; + + trace2_thread_start("fsm-flush"); + pthread_mutex_lock(&data->flush_lock); + for (;;) { + uint64_t requested; + uint64_t previously_finished; + + while (data->flush_requested == data->flush_finished && + data->flush_state == FLUSH_WORKER_RUNNING) + pthread_cond_wait(&data->flush_requested_cond, + &data->flush_lock); + if (data->flush_state != FLUSH_WORKER_RUNNING) + break; + if (data->test_flush_coalesce_delay_ms) { + pthread_mutex_unlock(&data->flush_lock); + sleep_millisec(data->test_flush_coalesce_delay_ms); + pthread_mutex_lock(&data->flush_lock); + if (data->flush_state != FLUSH_WORKER_RUNNING) + break; + } + + requested = data->flush_requested; + previously_finished = data->flush_finished; + pthread_mutex_unlock(&data->flush_lock); + + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence begin request=%"PRIu64 + " coalesced=%"PRIu64, + requested, requested - previously_finished); + trace2_data_intmax("fsmonitor", NULL, + "darwin-fence/coalesced", + requested - previously_finished); + trace2_data_intmax("fsmonitor", NULL, + "darwin-fence/count", 1); + trace2_region_enter("fsmonitor", "darwin-flush-sync", + NULL); + if (data->test_flush_delay_ms) + sleep_millisec(data->test_flush_delay_ms); + FSEventStreamFlushSync(data->stream); + /* + * FlushSync guarantees that callbacks for earlier provider events + * have been invoked, but a callback dispatched onto our serial queue + * may still be running. Queue a synchronous no-op behind those + * callbacks so that their batches are published before the fence is + * reported complete. + */ + dispatch_sync_f(data->dq, NULL, drain_dispatch_queue); + trace2_region_leave("fsmonitor", "darwin-flush-sync", + NULL); + publish_test_deferred_batch(data); + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence complete request=%"PRIu64, + requested); + + pthread_mutex_lock(&data->flush_lock); + if (data->flush_finished < requested) + data->flush_finished = requested; + pthread_cond_broadcast(&data->flush_finished_cond); + } + if (data->flush_state == FLUSH_WORKER_STOPPING) + data->flush_state = FLUSH_WORKER_STOPPED; + pthread_cond_broadcast(&data->flush_finished_cond); + pthread_mutex_unlock(&data->flush_lock); + trace2_thread_exit(); + return NULL; +} + +static int start_flush_worker(struct fsm_listen_data *data) +{ + pthread_mutex_lock(&data->flush_lock); + if (data->flush_state != FLUSH_WORKER_NOT_STARTED) + BUG("unexpected Darwin provider fence worker state"); + data->flush_state = FLUSH_WORKER_RUNNING; + pthread_mutex_unlock(&data->flush_lock); + + if (pthread_create(&data->flush_thread, NULL, + flush_worker_proc, data)) { + pthread_mutex_lock(&data->flush_lock); + data->flush_state = FLUSH_WORKER_STOPPED; + pthread_mutex_unlock(&data->flush_lock); + return -1; + } + data->flush_thread_created = 1; + return 0; +} + +static int begin_flush_shutdown(struct fsm_listen_data *data) +{ + int in_flight; + + if (!data || !data->flush_sync_initialized) + return 0; + + pthread_mutex_lock(&data->flush_lock); + in_flight = data->flush_state == FLUSH_WORKER_FAILED; + if (data->flush_state == FLUSH_WORKER_RUNNING) { + in_flight = data->flush_finished < data->flush_requested; + data->flush_state = in_flight ? FLUSH_WORKER_FAILED : + FLUSH_WORKER_STOPPING; + } + pthread_cond_broadcast(&data->flush_requested_cond); + pthread_cond_broadcast(&data->flush_finished_cond); + pthread_mutex_unlock(&data->flush_lock); + return in_flight; +} + +static void stop_flush_worker(struct fsm_listen_data *data) +{ + if (!data->flush_thread_created) + return; + + /* + * FlushSync has no cancellation API. If shutdown races an in-flight + * fence, let the main thread fail-stop the process rather than joining + * a provider call which may never return or tearing down beneath it. + */ + if (begin_flush_shutdown(data)) { + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence abandoned during shutdown"); + return; + } + + pthread_join(data->flush_thread, NULL); + data->flush_thread_created = 0; + pthread_mutex_lock(&data->flush_lock); + if (data->flush_state != FLUSH_WORKER_STOPPED) + BUG("Darwin provider fence worker did not stop cleanly"); + pthread_mutex_unlock(&data->flush_lock); +} + static void log_flags_set(const char *path, const FSEventStreamEventFlags flag) { struct strbuf msg = STRBUF_INIT; @@ -301,6 +510,7 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, fsmonitor_force_resync(state); fsmonitor_batch__free_list(batch); + discard_test_deferred_batch(data); string_list_clear(&cookie_list, 0); batch = NULL; @@ -433,9 +643,45 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, 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, relative); + int defer_for_test = 0; + + if (data->test_defer_path && + !strcmp(relative, data->test_defer_path)) { + uint64_t generation; + + pthread_mutex_lock(&state->main_lock); + generation = state->token_generation; + pthread_mutex_unlock(&state->main_lock); + pthread_mutex_lock(&data->flush_lock); + if (!data->test_deferred_published) { + if (data->test_deferred_batch && + data->test_deferred_generation != + generation) { + fsmonitor_batch__free_list( + data->test_deferred_batch); + data->test_deferred_batch = NULL; + } + if (!data->test_deferred_batch) { + data->test_deferred_batch = + fsmonitor_batch__new(); + data->test_deferred_generation = + generation; + } + my_add_path(data->test_deferred_batch, + relative); + defer_for_test = 1; + } + pthread_mutex_unlock(&data->flush_lock); + } + if (defer_for_test) { + trace_printf_key(&trace_fsmonitor, + "test-defer-path: '%s'", + relative); + } else { + if (!batch) + batch = fsmonitor_batch__new(); + my_add_path(batch, relative); + } } break; @@ -451,6 +697,7 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, invalid_event: fsmonitor_force_resync(state); fsmonitor_batch__free_list(batch); + discard_test_deferred_batch(data); string_list_clear(&cookie_list, 0); batch = NULL; } @@ -470,12 +717,14 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, force_shutdown: free(resolved); fsmonitor_batch__free_list(batch); + discard_test_deferred_batch(data); string_list_clear(&cookie_list, 0); strbuf_release(&tmp); strbuf_release(&event_path); pthread_mutex_lock(&data->dq_lock); data->shutdown_style = FORCE_SHUTDOWN; + data->shutdown_requested = 1; pthread_cond_broadcast(&data->dq_finished); pthread_mutex_unlock(&data->dq_lock); @@ -519,8 +768,22 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) CALLOC_ARRAY(data, 1); state->listen_data = data; + data->state = state; data->test_cookie_delay_ms = git_env_ulong( "GIT_TEST_FSMONITOR_COOKIE_DELAY_MS", 0); + data->test_flush_delay_ms = git_env_ulong( + "GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS", 0); + data->test_flush_coalesce_delay_ms = git_env_ulong( + "GIT_TEST_FSMONITOR_FLUSH_COALESCE_DELAY_MS", 0); + data->test_flush_bypass = git_env_bool( + "GIT_TEST_FSMONITOR_FLUSH_SYNC_BYPASS", 0); + data->flush_timeout_ms = git_env_ulong( + "GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS", + FSMONITOR_FLUSH_TIMEOUT_MS); + data->test_defer_path = xstrdup_or_null(getenv( + "GIT_TEST_FSMONITOR_DEFER_PATH")); + data->test_defer_delay_ms = git_env_ulong( + "GIT_TEST_FSMONITOR_DEFER_PATH_DELAY_MS", 0); data->cfsr_event_path_key = CFStringCreateWithCString( NULL, "path", kCFStringEncodingUTF8); @@ -550,11 +813,20 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) if (!data->stream) goto failed; + pthread_mutex_init(&data->flush_lock, NULL); + pthread_cond_init(&data->flush_requested_cond, NULL); + pthread_cond_init(&data->flush_finished_cond, NULL); + data->flush_sync_initialized = 1; + pthread_mutex_init(&data->dq_lock, NULL); + pthread_cond_init(&data->dq_finished, NULL); + data->dq_sync_initialized = 1; + return 0; failed: error(_("Unable to create FSEventStream.")); + free(data->test_defer_path); FREE_AND_NULL(state->listen_data); return -1; } @@ -568,18 +840,37 @@ void fsm_listen__dtor(struct fsmonitor_daemon_state *state) data = state->listen_data; + if (data->flush_thread_created) + BUG("releasing FSEventStream with provider fence worker alive"); if (data->stream) { if (data->stream_started) FSEventStreamStop(data->stream); if (data->stream_scheduled) FSEventStreamInvalidate(data->stream); + /* + * Invalidation prevents new callbacks from being submitted. A + * synchronous no-op on our serial queue then waits for every + * callback that was already submitted to finish before state owned + * by the daemon is released. + */ + if (data->dq) + dispatch_sync_f(data->dq, NULL, drain_dispatch_queue); FSEventStreamRelease(data->stream); } if (data->dq) dispatch_release(data->dq); - pthread_cond_destroy(&data->dq_finished); - pthread_mutex_destroy(&data->dq_lock); + fsmonitor_batch__free_list(data->test_deferred_batch); + free(data->test_defer_path); + if (data->flush_sync_initialized) { + pthread_cond_destroy(&data->flush_finished_cond); + pthread_cond_destroy(&data->flush_requested_cond); + pthread_mutex_destroy(&data->flush_lock); + } + if (data->dq_sync_initialized) { + pthread_cond_destroy(&data->dq_finished); + pthread_mutex_destroy(&data->dq_lock); + } FREE_AND_NULL(state->listen_data); } @@ -592,13 +883,99 @@ void fsm_listen__stop_async(struct fsmonitor_daemon_state *state) pthread_mutex_lock(&data->dq_lock); data->shutdown_style = SHUTDOWN_EVENT; + data->shutdown_requested = 1; pthread_cond_broadcast(&data->dq_finished); pthread_mutex_unlock(&data->dq_lock); + begin_flush_shutdown(data); +} + +enum fsm_listen_flush_result fsm_listen__flush_sync( + struct fsmonitor_daemon_state *state) +{ + struct fsm_listen_data *data; + struct timeval now; + struct timespec deadline; + uint64_t requested; + int err = 0; + enum fsm_listen_flush_result result = FSM_LISTEN_FLUSH_OK; + + if (!state || !(data = state->listen_data) || + !data->flush_sync_initialized) + return FSM_LISTEN_FLUSH_SHUTDOWN; + + if (data->test_flush_bypass) { + trace_printf_key(&trace_fsmonitor, + "test-bypass-darwin-flush-sync"); + return FSM_LISTEN_FLUSH_OK; + } + + pthread_mutex_lock(&data->flush_lock); + if (data->flush_state == FLUSH_WORKER_FAILED) { + result = FSM_LISTEN_FLUSH_TIMEOUT; + goto done; + } + if (data->flush_state != FLUSH_WORKER_RUNNING) { + result = FSM_LISTEN_FLUSH_SHUTDOWN; + goto done; + } + + requested = ++data->flush_requested; + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence requested request=%"PRIu64, + requested); + trace2_data_intmax("fsmonitor", NULL, + "darwin-fence/request", requested); + pthread_cond_signal(&data->flush_requested_cond); + + gettimeofday(&now, NULL); + deadline.tv_sec = now.tv_sec + data->flush_timeout_ms / 1000; + deadline.tv_nsec = now.tv_usec * 1000 + + (data->flush_timeout_ms % 1000) * 1000000; + if (deadline.tv_nsec >= 1000000000) { + deadline.tv_sec++; + deadline.tv_nsec -= 1000000000; + } + + trace2_region_enter("fsmonitor", "darwin-fence-wait", NULL); + while (data->flush_finished < requested && + data->flush_state == FLUSH_WORKER_RUNNING && !err) + err = pthread_cond_timedwait(&data->flush_finished_cond, + &data->flush_lock, &deadline); + trace2_region_leave("fsmonitor", "darwin-fence-wait", NULL); + + if (data->flush_state == FLUSH_WORKER_FAILED) { + result = FSM_LISTEN_FLUSH_TIMEOUT; + } else if (data->flush_state != FLUSH_WORKER_RUNNING) { + result = FSM_LISTEN_FLUSH_SHUTDOWN; + } else if (data->flush_finished >= requested) { + result = FSM_LISTEN_FLUSH_OK; + } else if (err) { + data->flush_state = FLUSH_WORKER_FAILED; + pthread_cond_broadcast(&data->flush_requested_cond); + pthread_cond_broadcast(&data->flush_finished_cond); + trace2_data_intmax("fsmonitor", NULL, + "darwin-fence/timeout", 1); + result = FSM_LISTEN_FLUSH_TIMEOUT; + } + +done: + pthread_mutex_unlock(&data->flush_lock); + return result; } -void fsm_listen__flush_async(struct fsmonitor_daemon_state *state) +int fsm_listen__flush_failed(struct fsmonitor_daemon_state *state) { - FSEventStreamFlushAsync(state->listen_data->stream); + struct fsm_listen_data *data; + int timed_out; + + if (!state || !(data = state->listen_data) || + !data->flush_sync_initialized) + return 0; + + pthread_mutex_lock(&data->flush_lock); + timed_out = data->flush_state == FLUSH_WORKER_FAILED; + pthread_mutex_unlock(&data->flush_lock); + return timed_out; } void fsm_listen__loop(struct fsmonitor_daemon_state *state) @@ -607,18 +984,28 @@ void fsm_listen__loop(struct fsmonitor_daemon_state *state) data = state->listen_data; - pthread_mutex_init(&data->dq_lock, NULL); - pthread_cond_init(&data->dq_finished, NULL); data->dq = dispatch_queue_create("FSMonitor", NULL); FSEventStreamSetDispatchQueue(data->stream, data->dq); data->stream_scheduled = 1; + pthread_mutex_lock(&data->dq_lock); + if (data->shutdown_requested) { + pthread_mutex_unlock(&data->dq_lock); + return; + } + if (!FSEventStreamStart(data->stream)) { + pthread_mutex_unlock(&data->dq_lock); error(_("Failed to start the FSEventStream")); goto force_error_stop_without_loop; } data->stream_started = 1; + if (start_flush_worker(data)) { + pthread_mutex_unlock(&data->dq_lock); + error(_("Failed to start the FSEvents flush worker")); + goto force_error_stop_without_loop; + } /* * Our fs event listener is now running, so it's safe to start @@ -626,10 +1013,12 @@ void fsm_listen__loop(struct fsmonitor_daemon_state *state) */ ipc_server_start_async(state->ipc_server_data); - pthread_mutex_lock(&data->dq_lock); - pthread_cond_wait(&data->dq_finished, &data->dq_lock); + while (!data->shutdown_requested) + pthread_cond_wait(&data->dq_finished, &data->dq_lock); pthread_mutex_unlock(&data->dq_lock); + stop_flush_worker(data); + switch (data->shutdown_style) { case FORCE_ERROR_STOP: state->listen_error_code = -1; diff --git a/compat/fsmonitor/fsm-listen.h b/compat/fsmonitor/fsm-listen.h index d58e01243b9ad9..a8b6317e4f1305 100644 --- a/compat/fsmonitor/fsm-listen.h +++ b/compat/fsmonitor/fsm-listen.h @@ -39,8 +39,21 @@ void fsm_listen__dtor(struct fsmonitor_daemon_state *state); void fsm_listen__loop(struct fsmonitor_daemon_state *state); #ifdef __APPLE__ -/* Request delivery of all FSEvents that occurred before this call. */ -void fsm_listen__flush_async(struct fsmonitor_daemon_state *state); +enum fsm_listen_flush_result { + FSM_LISTEN_FLUSH_OK = 0, + FSM_LISTEN_FLUSH_SHUTDOWN, + FSM_LISTEN_FLUSH_TIMEOUT, +}; + +/* + * Request and wait for delivery of all FSEvents that occurred before + * this call. The caller must not hold fsmonitor_daemon_state::main_lock. + */ +enum fsm_listen_flush_result fsm_listen__flush_sync( + struct fsmonitor_daemon_state *state); + +/* True when a provider fence cannot be joined safely during teardown. */ +int fsm_listen__flush_failed(struct fsmonitor_daemon_state *state); #endif /* diff --git a/fsmonitor--daemon.h b/fsmonitor--daemon.h index 850188f872b783..561aed4c497657 100644 --- a/fsmonitor--daemon.h +++ b/fsmonitor--daemon.h @@ -46,6 +46,7 @@ struct fsmonitor_daemon_state { int nr_paths_watching; struct fsmonitor_token_data *current_token_data; + uint64_t token_generation; struct strbuf path_cookie_prefix; pthread_cond_t cookies_cond; @@ -161,6 +162,15 @@ void fsmonitor_publish(struct fsmonitor_daemon_state *state, struct fsmonitor_batch *batch, const struct string_list *cookie_names); +/* + * Publish a delayed batch only if it still belongs to the named token + * generation. The batch is consumed in either case. + */ +int fsmonitor_publish_if_current_generation( + struct fsmonitor_daemon_state *state, + struct fsmonitor_batch *batch, + uint64_t token_generation); + /* * If the platform-specific layer loses sync with the filesystem, * it should call this to invalidate cached data and abort waiting diff --git a/fsmonitor-ipc.c b/fsmonitor-ipc.c index 49ff20be9d0563..23ee22432d25ce 100644 --- a/fsmonitor-ipc.c +++ b/fsmonitor-ipc.c @@ -450,7 +450,9 @@ static int server_supports_required_capabilities(void) has_capability(&answer, FSMONITOR_IPC_HARDLINK_QUERY_VERSION) && has_capability(&answer, - FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY); + FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY) && + has_capability(&answer, + FSMONITOR_IPC_DARWIN_PROVIDER_FENCE_CAPABILITY); #endif #if FSMONITOR_IPC_HAS_DIR_METADATA ret = ret && @@ -958,6 +960,7 @@ int fsmonitor_ipc__send_query(const char *since_token, goto try_again; } if (!ret && is_trivial_response(answer) && + !response_identifies_cookie_retiring_daemon(answer) && !server_supports_bound_queries()) { if (!try_send_attested_legacy_query( tok, &identity, answer)) { diff --git a/fsmonitor-ipc.h b/fsmonitor-ipc.h index 3800b51c897d9c..70eaba651f4830 100644 --- a/fsmonitor-ipc.h +++ b/fsmonitor-ipc.h @@ -19,6 +19,9 @@ struct repository; #define FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY \ "cookie-token-retirement-v1" #define FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX "cookie-v1." +#define FSMONITOR_IPC_DARWIN_PROVIDER_FENCE_CAPABILITY \ + "darwin-provider-fence-v1" +#define FSMONITOR_IPC_DARWIN_PROVIDER_FENCE_TOKEN_PREFIX "fence-v1." #define FSMONITOR_IPC_WORKTREE_ID_HEX 64 #ifdef __APPLE__ @@ -26,7 +29,8 @@ struct repository; FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX #define FSMONITOR_IPC_COOKIE_TOKEN_PREFIX \ FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX \ - FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX \ + FSMONITOR_IPC_DARWIN_PROVIDER_FENCE_TOKEN_PREFIX #define FSMONITOR_IPC_HAS_DIR_METADATA 1 #elif defined(__linux__) #define FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX \ diff --git a/t/helper/test-simple-ipc.c b/t/helper/test-simple-ipc.c index c312cb8e69eddc..0bbee3eaa8488b 100644 --- a/t/helper/test-simple-ipc.c +++ b/t/helper/test-simple-ipc.c @@ -167,6 +167,7 @@ static int fsmonitor_legacy; static int fsmonitor_capability_superset; static int fsmonitor_pre_dir_metadata; static int fsmonitor_pre_cookie_retirement; +static int fsmonitor_pre_provider_fence; static int fsmonitor_unmarked_response; static int fsmonitor_disconnect_first; @@ -187,8 +188,15 @@ static int app__fsmonitor_capability_superset( #endif #ifdef __APPLE__ FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n" + FSMONITOR_IPC_DARWIN_PROVIDER_FENCE_CAPABILITY "\n" #endif ; + static const char pre_provider_fence_capabilities[] = + FSMONITOR_IPC_QUERY_VERSION "\n" + FSMONITOR_IPC_HARDLINK_QUERY_VERSION "\n" + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_CAPABILITY "\n" + FSMONITOR_IPC_DIR_METADATA_CAPABILITY "\n" + FSMONITOR_IPC_HARDLINK_INODE_CAPABILITY "\n"; static const char pre_cookie_capabilities[] = FSMONITOR_IPC_QUERY_VERSION "\n" #ifdef __APPLE__ @@ -216,8 +224,17 @@ static int app__fsmonitor_capability_superset( "test-pre-dir:0"; static const char old_token[] = "builtin:" +#ifdef __APPLE__ + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX +#else FSMONITOR_IPC_PLATFORM_TOKEN_PREFIX +#endif "test-pre-cookie:0"; + static const char pre_provider_fence_token[] = + "builtin:" + FSMONITOR_IPC_HARDLINK_INODE_TOKEN_PREFIX + FSMONITOR_IPC_COOKIE_TOKEN_RETIREMENT_PREFIX + "test-pre-fence:0"; const char *token; const char *query; size_t token_len, query_len; @@ -235,12 +252,18 @@ static int app__fsmonitor_capability_superset( return reply_cb(reply_data, pre_cookie_capabilities, sizeof(pre_cookie_capabilities) - 1); + if (fsmonitor_pre_provider_fence) + return reply_cb(reply_data, + pre_provider_fence_capabilities, + sizeof(pre_provider_fence_capabilities) - 1); return reply_cb(reply_data, capabilities, sizeof(capabilities) - 1); } if (fsmonitor_pre_dir_metadata) token = pre_dir_metadata_token; + else if (fsmonitor_pre_provider_fence) + token = pre_provider_fence_token; else if (fsmonitor_pre_cookie_retirement || fsmonitor_unmarked_response) token = old_token; else @@ -304,7 +327,8 @@ static int test_app_cb(void *application_data, } if (fsmonitor_capability_superset || fsmonitor_pre_dir_metadata || - fsmonitor_pre_cookie_retirement || fsmonitor_unmarked_response) + fsmonitor_pre_cookie_retirement || + fsmonitor_pre_provider_fence || fsmonitor_unmarked_response) return app__fsmonitor_capability_superset( command, command_len, reply_cb, reply_data); @@ -457,6 +481,8 @@ static int daemon__start_server(void) strvec_push(&cp.args, "--fsmonitor-pre-dir-metadata"); if (fsmonitor_pre_cookie_retirement) strvec_push(&cp.args, "--fsmonitor-pre-cookie-retirement"); + if (fsmonitor_pre_provider_fence) + strvec_push(&cp.args, "--fsmonitor-pre-provider-fence"); if (fsmonitor_unmarked_response) strvec_push(&cp.args, "--fsmonitor-unmarked-response"); if (fsmonitor_disconnect_first) @@ -994,6 +1020,9 @@ int cmd__simple_ipc(int argc, const char **argv) OPT_BOOL(0, "fsmonitor-pre-cookie-retirement", &fsmonitor_pre_cookie_retirement, N_("emulate a daemon without failed-cookie token retirement")), + OPT_BOOL(0, "fsmonitor-pre-provider-fence", + &fsmonitor_pre_provider_fence, + N_("emulate a Darwin daemon without provider fencing")), OPT_BOOL(0, "fsmonitor-unmarked-response", &fsmonitor_unmarked_response, N_("advertise token retirement but return an unmarked token")), diff --git a/t/t1602-index-witness.sh b/t/t1602-index-witness.sh index 88c8793bcf035f..2a13a5e1be2f80 100755 --- a/t/t1602-index-witness.sh +++ b/t/t1602-index-witness.sh @@ -381,7 +381,7 @@ test_index_witness_full_proof () { if $token ne $expected_token; } else { die "not a real builtin token in $path\n" - if $token !~ /^builtin:dirmeta-v1\.inode-v1\./; + if $token !~ /^builtin:dirmeta-v1\.inode-v1\.cookie-v1\.fence-v1\./; } for my $name (qw(FSMN FSUC)) { my $body = $ext{$name} // die "missing $name in $path\n"; @@ -615,7 +615,7 @@ test_lazy_prereq INDEX_WITNESS_SCRIPTED_IPC ' # before starting it, and keep all later fixture writes inside .git. if test_have_prereq MACOS then - index_witness_scripted_token=builtin:dirmeta-v1.inode-v1.cookie-v1.test-capable:0 + index_witness_scripted_token=builtin:dirmeta-v1.inode-v1.cookie-v1.fence-v1.test-capable:0 else index_witness_scripted_token=builtin:cookie-v1.test-capable:0 fi diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index b1dc6d4bb517b1..4771e93116b862 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -59,6 +59,10 @@ test_lazy_prereq HARDLINKS ' ln hardlink-a hardlink-b ' +test_lazy_prereq UNTRACKED_CACHE ' + git update-index --test-untracked-cache +' + test_lazy_prereq FSMONITOR_DIR_METADATA ' test "$uname_s" = Darwin || test "$uname_s" = Linux ' @@ -85,7 +89,8 @@ fi case "$uname_s" in Darwin) fsmonitor_pre_cookie_token_prefix=dirmeta-v1.inode-v1. - fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. + fsmonitor_pre_fence_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. + fsmonitor_cookie_token_prefix=${fsmonitor_pre_fence_token_prefix}fence-v1. ;; Linux) fsmonitor_pre_cookie_token_prefix=dirmeta-v1. @@ -166,6 +171,112 @@ start_daemon () { ) } +provider_rename_scope_run () { + repo=$1 + scope=$2 + fsm=$3 + uc=$4 + defer_path=$5 + + git init "$repo" || return 1 + ( + cd "$repo" && + mkdir dir1 dir2 && + printf "root\\n" >rename && + printf "one\\n" >dir1/rename && + printf "two\\n" >dir2/rename && + git add rename dir1/rename dir2/rename && + git commit -m base && + + if test "$uc" = true + then + git config core.untrackedCache true && + git update-index --untracked-cache + else + git config core.untrackedCache false && + git update-index --no-untracked-cache + fi && + if test "$fsm" = true + then + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_DEFER_PATH=$defer_path && + export GIT_TEST_FSMONITOR_DEFER_PATH && + start_daemon --tf "$PWD/../$repo.trace" + else + git config core.fsmonitor false + fi && + + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + printf "stale-root\\n" >new && + printf "stale-one\\n" >dir1/new && + printf "stale-two\\n" >dir2/new && + git status --porcelain=v2 >.git/stale-prime-1 && + git status --porcelain=v2 >.git/stale-prime-2 && + rm new dir1/new dir2/new && + + case "$scope" in + all) + mv rename renamed && + mv dir1/rename dir1/renamed && + mv dir2/rename dir2/renamed + ;; + root) + mv rename renamed + ;; + nested) + mv dir1/rename dir1/renamed && + mv dir2/rename dir2/renamed + ;; + *) + BUG "unknown provider rename scope: $scope" + ;; + esac && + + GIT_INDEX_FILE="$PWD/.git/cold.index" git read-tree HEAD && + GIT_INDEX_FILE="$PWD/.git/cold.index" \ + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 >.git/expect && + cp .git/index .git/index-before && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + test_cmp .git/index-before .git/index && + + if test "$fsm" = true + then + test_grep "test-defer-path: '$defer_path'" \ + "../$repo.trace" && + test_grep \ + "test-publish-deferred-path-at-provider-fence" \ + "../$repo.trace" && + test_grep "Darwin provider fence complete" \ + "../$repo.trace" + fi + ) + result=$? + maybe_timeout 30 git -C "$repo" fsmonitor--daemon stop \ + >/dev/null 2>&1 || : + rm -rf "$repo" + return $result +} + +cleanup_provider_rename_matrix () { + for scope in all root nested + do + for fsm in false true + do + for uc in false true + do + stop_daemon_delete_repo \ + "provider-rename-$scope-$fsm-$uc" + done + done + done +} + # Is a Trace2 data event present with the given catetory and key? # We do not care what the value is. # @@ -275,13 +386,84 @@ test_expect_success 'implicit daemon start' ' test_must_fail git -C test_implicit fsmonitor--daemon status ' -test_expect_success MACOS 'rescue a delayed FSEvents cookie after timeout' ' +test_expect_success MACOS \ + 'provider-fence client replaces an unfenced Darwin daemon' ' + test_when_finished "stop_daemon_delete_repo test_pre_fence" && + + git init test_pre_fence && + ( + cd test_pre_fence && + 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-pre-provider-fence && + test-tool simple-ipc send --name="$ipc_path" \ + --token=get-capabilities >.git/old-capabilities && + test_grep "^cookie-token-retirement-v1$" \ + .git/old-capabilities && + test_grep ! "^darwin-provider-fence-v1$" \ + .git/old-capabilities && + old_token="builtin:${fsmonitor_pre_fence_token_prefix}test-pre-fence: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/incompatible-daemon 1 \ + <.git/upgrade.trace && + test_grep \ + "\"argv\":.*\"fsmonitor--daemon\",\"run\",\"--detach\"" \ + .git/upgrade.trace && + git fsmonitor--daemon status + ) +' + +test_expect_success MACOS \ + 'provider-fence daemon retains the token marker for older clients' ' + test_when_finished "stop_daemon_delete_repo test_new_fence" && + + git init test_new_fence && + ( + cd test_new_fence && + 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-capability-superset && + test-tool simple-ipc send --name="$ipc_path" \ + --token=get-capabilities >.git/new-capabilities && + test_grep "^query-v1$" .git/new-capabilities && + test_grep "^query-v2$" .git/new-capabilities && + test_grep "^cookie-token-retirement-v1$" \ + .git/new-capabilities && + test_grep "^dir-metadata-filter-v1$" \ + .git/new-capabilities && + test_grep "^hardlink-inode-v1$" .git/new-capabilities && + test_grep "^darwin-provider-fence-v1$" \ + .git/new-capabilities && + test-tool simple-ipc send --name="$ipc_path" \ + --token="query-v2 pre-fence-client" \ + >.git/new-response && + test_grep "^builtin:${fsmonitor_pre_fence_token_prefix}" \ + .git/new-response && + test_grep "^builtin:${fsmonitor_cookie_token_prefix}" \ + .git/new-response + ) +' + +test_expect_success MACOS 'complete a delayed Darwin provider fence within budget' ' test_when_finished "stop_daemon_delete_repo test_delayed_cookie" && git init test_delayed_cookie && ( - GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=1200 && - export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS && + GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS=1200 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=3000 && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && start_daemon -C test_delayed_cookie \ --tf "$PWD/delayed-cookie.trace" --tk true ) && @@ -295,20 +477,63 @@ test_expect_success MACOS 'rescue a delayed FSEvents cookie after timeout' ' test_grep "^builtin:.*Q$" actual-q && test_grep ! "Q/Q" actual-q && test_grep ! "Q//Q" actual-q && - test_grep "cookie_wait: requesting FSEvents flush after initial timeout" \ - delayed-cookie.trace && + test_grep "Darwin provider fence requested" delayed-cookie.trace && + test_grep "Darwin provider fence begin" delayed-cookie.trace && + test_grep "Darwin provider fence complete" delayed-cookie.trace && test_grep "cookie-seen:" delayed-cookie.trace && - test_grep ! "cookie_wait timed out$" delayed-cookie.trace && + test_grep ! "synchronous flush timed out" delayed-cookie.trace && + test_must_be_empty error +' + +test_expect_success MACOS \ + 'real Darwin provider fence rescues a delayed cookie callback' ' + test_when_finished " + stop_daemon_delete_repo test_delayed_callback; + rm -f delayed-callback.trace + " && + + git init test_delayed_callback && + ( + GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=1500 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=3000 && + export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + start_daemon -C test_delayed_callback \ + --tf "$PWD/delayed-callback.trace" --tk true + ) && + + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && + test-tool -C test_delayed_callback fsmonitor-client query \ + --token "$token" >actual 2>error && + nul_to_q actual-q && + response=$(sed -n "s/Q.*//p" actual-q) && + test "${response%:*}" = "${token%:*}" && + test_grep "^builtin:.*Q$" actual-q && + test_grep ! "Q/Q" actual-q && + test_grep "cookie_wait timed out" delayed-callback.trace && + test_grep "Darwin provider fence begin" delayed-callback.trace && + test_grep "cookie-seen:" delayed-callback.trace && + test_grep "Darwin provider fence complete" \ + delayed-callback.trace && + test_grep ! "synchronous flush timed out" delayed-callback.trace && + perl -ne '\'' + $t ||= $. if /cookie_wait timed out/; + $b ||= $. if $t && /Darwin provider fence begin/; + $s ||= $. if $b && /cookie-seen:/; + $c ||= $. if $s && /Darwin provider fence complete/; + END { exit !($t && $b && $s && $c && + $t < $b && $b < $s && $s < $c) } + '\'' delayed-callback.trace && test_must_be_empty error ' -test_expect_success MACOS 'fall back when a delayed FSEvents cookie stays late' ' +test_expect_success MACOS 'retire a daemon when its provider fence times out' ' test_when_finished "stop_daemon_delete_repo test_lost_cookie" && git init test_lost_cookie && ( - GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=2500 && - export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS && + GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS=2500 && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS && start_daemon -C test_lost_cookie \ --tf "$PWD/lost-cookie.trace" --tk true ) && @@ -320,10 +545,538 @@ test_expect_success MACOS 'fall back when a delayed FSEvents cookie stays late' response=$(sed -n "s/Q.*//p" actual-q) && test "${response%:*}" != "${token%:*}" && test_grep "Q/Q$" actual-q && - test_grep "cookie_wait: requesting FSEvents flush after initial timeout" \ + test_grep "Darwin provider fence requested" lost-cookie.trace && + test_grep "cookie_wait: synchronous flush timed out" \ lost-cookie.trace && - test_grep "cookie_wait timed out$" lost-cookie.trace && - test_must_be_empty error + test_must_be_empty error && + test_must_fail git -C test_lost_cookie fsmonitor--daemon status && + test-tool -C test_lost_cookie fsmonitor-client query --token 0 \ + >restarted && + git -C test_lost_cookie fsmonitor--daemon status +' + +test_expect_success MACOS \ + 'retire a daemon when a callback blocks the real provider fence' ' + test_when_finished " + stop_daemon_delete_repo test_blocked_callback; + rm -f blocked-callback.trace + " && + + git init test_blocked_callback && + ( + GIT_TEST_FSMONITOR_COOKIE_DELAY_MS=2500 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=500 && + export GIT_TEST_FSMONITOR_COOKIE_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + start_daemon -C test_blocked_callback \ + --tf "$PWD/blocked-callback.trace" --tk true + ) && + + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && + test-tool -C test_blocked_callback fsmonitor-client query \ + --token "$token" >actual 2>error && + nul_to_q actual-q && + test_grep "Q/Q$" actual-q && + test_grep "cookie_wait timed out" blocked-callback.trace && + test_grep "Darwin provider fence begin" blocked-callback.trace && + test_grep "cookie_wait: synchronous flush timed out" \ + blocked-callback.trace && + test_grep ! "Darwin provider fence complete" \ + blocked-callback.trace && + test_must_be_empty error && + + # Start the replacement immediately. The timed-out daemon must not + # unlink a socket which the replacement has already claimed. + test-tool -C test_blocked_callback fsmonitor-client query --token 0 \ + >restarted && + git -C test_blocked_callback fsmonitor--daemon status +' + +test_expect_success MACOS \ + 'bypassing the Darwin provider fence reproduces stale status' ' + test_when_finished "stop_daemon_delete_repo test_provider_bypass" && + + git init test_provider_bypass && + ( + cd test_provider_bypass && + printf "base\\n" >tracked && + git add tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + GIT_TEST_FSMONITOR_DEFER_PATH=tracked && + GIT_TEST_FSMONITOR_FLUSH_SYNC_BYPASS=1 && + export GIT_TEST_FSMONITOR_DEFER_PATH && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_BYPASS && + start_daemon --tf "$PWD/../provider-bypass.trace" && + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + + printf "changed\\n" >>tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + cp .git/index .git/index-before && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 \ + >.git/actual && + test_grep "^1 \\.M .* tracked$" .git/expect && + test_must_be_empty .git/actual && + test_cmp .git/index-before .git/index && + test_grep "test-defer-path: .*tracked" \ + ../provider-bypass.trace && + test_grep "test-bypass-darwin-flush-sync" \ + ../provider-bypass.trace && + test_grep ! "test-publish-deferred-path-at-provider-fence" \ + ../provider-bypass.trace + ) +' + +test_expect_success MACOS \ + 'Darwin provider fence publishes older paths before returning' ' + test_when_finished "stop_daemon_delete_repo test_provider_fence" && + + git init test_provider_fence && + ( + cd test_provider_fence && + printf "base\\n" >tracked && + git add tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + GIT_TEST_FSMONITOR_DEFER_PATH=tracked && + export GIT_TEST_FSMONITOR_DEFER_PATH && + start_daemon --tf "$PWD/../provider-fence.trace" && + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + + printf "changed\\n" >>tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 \ + >.git/actual && + test_cmp .git/expect .git/actual && + test_grep "^1 \\.M .* tracked$" .git/actual && + test_grep "test-defer-path: '\''tracked'\''" \ + ../provider-fence.trace && + test_grep "test-publish-deferred-path-at-provider-fence" \ + ../provider-fence.trace && + perl -ne '\'' + $d ||= $. if /test-defer-path: .tracked./; + $s ||= $. if $d && /cookie-seen:/; + $p ||= $. if $s && + /test-publish-deferred-path-at-provider-fence/; + $c ||= $. if $p && /Darwin provider fence complete/; + END { exit !($d && $s && $p && $c && + $d < $s && $s < $p && $p < $c) } + '\'' ../provider-fence.trace + ) +' + +test_expect_success MACOS \ + 'Darwin provider fence rejects a token reset while in flight' ' + test_when_finished "stop_daemon_delete_repo test_provider_reset" && + + git init test_provider_reset && + ( + cd test_provider_reset && + GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS=1500 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=5000 && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + start_daemon --tf "$PWD/../provider-reset.trace" --tk true && + test-tool fsmonitor-client query --token 0 >.git/initial && + nul_to_q <.git/initial >.git/initial-q && + token=$(sed -n "s/Q.*//p" .git/initial-q) && + test -n "$token" && + : >../provider-reset.trace && + + { + test-tool fsmonitor-client query --token "$token" \ + >.git/in-flight 2>.git/in-flight.err & + query_pid=$! + } && + seen= && + for i in $(test_seq 1 100) + do + if grep "Darwin provider fence begin" \ + ../provider-reset.trace >/dev/null 2>&1 + then + seen=1 && + break + fi && + sleep 0.05 || return 1 + done && + test "$seen" = 1 && + test-tool fsmonitor-client flush >.git/reset && + wait "$query_pid" && + test_must_be_empty .git/in-flight.err && + nul_to_q <.git/in-flight >.git/in-flight-q && + test_grep "Q/Q$" .git/in-flight-q && + test_grep "provider fence crossed token generation" \ + ../provider-reset.trace && + test_grep ! "synchronous flush timed out" \ + ../provider-reset.trace && + git fsmonitor--daemon status + ) +' + +test_expect_success MACOS \ + 'Darwin provider fence discards a deferred path across token reset' ' + test_when_finished " + stop_daemon_delete_repo test_provider_deferred_reset; + rm -f provider-deferred-reset.trace + " && + + git init test_provider_deferred_reset && + ( + cd test_provider_deferred_reset && + printf "base\n" >tracked && + git add tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + GIT_TEST_FSMONITOR_DEFER_PATH=tracked && + GIT_TEST_FSMONITOR_DEFER_PATH_DELAY_MS=1500 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=5000 && + export GIT_TEST_FSMONITOR_DEFER_PATH && + export GIT_TEST_FSMONITOR_DEFER_PATH_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + start_daemon \ + --tf "$PWD/../provider-deferred-reset.trace" \ + --tk true && + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + test-tool fsmonitor-client query --token 0 >.git/initial && + nul_to_q <.git/initial >.git/initial-q && + token=$(sed -n "s/Q.*//p" .git/initial-q) && + test -n "$token" && + + : >../provider-deferred-reset.trace && + printf "changed\n" >>tracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + + { + test-tool fsmonitor-client query --token "$token" \ + >.git/in-flight 2>.git/in-flight.err & + query_pid=$! + } && + seen= && + for i in $(test_seq 1 100) + do + if grep "Darwin provider fence begin" \ + ../provider-deferred-reset.trace >/dev/null 2>&1 && + grep "test-defer-path: '\''tracked'\''" \ + ../provider-deferred-reset.trace >/dev/null 2>&1 + then + seen=1 && + break + fi && + sleep 0.05 || return 1 + done && + test "$seen" = 1 && + test-tool fsmonitor-client flush >.git/reset && + wait "$query_pid" && + test_must_be_empty .git/in-flight.err && + nul_to_q <.git/in-flight >.git/in-flight-q && + test_grep "Q/Q$" .git/in-flight-q && + test_grep "test-publish-deferred-path-at-provider-fence" \ + ../provider-deferred-reset.trace && + test_grep "test-discard-deferred-path-after-token-reset" \ + ../provider-deferred-reset.trace && + test_grep "provider fence crossed token generation" \ + ../provider-deferred-reset.trace && + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 \ + >.git/actual && + test_cmp .git/expect .git/actual && + git fsmonitor--daemon status + ) +' + +test_expect_success MACOS \ + 'watched-root shutdown retires an in-flight Darwin fence' ' + test_when_finished " + stop_daemon_delete_repo test_provider_root; + stop_daemon_delete_repo test_provider_root-away; + rm -f provider-root.trace + " && + + git init test_provider_root && + ( + cd test_provider_root && + GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS=1500 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=5000 && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + start_daemon --tf "$PWD/../provider-root.trace" --tk true + ) && + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && + : >provider-root.trace && + { + test-tool -C test_provider_root fsmonitor-client query \ + --token "$token" >test_provider_root/.git/in-flight \ + 2>test_provider_root/.git/in-flight.err & + query_pid=$! + } && + seen= && + for i in $(test_seq 1 100) + do + if grep "Darwin provider fence begin" provider-root.trace \ + >/dev/null 2>&1 + then + seen=1 && + break + fi && + sleep 0.05 || return 1 + done && + test "$seen" = 1 && + mv test_provider_root test_provider_root-away && + wait "$query_pid" && + test_must_be_empty test_provider_root-away/.git/in-flight.err && + nul_to_q test_provider_root-away/.git/in-flight-q && + test_grep "Q/Q$" test_provider_root-away/.git/in-flight-q && + test_grep "event: root changed" provider-root.trace && + test_must_fail git -C test_provider_root-away \ + fsmonitor--daemon status && + test-tool -C test_provider_root-away fsmonitor-client query \ + --token 0 >test_provider_root-away/.git/restarted && + git -C test_provider_root-away fsmonitor--daemon status +' + +test_expect_success MACOS \ + 'explicit shutdown drains an in-flight Darwin fence' ' + test_when_finished "stop_daemon_delete_repo test_provider_stop" && + + git init test_provider_stop && + ( + cd test_provider_stop && + GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS=1200 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=3000 && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + start_daemon --tf "$PWD/../provider-stop.trace" --tk true + ) && + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && + : >provider-stop.trace && + { + test-tool -C test_provider_stop fsmonitor-client query \ + --token "$token" >test_provider_stop/.git/in-flight \ + 2>test_provider_stop/.git/in-flight.err & + query_pid=$! + } && + seen= && + for i in $(test_seq 1 100) + do + if grep "Darwin provider fence begin" provider-stop.trace \ + >/dev/null 2>&1 + then + seen=1 && + break + fi && + sleep 0.05 || return 1 + done && + test "$seen" = 1 && + { + git -C test_provider_stop fsmonitor--daemon stop \ + >test_provider_stop/.git/stop.out \ + 2>test_provider_stop/.git/stop.err & + stop_pid=$! + } && + wait "$query_pid" && + wait "$stop_pid" && + test_must_be_empty test_provider_stop/.git/in-flight.err && + test_must_be_empty test_provider_stop/.git/stop.err && + nul_to_q test_provider_stop/.git/in-flight-q && + response=$(sed -n "s/Q.*//p" \ + test_provider_stop/.git/in-flight-q) && + test "${response%:*}" = "${token%:*}" && + test_grep ! "Q/Q$" test_provider_stop/.git/in-flight-q && + test_grep "Darwin provider fence complete" provider-stop.trace && + test_grep ! "abandoned during shutdown" provider-stop.trace && + test_must_fail git -C test_provider_stop fsmonitor--daemon status +' + +test_expect_success MACOS \ + 'concurrent Darwin queries coalesce without hiding dirt' ' + test_when_finished "stop_daemon_delete_repo test_provider_coalesce" && + + git init test_provider_coalesce && + ( + cd test_provider_coalesce && + mkdir tracked-dir && + printf "base-a\\n" >tracked-a && + printf "base-b\\n" >tracked-dir/tracked-b && + git add tracked-a tracked-dir/tracked-b && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + GIT_TEST_FSMONITOR_FLUSH_COALESCE_DELAY_MS=500 && + export GIT_TEST_FSMONITOR_FLUSH_COALESCE_DELAY_MS && + start_daemon --tf "$PWD/../provider-coalesce.trace" && + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + + printf "dirty\\n" >>tracked-a && + printf "untracked\\n" >tracked-dir/untracked && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >.git/expect && + : >../provider-coalesce.trace && + + pids= && + { + for i in $(test_seq 1 8) + do + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 \ + >.git/actual-$i 2>.git/error-$i & + pids="$pids $!" || return 1 + done && + for pid in $pids + do + wait "$pid" || return 1 + done + } && + for i in $(test_seq 1 8) + do + test_cmp .git/expect .git/actual-$i || return 1 + test_must_be_empty .git/error-$i || return 1 + done && + requests=$(grep -c "Darwin provider fence requested" \ + ../provider-coalesce.trace) && + fences=$(grep -c "Darwin provider fence begin" \ + ../provider-coalesce.trace) && + test "$requests" -ge 8 && + test "$fences" -lt "$requests" && + test_grep "coalesced=[2-9]" ../provider-coalesce.trace && + test_grep ! "synchronous flush timed out" \ + ../provider-coalesce.trace && + git fsmonitor--daemon status + ) +' + +test_expect_success MACOS \ + 'queries arriving during a Darwin fence require a second fence' ' + test_when_finished " + stop_daemon_delete_repo test_provider_two_wave; + rm -f provider-two-wave.trace + " && + + git init test_provider_two_wave && + ( + cd test_provider_two_wave && + GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS=2500 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=7000 && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + start_daemon --tf "$PWD/../provider-two-wave.trace" \ + --tk true + ) && + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && + : >provider-two-wave.trace && + { + test-tool -C test_provider_two_wave fsmonitor-client query \ + --token "$token" >test_provider_two_wave/.git/q1 \ + 2>test_provider_two_wave/.git/q1.err & + q1_pid=$! + } && + seen= && + for i in $(test_seq 1 100) + do + if grep "Darwin provider fence begin request=1" \ + provider-two-wave.trace >/dev/null 2>&1 + then + seen=1 && + break + fi && + sleep 0.05 || return 1 + done && + test "$seen" = 1 && + { + test-tool -C test_provider_two_wave fsmonitor-client query \ + --token "$token" >test_provider_two_wave/.git/q2 \ + 2>test_provider_two_wave/.git/q2.err & + q2_pid=$! + } && + seen= && + for i in $(test_seq 1 100) + do + if grep "Darwin provider fence requested request=2" \ + provider-two-wave.trace >/dev/null 2>&1 + then + seen=1 && + break + fi && + sleep 0.05 || return 1 + done && + test "$seen" = 1 && + wait "$q1_pid" && + wait "$q2_pid" && + test_must_be_empty test_provider_two_wave/.git/q1.err && + test_must_be_empty test_provider_two_wave/.git/q2.err && + nul_to_q test_provider_two_wave/.git/q1-q && + nul_to_q test_provider_two_wave/.git/q2-q && + test_grep ! "Q/Q$" test_provider_two_wave/.git/q1-q && + test_grep ! "Q/Q$" test_provider_two_wave/.git/q2-q && + test "$(grep -c "Darwin provider fence begin" \ + provider-two-wave.trace)" = 2 && + test "$(grep -c "Darwin provider fence complete" \ + provider-two-wave.trace)" = 2 && + perl -ne '\'' + $b1 ||= $. if /fence begin request=1/; + $c1 ||= $. if $b1 && /fence complete request=1/; + $b2 ||= $. if $c1 && /fence begin request=2/; + $c2 ||= $. if $b2 && /fence complete request=2/; + END { exit !($b1 && $c1 && $b2 && $c2 && + $b1 < $c1 && $c1 < $b2 && $b2 < $c2) } + '\'' provider-two-wave.trace && + git -C test_provider_two_wave fsmonitor--daemon status +' + +test_expect_success MACOS,UNTRACKED_CACHE \ + 'Darwin provider fence covers rename scopes and cache controls' ' + test_when_finished cleanup_provider_rename_matrix && + + for scope in all root nested + do + case "$scope" in + all|root) + defer_path=rename + ;; + nested) + defer_path=dir1/rename + ;; + esac && + for fsm in false true + do + for uc in false true + do + # The UC-only control is covered by the main matrix + # and is known to be timing-sensitive on macOS. + if test "$fsm" = false && test "$uc" = true + then + continue + fi && + provider_rename_scope_run \ + "provider-rename-$scope-$fsm-$uc" \ + "$scope" "$fsm" "$uc" "$defer_path" || + return 1 + done + done + done ' test_expect_success MACOS 'fresh unpinned batches honor the retention grace' ' @@ -1022,10 +1775,6 @@ test_expect_success 'cleanup worktrees' ' # data) from fsmonitor doesn't cause incorrect results. And doesn't # cause incorrect results when the untracked-cache is enabled. -test_lazy_prereq UNTRACKED_CACHE ' - git update-index --test-untracked-cache -' - test_expect_success 'Matrix: setup for untracked-cache,fsmonitor matrix' ' test_unconfig core.fsmonitor && git update-index --no-fsmonitor && @@ -2027,6 +2776,7 @@ test_expect_success FSMONITOR_LINUX \ test_create_repo inotify-nameless && ( cd inotify-nameless && + sane_unset GIT_TEST_SPLIT_INDEX && mkdir -p existing/inner && test_write_lines tracked >existing/inner/tracked && git add existing/inner/tracked && @@ -2035,6 +2785,7 @@ test_expect_success FSMONITOR_LINUX \ start_daemon --tf "$PWD/.git/daemon.trace" && git status --porcelain=v2 >.git/prime && test_must_be_empty .git/prime && + git update-index --force-write-index && chmod 750 existing/inner && test-tool fsmonitor-client query >.git/chmod.raw && @@ -5141,6 +5892,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP native_stash_setup "$repo" "$mode" && ( cd "$repo" && + sane_unset GIT_TEST_SPLIT_INDEX && native_stash_create_policy_file .gitattributes && native_stash_reset && native_stash_create_policy_file .gitignore && @@ -5224,6 +5976,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP fi && ( cd "$worktree" && + sane_unset GIT_TEST_SPLIT_INDEX && git fsmonitor--daemon status >/dev/null 2>&1 || start_daemon && index=$(git rev-parse --git-path index) && diff --git a/t/t7533-status-scoped-stash.sh b/t/t7533-status-scoped-stash.sh index 5201d798d46fd1..d638cf21c27bc0 100755 --- a/t/t7533-status-scoped-stash.sh +++ b/t/t7533-status-scoped-stash.sh @@ -477,6 +477,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP scoped_stash_setup scoped-stash-whole && ( cd scoped-stash-whole && + sane_unset GIT_TEST_SPLIT_INDEX && test_write_lines dirty >tracked && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCCCCCCCCCC \ GIT_TEST_FSMONITOR_QUERY_PATH=tracked \ diff --git a/t/t7537-fsmonitor-cookie-compat.sh b/t/t7537-fsmonitor-cookie-compat.sh index e3ae98de78ea3d..2c96eda96e1f6d 100755 --- a/t/t7537-fsmonitor-cookie-compat.sh +++ b/t/t7537-fsmonitor-cookie-compat.sh @@ -13,7 +13,7 @@ fi if test_have_prereq MACOS then fsmonitor_pre_cookie_token_prefix=dirmeta-v1.inode-v1. - fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1. + fsmonitor_cookie_token_prefix=${fsmonitor_pre_cookie_token_prefix}cookie-v1.fence-v1. elif test "$uname_s" = Linux then fsmonitor_pre_cookie_token_prefix=dirmeta-v1. From 5c54f6390169d4c2f02a28c9774e7de4580d8461 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 23:16:47 -0500 Subject: [PATCH 419/432] fsmonitor: allow .git components in daemon events ae161e84af (fsmonitor: validate builtin daemon responses before applying them, 2026-07-10) validates each worktree path with verify_path(). That helper enforces index-entry rules and rejects a .git component anywhere in a path. Filesystem providers can legitimately report such a component for an untracked nested repository. The client therefore rejects the entire response after an event such as scratch/.git/file, forcing a full worktree scan. Commands that need a current provider boundary cannot persist a clean proof from that query. Validate the narrower daemon-response contract instead: require a relative path with nonempty, non-dot components and at most one trailing separator. Keep rejecting absolute and traversal paths, but allow .git components that already exist in the worktree. Cover the parser directly and exercise a real Linux daemon event against an optional-lock-free status oracle. --- fsmonitor.c | 30 ++++++++++++++++++----------- t/t7527-builtin-fsmonitor.sh | 28 +++++++++++++++++++++++++++ t/unit-tests/u-fsmonitor-response.c | 6 ++++++ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/fsmonitor.c b/fsmonitor.c index 1725b06d34363f..2e463dcf100726 100644 --- a/fsmonitor.c +++ b/fsmonitor.c @@ -943,21 +943,29 @@ void fsmonitor_format_worktree_paths( static int fsmonitor_valid_worktree_path(const char *path, size_t len) { - struct strbuf copy = STRBUF_INIT; - int valid = 0; + size_t component = 0, i; 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); + if (is_dir_sep(path[len - 1]) && + (!--len || is_dir_sep(path[len - 1]))) + return 0; -done: - strbuf_release(©); - return valid; + for (i = 0; i <= len; i++) { + size_t component_len; + + if (i < len && !is_dir_sep(path[i])) + continue; + component_len = i - component; + if (!component_len || + (component_len == 1 && path[component] == '.') || + (component_len == 2 && path[component] == '.' && + path[component + 1] == '.')) + return 0; + component = i + 1; + } + + return 1; } static int fsmonitor_parse_hardlink_inode(const char *path, size_t len, diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 4771e93116b862..d32fca02dfc5f2 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2797,6 +2797,34 @@ test_expect_success FSMONITOR_LINUX \ ) ' +test_expect_success FSMONITOR_LINUX \ + 'nested Git directory events remain valid' ' + test_when_finished \ + "git -C nested-dotgit fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo nested-dotgit && + ( + cd nested-dotgit && + test_write_lines tracked >tracked && + git add tracked && + git commit -qm base && + git config core.fsmonitor true && + start_daemon && + git status --porcelain=v2 >.git/prime && + test_must_be_empty .git/prime && + + mkdir -p scratch/.git && + test_write_lines nested >scratch/.git/file && + GIT_OPTIONAL_LOCKS=0 git -c core.fsmonitor=false \ + status --porcelain=v2 >.git/expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/status.trace" \ + git status --porcelain=v2 >.git/actual && + test_cmp .git/expect .git/actual && + ! test_trace2_data fsm_client query/invalid-response 1 \ + <.git/status.trace + ) +' + test_expect_success MACOS 'implicit daemon reuses the invoking Git executable' ' test_create_repo same-executable-spawn && mkdir fake-exec-path && diff --git a/t/unit-tests/u-fsmonitor-response.c b/t/unit-tests/u-fsmonitor-response.c index 1b024d72f6dea5..0f9016ebe0c1ee 100644 --- a/t/unit-tests/u-fsmonitor-response.c +++ b/t/unit-tests/u-fsmonitor-response.c @@ -103,6 +103,8 @@ void test_fsmonitor_response__accepts_valid_builtin_responses(void) static const char directory_path[] = "nested/\0"; static const char both_paths[] = "merged\0merged/\0"; static const char case_path[] = "Tracked\0"; + static const char nested_git[] = + "builtin:12\0scratch/.git/file\0scratch/.git/\0"; check_response(delta, sizeof(delta) - 1, FSMONITOR_QUERY_DELTA, "builtin:2", delta + sizeof("builtin:2"), @@ -112,6 +114,10 @@ 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_response(nested_git, sizeof(nested_git) - 1, + FSMONITOR_QUERY_DELTA, "builtin:12", + nested_git + sizeof("builtin:12"), + sizeof(nested_git) - 1 - sizeof("builtin:12")); check_worktree_event(stale_root, strlen("/repo"), 0, 1, global_path, sizeof(global_path) - 1); From 16d98d8ce7bdc879742bc1a47c610f96f126d306 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 23:17:14 -0500 Subject: [PATCH 420/432] sequencer: finish final-conflict rebases cleanly fbd7a23237 (rebase: introduce and use pseudo-ref REBASE_HEAD, 2018-02-11) records the commit currently being replayed. The sequencer normally deletes that ref before executing each todo item. When the final item stops for a conflict, rebase --continue commits the resolved result. pick_commits() then reaches the end of the list without entering another iteration and removes the rebase state directly. The merge backend skips finish_rebase() because the sequencer owns cleanup, so REBASE_HEAD survives a successful rebase. The same path also strands the fsmonitor proof when index.skipHash is enabled. After committing the resolution, the sequencer reloads the canonical index and repairs its proof through a close-only index.lock witness. A skip-hash witness has a null trailer and a fresh file identity, so proof-epoch validation cannot bind it to the in-memory index. Rebase succeeds without FSUC, and read-only status cannot repair it. Delete REBASE_HEAD whenever interactive-rebase state is removed. This matches finish_rebase() cleanup and also avoids retaining a ref for an explicitly quit operation. Propagate a failed deletion so rebase does not report success after leaving the stale ref behind. Give only PROVISIONAL_LOCK witnesses a real checksum. The final index rewrite continues to honor index.skipHash, preserving the normal index write fast path while giving proof repair an authenticated epoch. Extend the final-conflict test to require REBASE_HEAD during resolution, its removal after completion, and a reported failure when the ref cannot be deleted. Exercise skip-hash proof repair after a clean-prefix, conflicted replay in primary and linked worktrees, with plain and configured-filter repositories. --- read-cache.c | 9 ++++++++- sequencer.c | 5 +++++ t/t3418-rebase-continue.sh | 17 ++++++++++++++++- t/t7527-builtin-fsmonitor.sh | 25 +++++++++++++++++++++---- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/read-cache.c b/read-cache.c index 551823805f5b1b..87ae9b508ed8d3 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3535,7 +3535,14 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, f = hashfd(the_repository->hash_algo, tempfile->fd, tempfile->filename.buf); prepare_repo_settings(r); - f->skip_hash = r->settings.index_skip_hash; + /* + * A provisional lock is a proof witness for the current in-memory + * index. Its fresh file identity cannot authenticate a null trailer, + * so give only that witness a checksum. The final index write still + * honors index.skipHash. + */ + f->skip_hash = r->settings.index_skip_hash && + !(flags & PROVISIONAL_LOCK); for (i = removed = extended = 0; i < entries; i++) { if (cache[i]->ce_flags & CE_REMOVE) diff --git a/sequencer.c b/sequencer.c index 295df2eb383a23..e2ddd1ae0401f2 100644 --- a/sequencer.c +++ b/sequencer.c @@ -433,6 +433,11 @@ int sequencer_remove_state(struct replay_opts *opts) struct strbuf buf = STRBUF_INIT; int ret = 0; + if (is_rebase_i(opts) && + refs_delete_ref(get_main_ref_store(the_repository), NULL, + "REBASE_HEAD", NULL, REF_NO_DEREF)) + ret = -1; + if (is_rebase_i(opts) && strbuf_read_file(&buf, rebase_path_refs_to_delete(), 0) > 0) { char *p = buf.buf; diff --git a/t/t3418-rebase-continue.sh b/t/t3418-rebase-continue.sh index cb5c3a1cb5bc6f..d6bfd73b9a2905 100755 --- a/t/t3418-rebase-continue.sh +++ b/t/t3418-rebase-continue.sh @@ -35,10 +35,25 @@ test_expect_success 'merge based rebase --continue removes .git/MERGE_MSG' ' git checkout -f --detach topic && test_must_fail git rebase --onto main HEAD^ && + test_cmp_rev REBASE_HEAD topic && git read-tree --reset -u HEAD && test_path_is_file .git/MERGE_MSG && git rebase --continue && - test_path_is_missing .git/MERGE_MSG + test_path_is_missing .git/MERGE_MSG && + test_must_fail git rev-parse --verify REBASE_HEAD +' + +test_expect_success REFFILES 'merge based rebase --continue reports REBASE_HEAD cleanup failure' ' + git checkout -f --detach topic && + + test_must_fail git rebase --onto main HEAD^ && + git read-tree --reset -u HEAD && + test_when_finished "rm -f .git/REBASE_HEAD .git/REBASE_HEAD.lock" && + >.git/REBASE_HEAD.lock && + test_must_fail git rebase --continue 2>err && + test_grep "cannot lock ref.*REBASE_HEAD" err && + test_path_is_missing .git/rebase-merge && + test_path_is_file .git/REBASE_HEAD ' test_expect_success 'apply based rebase --continue works with touched file' ' diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index d32fca02dfc5f2..70512c2d9555b6 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -5933,7 +5933,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP ' test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS,!MINGW \ - 'completed native replay retains a full proof' ' + 'completed native replay retains a full proof with skip-hash index' ' for mode in plain lfs do for location in primary linked @@ -5970,6 +5970,9 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP git add conflict && git commit -qm upstream && git switch -qc topic "$base" && + test_write_lines prefix >prefix && + git add prefix && + git commit -qm prefix && test_write_lines topic >conflict && git add conflict && git commit -qm topic && @@ -5978,6 +5981,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP git config core.checkStat default && git config core.untrackedCache true && git config core.fsmonitor true && + git config index.skipHash true && git config core.preloadIndex true && git config core.preloadIndexBulk true && if test "$mode" = lfs @@ -6044,6 +6048,11 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP test_must_fail git cherry-pick "$topic" ;; esac && + if test "$replay" != cherry-pick + then + test_path_is_file prefix && + test "$(git log -1 --format=%s)" = prefix + fi && test -n "$(git ls-files -u)" && test_grep ! FSUC "$index" && test_write_lines resolved >conflict && @@ -6060,16 +6069,24 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP <"$artifact.add.trace" && if test "$replay" = cherry-pick then - GIT_EDITOR=true git cherry-pick --continue + GIT_TRACE2_EVENT="$artifact.continue.trace" \ + GIT_EDITOR=true git cherry-pick --continue else - GIT_EDITOR=true git rebase --continue + GIT_TRACE2_EVENT="$artifact.continue.trace" \ + GIT_EDITOR=true git rebase --continue fi && if test "$replay" = cherry-pick then native_tracked_full_proof "$index" && test_grep ! FSUC "$index" else - native_stash_full_proof "$index" + native_stash_full_proof "$index" && + test_trace2_data fsmonitor \ + history/writer-proof-repaired 1 \ + <"$artifact.continue.trace" && + ! test_trace2_data fsmonitor \ + history/writer-proof-repaired 0 \ + <"$artifact.continue.trace" fi && cp "$index" "$artifact.index.before" && GIT_OPTIONAL_LOCKS=0 \ From 9b09d49c9c40cb0b2c09907e99ae593e51d92942 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 28 Aug 2026 01:12:29 -0500 Subject: [PATCH 421/432] wt-status: replace authenticated untracked output on reopen A proof repair can close one provider token, collect untracked results, reopen the token, and close it again with the same struct wt_status. If the first closure published untracked output, the second closure tries to publish another snapshot over it and hits: BUG: publishing untracked results over collected status This is reachable from stash pop when an index writer repairs a complete FSMonitor proof while an untracked path is present. Before closing a required new token, discard output explicitly marked as coming from an earlier authenticated token closure or bulk preload. Keep the BUG for ordinary caller-collected results, which must not be silently overwritten. Extend refresh invalidation to discard both authenticated forms as well. Allow the scripted provider to opt into proof repair, and add a regression covering the two-token stash path with visible untracked output. --- t/t7519-status-fsmonitor.sh | 42 +++++++++++++++++++++++++++++++++++++ wt-status.c | 27 ++++++++++++++++++------ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 6b04326cc81cc2..eebee5f61c9949 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -4429,6 +4429,48 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'stash proof repair replaces authenticated untracked output' ' + test_when_finished "rm -rf stash-reopened-untracked" && + test_create_repo stash-reopened-untracked && + ( + cd stash-reopened-untracked && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines tracked >tracked && + test_write_lines baseline >state && + git add tracked state && + git commit -m base && + git config feature.manyFiles true && + git config index.version 4 && + 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 >visible && + test_write_lines temporary >state && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=state \ + git stash push -qm diagnostic -- state && + test_fsmonitor_full_proof .git/index paired && + GIT_TEST_FSMONITOR_ALLOW_PROOF_REPAIR_SEQUENCE=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CDCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=state \ + GIT_TRACE2_EVENT="$PWD/.git/stash-pop.trace" \ + git stash pop >.git/stash-pop && + test_grep "visible" .git/stash-pop && + test_write_lines temporary >.git/state.expect && + test_cmp .git/state.expect state && + test_trace2_data status \ + untracked/replaced-authenticated-snapshot 1 \ + <.git/stash-pop.trace + ) +' + test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ 'builtin initial trivial response anchors a closure' ' test_when_finished "rm -rf builtin-initial-trivial" && diff --git a/wt-status.c b/wt-status.c index 83497130d6548f..1e02226d0bc452 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1731,6 +1731,18 @@ static int wt_status_collect_untracked(struct wt_status *s) s, &s->untracked, &s->ignored); } +static int wt_status_clear_authenticated_untracked(struct wt_status *s) +{ + if (!s->untracked_from_token_closure && !s->untracked_from_preload) + return 0; + + string_list_clear(&s->untracked, 0); + string_list_clear(&s->ignored, 0); + s->untracked_from_token_closure = 0; + s->untracked_from_preload = 0; + return 1; +} + #define FSMONITOR_TOKEN_MAX_QUERIES 3 struct wt_status_token_closure { @@ -2273,6 +2285,11 @@ static int wt_status_close_fsmonitor_token( int preserve_untracked, token_accepted = 0; refresh_fsmonitor(istate); + if (require_untracked && fsmonitor_has_pending_token(istate) && + wt_status_clear_authenticated_untracked(s)) + trace2_data_intmax( + "status", s->repo, + "untracked/replaced-authenticated-snapshot", 1); preserve_untracked = !require_untracked && s->show_untracked_files == SHOW_NO_UNTRACKED_FILES && !s->show_ignored_mode && !s->pathspec.nr && @@ -2455,7 +2472,9 @@ static int fsmonitor_proof_repair_is_eligible(struct repository *repo) const char *test_sequence = getenv("GIT_TEST_FSMONITOR_QUERY_SEQUENCE"); - if ((test_sequence && *test_sequence) || !fstat_is_reliable() || + if ((test_sequence && *test_sequence && + !getenv("GIT_TEST_FSMONITOR_ALLOW_PROOF_REPAIR_SEQUENCE")) || + !fstat_is_reliable() || getenv(INDEX_ENVIRONMENT) || getenv(GIT_WORK_TREE_ENVIRONMENT) || getenv(GIT_COMMON_DIR_ENVIRONMENT) || getenv(DB_ENVIRONMENT) || @@ -2641,11 +2660,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); - s->untracked_from_token_closure = 0; - } + wt_status_clear_authenticated_untracked(s); wt_status_release_exclude_proof(s); wt_status_release_attr_snapshot(s); if (!s->pathspec.nr && !istate->split_index && From 0f0eb01a3556558e7a2c5b28392c5b4fec710f5a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 28 Aug 2026 04:25:02 -0500 Subject: [PATCH 422/432] merge: retain clean proofs across retries and resolve undo An fsmonitor provider can reset while merge is reading an index with an authenticated clean-status proof. The reset leaves that proof available for revalidation, but merge updates the worktree before repairing it. The resulting index can lose FSUC after a clean merge. Read-only status cannot persist the missing proof, so every later status falls back. A multi-strategy merge can lose the same history after preparation. An external strategy may replace the index before it declines or reports a conflict. restore_state() then reloads that index while rewinding the worktree. A later built-in strategy sees the original repair decision, but no longer has the paired proof from which to repair. Resolved conflicts expose a separate instance of the same failure. merge clears the resolve-undo extension before updating the worktree. That removal sets RESOLVE_UNDO_CHANGED, which prevented checkout from transferring an otherwise current proof. The result had neither a live provider token nor a pending token from which the writer could repair. Revalidate an authenticated proof before a non-fast-forward merge updates the worktree. Repair it before built-in results are published, after successful external strategies, and after each restore_state() rewind. Permit transfer after the resolve-undo map has been cleared, since removing that optional extension changes neither tracked entries nor worktree contents. Continue rejecting a live resolve-undo map. A repaired writer stats only entries that lack provider validation or stat data before certifying the new index. Fast-forward merges retain their existing path, while conflicts continue to fail closed. Cover built-in ort with and without retained resolve-undo history, trivial and content-level resolve merges, an external strategy that declines, and one that leaves a three-stage conflict before a clean ort retry. Verify that clean results remove resolve-undo data, keep a paired proof, and leave repeated read-only status unable to rewrite the index. --- builtin/merge.c | 110 +++++++++++++++++++---- clean-status-history.c | 6 +- t/t7519-status-fsmonitor.sh | 173 ++++++++++++++++++++++++++++++++++++ wt-status.c | 72 +++++++++++++++ wt-status.h | 2 + 5 files changed, 343 insertions(+), 20 deletions(-) diff --git a/builtin/merge.c b/builtin/merge.c index 82a276074329d6..3c8dc78f3d07f4 100644 --- a/builtin/merge.c +++ b/builtin/merge.c @@ -44,6 +44,7 @@ #include "merge-ort-wrappers.h" #include "resolve-undo.h" #include "remote.h" +#include "reset.h" #include "fmt-merge-msg.h" #include "sequencer.h" #include "string-list.h" @@ -392,13 +393,13 @@ static void read_empty(const struct object_id *oid) static void reset_hard(const struct object_id *oid) { - struct child_process cmd = CHILD_PROCESS_INIT; - - strvec_pushl(&cmd.args, "read-tree", "-v", "--reset", "-u", - oid_to_hex(oid), NULL); - cmd.git_cmd = 1; + struct reset_working_tree_options opts = { + .oid = oid, + .flags = RESET_WORKING_TREE_HARD | + RESET_WORKING_TREE_PRESERVE_SEMANTIC_HISTORY, + }; - if (run_command(&cmd)) + if (reset_working_tree(the_repository, &opts) < 0) die(_("read-tree failed")); } @@ -759,6 +760,9 @@ static int read_tree_trivial(struct object_id *common, struct object_id *head, opts.trivial_merges_only = 1; opts.merge = 1; opts.preserve_ignored = 0; /* FIXME: !overwrite_ignore */ + opts.preserve_semantic_history = + clean_status_revalidated_token_matches(the_repository->index); + opts.preserve_untracked_history = opts.preserve_semantic_history; trees[nr_trees] = repo_parse_tree_indirect(the_repository, common); if (!trees[nr_trees++]) return -1; @@ -788,19 +792,50 @@ static void write_tree_trivial(struct object_id *oid) die(_("git write-tree failed to write a tree")); } +/* + * An external strategy may replace the index before it succeeds or fails. + * After a clean result, or after restore_state() has put the index and + * worktree back into a known state, certify that state instead of trusting + * history left by the child process. + */ +static void repair_merge_fsmonitor_proof(int had_full_proof) +{ + struct lock_file lock = LOCK_INIT; + + if (!had_full_proof) + return; + if (repo_hold_locked_index(the_repository, &lock, + LOCK_REPORT_ON_ERROR) < 0) + die(_("unable to write new index file")); + if (wt_status_repair_fsmonitor_proof_after_worktree_update( + the_repository, &lock, had_full_proof) < 0) { + rollback_lock_file(&lock); + die(_("unable to repair new index file")); + } + if (write_locked_index(the_repository->index, &lock, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) + die(_("unable to write new index file")); +} + static int try_merge_strategy(const char *strategy, struct commit_list *common, struct commit_list *remoteheads, - struct commit *head) + struct commit *head, int repair_after_merge) { const char *head_arg = "HEAD"; + int use_builtin_strategy = !strcmp(strategy, "recursive") || + !strcmp(strategy, "subtree") || !strcmp(strategy, "ort"); + + if (use_builtin_strategy && !repair_after_merge) + repair_after_merge = + wt_status_prepare_fsmonitor_proof_for_worktree_update( + the_repository); if (repo_refresh_and_write_index(the_repository, REFRESH_QUIET, SKIP_IF_UNCHANGED, 0, NULL, NULL, NULL) < 0) die(_("Unable to write index.")); - if (!strcmp(strategy, "recursive") || !strcmp(strategy, "subtree") || - !strcmp(strategy, "ort")) { + if (use_builtin_strategy) { struct lock_file lock = LOCK_INIT; int clean, x; struct commit *result; @@ -841,14 +876,24 @@ static int try_merge_strategy(const char *strategy, struct commit_list *common, rollback_lock_file(&lock); return 2; } + if (wt_status_repair_fsmonitor_proof_after_worktree_update( + the_repository, &lock, repair_after_merge) < 0) { + rollback_lock_file(&lock); + die(_("unable to repair new index file")); + } if (write_locked_index(the_repository->index, &lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) die(_("unable to write %s"), repo_get_index_file(the_repository)); return clean ? 0 : 1; } else { - return try_merge_command(the_repository, - strategy, xopts.nr, xopts.v, - common, head_arg, remoteheads); + int ret = try_merge_command(the_repository, + strategy, xopts.nr, xopts.v, + common, head_arg, remoteheads); + + if (!ret) + repair_merge_fsmonitor_proof( + repair_after_merge); + return ret; } } @@ -988,14 +1033,28 @@ static void prepare_to_commit(struct commit_list *remoteheads) strbuf_release(&msg); } -static int merge_trivial(struct commit *head, struct commit_list *remoteheads) +static int merge_trivial(struct commit *head, struct commit_list *remoteheads, + int repair_after_merge) { + struct lock_file lock = LOCK_INIT; struct object_id result_tree, result_commit; struct commit_list *parents = NULL, **pptr = &parents; + int refresh_error; - if (repo_refresh_and_write_index(the_repository, REFRESH_QUIET, - SKIP_IF_UNCHANGED, 0, NULL, NULL, - NULL) < 0) + if (repo_hold_locked_index(the_repository, &lock, + LOCK_REPORT_ON_ERROR) < 0) + return error(_("Unable to write index.")); + refresh_error = refresh_index(the_repository->index, REFRESH_QUIET, + NULL, NULL, NULL); + if (wt_status_repair_fsmonitor_proof_after_worktree_update( + the_repository, &lock, repair_after_merge) < 0) { + rollback_lock_file(&lock); + return error(_("unable to repair new index file")); + } + if (write_locked_index(the_repository->index, &lock, + COMMIT_LOCK | SKIP_IF_UNCHANGED)) + return error(_("Unable to write index.")); + if (refresh_error) return error(_("Unable to write index.")); write_tree_trivial(&result_tree); @@ -1372,6 +1431,7 @@ int cmd_merge(int argc, struct strbuf buf = STRBUF_INIT; int i, ret = 0, head_subsumed; int best_cnt = -1, merge_was_ok = 0, automerge_was_ok = 0; + int repair_after_merge = 0; struct commit_list *common = NULL; const char *best_strategy = NULL, *wt_strategy = NULL; struct commit_list *remoteheads = NULL, *p; @@ -1732,10 +1792,14 @@ int cmd_merge(int argc, /* See if it is really trivial. */ git_committer_info(IDENT_STRICT); printf(_("Trying really trivial in-index merge...\n")); + repair_after_merge = + wt_status_prepare_fsmonitor_proof_for_worktree_update( + the_repository); if (!read_tree_trivial(&common->item->object.oid, &head_commit->object.oid, &remoteheads->item->object.oid)) { - ret = merge_trivial(head_commit, remoteheads); + ret = merge_trivial(head_commit, remoteheads, + repair_after_merge); goto done; } printf(_("Nope.\n")); @@ -1767,6 +1831,10 @@ int cmd_merge(int argc, if (fast_forward == FF_ONLY) die_ff_impossible(); + repair_after_merge = + wt_status_prepare_fsmonitor_proof_for_worktree_update( + the_repository); + if (autostash) create_autostash_ref(the_repository, "MERGE_AUTOSTASH", NULL, false); @@ -1794,6 +1862,7 @@ int cmd_merge(int argc, if (i) { printf(_("Rewinding the tree to pristine...\n")); restore_state(&head_commit->object.oid, &stash); + repair_merge_fsmonitor_proof(repair_after_merge); } if (use_strategies_nr != 1) printf(_("Trying merge strategy %s...\n"), @@ -1806,7 +1875,8 @@ int cmd_merge(int argc, ret = try_merge_strategy(wt_strategy, common, remoteheads, - head_commit); + head_commit, + repair_after_merge); /* * The backend exits with 1 when conflicts are * left to be resolved, with 2 when it does not @@ -1848,6 +1918,7 @@ int cmd_merge(int argc, */ if (!best_strategy) { restore_state(&head_commit->object.oid, &stash); + repair_merge_fsmonitor_proof(repair_after_merge); if (use_strategies_nr > 1) fprintf(stderr, _("No merge strategy handled the merge.\n")); @@ -1863,10 +1934,11 @@ int cmd_merge(int argc, else { printf(_("Rewinding the tree to pristine...\n")); restore_state(&head_commit->object.oid, &stash); + repair_merge_fsmonitor_proof(repair_after_merge); printf(_("Using the %s strategy to prepare resolving by hand.\n"), best_strategy); try_merge_strategy(best_strategy, common, remoteheads, - head_commit); + head_commit, repair_after_merge); } if (squash) { diff --git a/clean-status-history.c b/clean-status-history.c index 434ff3f7b9c8f2..5e6ae97d434d79 100644 --- a/clean-status-history.c +++ b/clean-status-history.c @@ -2186,10 +2186,14 @@ static int transfer_current_proof_if_semantically_same_index( if (manifest_refresh_required) *manifest_refresh_required = 0; + /* + * Clearing resolve-undo changes only its optional index extension. + * A live resolve-undo map is still rejected below, but its removal does + * not change tracked entries or the worktree state certified here. + */ 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)) diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index eebee5f61c9949..5b11dbec6ff5b2 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -3689,6 +3689,179 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'no-ff merges repair proof after provider reset' ' + test_when_finished "rm -rf merge-proof-* merge-proof-linked-*" && + for mode in ort ort-resolve-undo multistrategy-decline \ + multistrategy-conflict \ + resolve-trivial resolve-content + do + case "$mode" in + ort) + set -- -s ort && + expect_refreshes=1 && + merge_queries=TCCCCCCCCCCCCCCCCCCCCCCCC + ;; + ort-resolve-undo) + set -- -s ort && + expect_refreshes=1 && + merge_queries=CCCCCCCCCCCCCCCCCCCCCCCC + ;; + multistrategy-decline) + set -- -s decline -s ort && + expect_refreshes=1 && + merge_queries=TCCCCCCCCCCCCCCCCCCCCCCCC + ;; + multistrategy-conflict) + set -- -s pollute -s ort && + expect_refreshes=1 && + merge_queries=TCCCCCCCCCCCCCCCCCCCCCCCC + ;; + resolve-trivial) + set -- -s resolve && + expect_refreshes=1 && + merge_queries=TCCCCCCCCCCCCCCCCCCCCCCCC + ;; + resolve-content) + set -- -s resolve && + expect_refreshes=4 && + merge_queries=TCCCCCCCCCCCCCCCCCCCCCCCC + ;; + esac && + test_create_repo "merge-proof-$mode" && + ( + cd "merge-proof-$mode" && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir -p .qualification && + test_write_lines one two three >tracked && + test_write_lines resolved >resolved && + test_write_lines ready >.qualification/workflow-state && + git -c core.fsmonitor=false add . && + git -c core.fsmonitor=false commit -qm base && + git branch review && + git -c core.fsmonitor=false worktree add --quiet \ + "../merge-proof-linked-$mode" review && + if test "$mode" = resolve-content + then + test_write_lines main two three >tracked && + git -c core.fsmonitor=false add tracked && + git -c core.fsmonitor=false commit -qm main && + test_write_lines one two review \ + >"../merge-proof-linked-$mode/tracked" + fi && + test_write_lines reviewed \ + >"../merge-proof-linked-$mode/.qualification/review-attestation" && + git -C "../merge-proof-linked-$mode" \ + -c core.fsmonitor=false add \ + .qualification/review-attestation tracked && + git -C "../merge-proof-linked-$mode" \ + -c core.fsmonitor=false \ + commit -qm review && + git config feature.manyFiles true && + git config index.version 4 && + git config core.untrackedCache true && + git config core.fsmonitor true && + if test "$mode" = ort-resolve-undo + then + base_oid=$(git rev-parse HEAD:resolved) && + ours_oid=$(printf "%s\n" ours | + git hash-object -w --stdin) && + theirs_oid=$(printf "%s\n" theirs | + git hash-object -w --stdin) && + git update-index --force-remove resolved && + { + printf "100644 %s 1\tresolved\n" "$base_oid" && + printf "100644 %s 2\tresolved\n" "$ours_oid" && + printf "100644 %s 3\tresolved\n" "$theirs_oid" + } | git update-index --index-info && + test_write_lines resolved >resolved && + git add resolved && + git ls-files --resolve-undo >resolve-undo && + test_file_not_empty resolve-undo && + rm resolve-undo + else + : + fi && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git update-index --fsmonitor && + gitdir=$(git rev-parse --absolute-git-dir) && + if test "$mode" = multistrategy-decline + then + mkdir -p "$gitdir/test-bin" && + write_script "$gitdir/test-bin/git-merge-decline" <<-\EOF + exit 2 + EOF + elif test "$mode" = multistrategy-conflict + then + mkdir -p "$gitdir/test-bin" && + write_script "$gitdir/test-bin/git-merge-pollute" <<-\EOF + base_oid=$(git rev-parse HEAD:tracked) + ours_oid=$(printf "%s\n" polluted-ours | git hash-object -w --stdin) + theirs_oid=$(printf "%s\n" polluted-theirs | git hash-object -w --stdin) + git update-index --force-remove tracked + { + printf "100644 %s 1\ttracked\n" "$base_oid" + printf "100644 %s 2\ttracked\n" "$ours_oid" + printf "100644 %s 3\ttracked\n" "$theirs_oid" + } | git update-index --index-info + printf "%s\n" "<<<<<<< ours" polluted-ours ======= \ + polluted-theirs ">>>>>>> theirs" >tracked + exit 1 + EOF + else + : + fi && + for pass in first second third + do + GIT_INDEX_FILE="$gitdir/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git status --porcelain=v2 >"$gitdir/prime-$pass" && + test_must_be_empty "$gitdir/prime-$pass" || return 1 + done && + test_fsmonitor_full_proof "$gitdir/index" paired && + GIT_TEST_FSMONITOR_ALLOW_PROOF_REPAIR_SEQUENCE=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE="$merge_queries" \ + GIT_TRACE2_EVENT="$gitdir/merge.trace" \ + PATH="$gitdir/test-bin:$PATH" \ + git merge --no-ff "$@" -m merge review && + git ls-files --resolve-undo >"$gitdir/resolve-undo-after" && + test_must_be_empty "$gitdir/resolve-undo-after" && + if test "$mode" != ort-resolve-undo + then + test_trace2_data fsm_client query/trivial-response 1 \ + <"$gitdir/merge.trace" + else + : + fi && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <"$gitdir/merge.trace" && + ! test_trace2_data fsmonitor history/writer-proof-repaired 0 \ + <"$gitdir/merge.trace" && + test_trace2_data fsmonitor history/writer-entry-refreshes \ + "$expect_refreshes" <"$gitdir/merge.trace" && + test_fsmonitor_full_proof "$gitdir/index" paired && + cp "$gitdir/index" "$gitdir/merge.before" && + for pass in first second + do + GIT_INDEX_FILE="$gitdir/index" \ + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + GIT_TRACE2_EVENT="$gitdir/status-$pass.trace" \ + git status --porcelain=v2 \ + >"$gitdir/status-$pass" && + test_must_be_empty "$gitdir/status-$pass" && + test_cmp_bin "$gitdir/merge.before" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/status-$pass.trace" && + ! test_trace2_data fsmonitor untracked/proof-missing 1 \ + <"$gitdir/status-$pass.trace" && + ! test_region index do_write_index \ + "$gitdir/status-$pass.trace" || return 1 + done + ) || return 1 + done +' + 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" && diff --git a/wt-status.c b/wt-status.c index 1e02226d0bc452..8d89a97a009130 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2521,6 +2521,64 @@ static int locked_index_entries_have_stat_data( return 1; } +static void prepare_uncertified_index_entries_for_refresh( + struct index_state *istate) +{ + const struct stat_data empty = { 0 }; + int changed = 0; + size_t refreshes = 0; + + /* + * An owned worktree update can leave new entries process-locally + * up-to-date without a provider-valid bit, or transfer provider-valid + * bits to entries without stat data. A provider reset has no prior event + * interval from which to report those writes. Force the repair refresh + * to stat only those entries before certifying them. + */ + for (size_t i = 0; i < istate->cache_nr; i++) { + struct cache_entry *ce = istate->cache[i]; + int zero_stat = + !memcmp(&ce->ce_stat_data, &empty, sizeof(empty)); + + if (!zero_stat && (ce->ce_flags & CE_FSMONITOR_VALID)) + continue; + refreshes++; + if (zero_stat && (ce->ce_flags & CE_FSMONITOR_VALID)) { + changed = 1; + ce->ce_flags &= ~CE_FSMONITOR_VALID; + } + ce->ce_flags &= ~CE_UPTODATE; + } + if (changed) + istate->cache_changed |= FSMONITOR_CHANGED; + trace2_data_intmax("fsmonitor", istate->repo, + "history/writer-entry-refreshes", refreshes); +} + +static int has_repairable_fsmonitor_proof_candidate( + struct repository *repo) +{ + struct index_state *istate = repo->index; + + if (clean_status_has_current_full_fsmonitor_proof(istate)) + return 1; + /* + * A provider reset can arrive while the index is being read. The live + * proof is then deliberately invalid, but a complete persistent proof + * and its paired untracked cache remain authenticated candidates for a + * full refresh. Preserve that pre-reset authority across an owned + * worktree update; the repair path still revalidates every component + * before publishing a new proof. + */ + return clean_status_has_persistent_fsmonitor_semantic_history(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) && + istate->untracked && istate->untracked->root && + istate->untracked->fsmonitor_revalidation; +} + int wt_status_fsmonitor_proof_needs_repair(struct repository *repo) { struct index_state *istate = repo->index; @@ -2597,6 +2655,19 @@ int wt_status_repair_fsmonitor_proof(struct repository *repo) return repair_fsmonitor_proof(repo, NULL, 0); } +int wt_status_prepare_fsmonitor_proof_for_worktree_update( + struct repository *repo) +{ + if (!has_repairable_fsmonitor_proof_candidate(repo)) + return 0; + if (clean_status_has_current_full_fsmonitor_proof(repo->index)) + return 1; + if (!wt_status_fsmonitor_proof_needs_repair(repo) || + !wt_status_repair_fsmonitor_proof(repo)) + return 0; + return clean_status_has_current_full_fsmonitor_proof(repo->index); +} + int wt_status_repair_fsmonitor_proof_at_path( struct repository *repo, const char *index_path) { @@ -2623,6 +2694,7 @@ static int repair_fsmonitor_proof_after_update( /* Consume paths written by this process before publishing its index. */ fsmonitor_refresh_after_worktree_update(repo->index); + prepare_uncertified_index_entries_for_refresh(repo->index); if (write_locked_index(repo->index, lock, PROVISIONAL_LOCK)) return -1; proof_index_path = get_lock_file_path(lock); diff --git a/wt-status.h b/wt-status.h index 3d44db8c24c6ff..69d8acdf35bad9 100644 --- a/wt-status.h +++ b/wt-status.h @@ -192,6 +192,8 @@ int wt_status_refresh_index(struct wt_status *s, int wt_status_repair_fsmonitor_proof(struct repository *repo); int wt_status_repair_fsmonitor_proof_at_path( struct repository *repo, const char *index_path); +int wt_status_prepare_fsmonitor_proof_for_worktree_update( + struct repository *repo); int wt_status_fsmonitor_proof_needs_repair(struct repository *repo); int wt_status_repair_fsmonitor_proof_after_worktree_update( struct repository *repo, struct lock_file *lock, int had_full_proof); From 1cf041ccb968c27cba8cf2c00cba40986ee28819 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sat, 29 Aug 2026 11:32:21 -0500 Subject: [PATCH 423/432] dir: load tracked excludes before targeted cache refresh 378744b68f (status: reuse closed proofs for scoped queries, 2026-08-11) taught the untracked cache to reconcile a provider-reported direct child without reopening its directory. A valid cached directory can still have a null exclude_oid when its existing contents are all tracked, since traversal never needed to load its tracked .gitignore. prep_exclude() interprets that null OID as proof that no per-directory exclude exists. The targeted refresh can therefore report a newly created ignored file as untracked. Before refreshing a provider-dirty cached directory with a null exclude OID, use its tracked exclude as the expected identity and load the worktree source. Prefer the exact stage-zero entry, then look for a case-folded alias on case-insensitive worktrees. add_patterns() still opens and hashes the actual source when only an alias exists, so a case collision can only force invalidation. Use the empty-blob ID for an unmerged, non-regular, removed, or intent-to-add match so that it forces a source read and conservative invalidation unless the source is truly empty. Cover both exact and case-folded tracked excludes with read-only status calls. They must match a cold oracle without opening the directory or writing the index, and a changed source must invalidate the cache. Also pin the sparse-index boundaries: an in-cone event retains targeted refresh without expansion, while a vivified outside-cone source takes the existing conservative expansion path. --- dir.c | 59 ++++++++++ t/t7519-status-fsmonitor.sh | 214 ++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) diff --git a/dir.c b/dir.c index b0e8aea8c85b22..261d454db6ee54 100644 --- a/dir.c +++ b/dir.c @@ -2856,6 +2856,56 @@ static struct path_pattern *last_matching_pattern_from_lists( return NULL; } +/* + * A valid cache directory may not have needed its per-directory excludes yet, + * leaving exclude_oid null. When a provider later reports its first child, + * use an exact stage-zero index entry as the comparison point before loading + * the worktree source. A regular stage-zero case-folded alias is also a safe + * comparison point: add_patterns() still opens and hashes the actual worktree + * source because its exact-case index lookup misses the alias. For staged, + * non-regular, removed, or intent-to-add matches, use the empty-blob ID to + * force the same source read and conservative invalidation. If the source is + * actually empty, retaining the cache is semantically safe. + */ +static int prime_cached_exclude_from_index(struct index_state *istate, + const struct strbuf *base, + const char *exclude_per_dir, + struct object_id *oid) +{ + struct cache_entry *ce; + struct strbuf path = STRBUF_INIT; + int pos; + + strbuf_addbuf(&path, base); + strbuf_addstr(&path, exclude_per_dir); + pos = index_name_pos(istate, path.buf, path.len); + if (pos >= 0) { + ce = istate->cache[pos]; + } else { + pos = -1 - pos; + if (pos < istate->cache_nr && + ce_namelen(istate->cache[pos]) == path.len && + !memcmp(istate->cache[pos]->name, path.buf, path.len)) { + ce = istate->cache[pos]; + } else if (repo_ignore_case(istate->repo)) { + ce = index_file_exists(istate, path.buf, path.len, 1); + } else { + ce = NULL; + } + } + if (!ce) { + strbuf_release(&path); + return 0; + } + if (!ce_stage(ce) && S_ISREG(ce->ce_mode) && + !(ce->ce_flags & CE_REMOVE) && !ce_intent_to_add(ce)) + oidcpy(oid, &ce->oid); + else + oidcpy(oid, the_hash_algo->empty_blob); + strbuf_release(&path); + return 1; +} + /* * Loads the per-directory exclude list for the substring of base * which has a char length of baselen. @@ -2958,6 +3008,15 @@ static void prep_exclude(struct dir_struct *dir, /* Try to read per-directory file */ oidclr(&oid_stat.oid, the_repository->hash_algo); oid_stat.valid = 0; + if (dir->exclude_per_dir && untracked && + untracked->valid && untracked->fsmonitor_dirty && + is_null_oid(&untracked->exclude_oid) && + prime_cached_exclude_from_index( + istate, &dir->internal.basebuf, + dir->exclude_per_dir, &untracked->exclude_oid)) { + istate->cache_changed |= UNTRACKED_CHANGED; + istate->fsmonitor_untracked_must_persist = 1; + } if (dir->exclude_per_dir && /* * If we know that no files have been added in diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 5b11dbec6ff5b2..31ae57216610f2 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -4544,6 +4544,220 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'provider delta loads a previously unused tracked exclude' ' + test_when_finished "rm -rf builtin-delta-ignored" && + test_create_repo builtin-delta-ignored && + ( + cd builtin-delta-ignored && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines ignored >cached/.gitignore && + test_write_lines tracked >cached/tracked && + git add cached/.gitignore cached/tracked && + git commit -m base && + git config core.untrackedCache true && + git config core.fsmonitor true && + git config status.showUntrackedFiles all && + 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 --untracked-files=all \ + >.git/prime && + test_must_be_empty .git/prime && + test_write_lines generated >cached/ignored && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=all \ + >.git/expect && + test_must_be_empty .git/expect && + for pass in first second + do + cp .git/index ".git/$pass.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/ignored \ + GIT_TRACE2_EVENT="$PWD/.git/$pass.trace" \ + git status --porcelain=v2 --untracked-files=all \ + >".git/$pass.actual" && + test_cmp .git/expect ".git/$pass.actual" && + test_cmp_bin ".git/$pass.index" .git/index && + test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + <".git/$pass.trace" && + # The provider path and its newly needed exclude source. \ + test_trace2_data read_directory paths-visited 2 \ + <".git/$pass.trace" && + test_trace2_data read_directory opendir 0 \ + <".git/$pass.trace" && + test_trace2_data read_directory gitignore-invalidation 0 \ + <".git/$pass.trace" && + ! test_region index do_write_index ".git/$pass.trace" || + return 1 + done + ) +' + +test_expect_success CASE_INSENSITIVE_FS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'provider delta loads a case-folded tracked exclude' ' + test_when_finished "rm -rf builtin-delta-ignored-icase" && + test_create_repo builtin-delta-ignored-icase && + ( + cd builtin-delta-ignored-icase && + sane_unset GIT_TEST_SPLIT_INDEX && + mkdir cached && + test_write_lines ignored >cached/.GitIgnore && + test_write_lines tracked >cached/tracked && + git add cached/.GitIgnore cached/tracked && + git commit -m base && + git config core.ignoreCase true && + git config core.untrackedCache true && + git config core.fsmonitor true && + git config status.showUntrackedFiles all && + 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 --untracked-files=all \ + >.git/prime && + test_must_be_empty .git/prime && + test_write_lines generated >cached/ignored && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=all \ + >.git/expect && + test_must_be_empty .git/expect && + for pass in first second + do + cp .git/index ".git/$pass.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/ignored \ + GIT_TRACE2_EVENT="$PWD/.git/$pass.trace" \ + git status --porcelain=v2 --untracked-files=all \ + >".git/$pass.actual" && + test_cmp .git/expect ".git/$pass.actual" && + test_cmp_bin ".git/$pass.index" .git/index && + test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + <".git/$pass.trace" && + test_trace2_data read_directory paths-visited 2 \ + <".git/$pass.trace" && + test_trace2_data read_directory opendir 0 \ + <".git/$pass.trace" && + test_trace2_data read_directory gitignore-invalidation 0 \ + <".git/$pass.trace" && + ! test_region index do_write_index ".git/$pass.trace" || + return 1 + done && + mtime=$(test-tool chmtime --get cached/.GitIgnore) && + test_write_lines visible >cached/.GitIgnore && + test-tool chmtime =$mtime cached/.GitIgnore && + GIT_OPTIONAL_LOCKS=0 \ + git -c core.fsmonitor=false -c core.untrackedCache=false \ + status --porcelain=v2 --untracked-files=all \ + >.git/changed.expect && + cp .git/index .git/changed.index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=cached/ \ + GIT_TRACE2_EVENT="$PWD/.git/changed.trace" \ + git status --porcelain=v2 --untracked-files=all \ + >.git/changed.actual && + test_cmp .git/changed.expect .git/changed.actual && + test_cmp_bin .git/changed.index .git/index && + test_trace2_data read_directory gitignore-invalidation 1 \ + <.git/changed.trace && + ! test_region index do_write_index .git/changed.trace + ) +' + +test_expect_success CASE_INSENSITIVE_FS,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ + 'case-folded excludes preserve sparse-index fallback boundaries' ' + test_when_finished "rm -rf sparse-casefold-source sparse-casefold" && + test_create_repo sparse-casefold-source && + ( + cd sparse-casefold-source && + mkdir -p in/cached out/cached && + test_write_lines ignored >in/cached/.GitIgnore && + test_write_lines inside >in/cached/tracked && + test_write_lines ignored >out/cached/.GitIgnore && + test_write_lines outside >out/cached/tracked && + git add . && + git commit -m base + ) && + git clone --quiet --sparse sparse-casefold-source sparse-casefold && + git -C sparse-casefold sparse-checkout init --cone --sparse-index && + git -C sparse-casefold sparse-checkout set in && + git -C sparse-casefold config core.ignoreCase true && + git -C sparse-casefold config core.untrackedCache true && + git -C sparse-casefold config core.fsmonitor true && + git -C sparse-casefold config status.showUntrackedFiles all && + git -C sparse-casefold config advice.sparseIndexExpanded false && + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + git -C sparse-casefold update-index --fsmonitor && + GIT_INDEX_FILE="$PWD/sparse-casefold/.git/index" \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC \ + git -C sparse-casefold status --porcelain=v2 \ + --untracked-files=all >sparse-casefold/.git/prime && + test_must_be_empty sparse-casefold/.git/prime && + git -C sparse-casefold ls-files --sparse -t \ + >sparse-casefold/.git/sparse-index && + test_grep "^S out/$" sparse-casefold/.git/sparse-index && + test_write_lines generated >sparse-casefold/in/cached/ignored && + GIT_OPTIONAL_LOCKS=0 \ + git -C sparse-casefold -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + --untracked-files=all >sparse-casefold/.git/in.expect && + test_must_be_empty sparse-casefold/.git/in.expect && + cp sparse-casefold/.git/index sparse-casefold/.git/in.index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=in/cached/ignored \ + GIT_TRACE2_EVENT="$PWD/sparse-casefold/.git/in.trace" \ + git -C sparse-casefold status --porcelain=v2 \ + --untracked-files=all >sparse-casefold/.git/in.actual && + test_cmp sparse-casefold/.git/in.expect \ + sparse-casefold/.git/in.actual && + test_cmp_bin sparse-casefold/.git/in.index \ + sparse-casefold/.git/index && + test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + sparse-casefold/out/cached/.GitIgnore && + test_write_lines generated >sparse-casefold/out/cached/ignored && + GIT_OPTIONAL_LOCKS=0 \ + git -C sparse-casefold -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + --untracked-files=all >sparse-casefold/.git/out.expect && + test_must_be_empty sparse-casefold/.git/out.expect && + cp sparse-casefold/.git/index sparse-casefold/.git/out.index && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=DCCCCCCCC \ + GIT_TEST_FSMONITOR_QUERY_PATH=out/cached/ignored \ + GIT_TRACE2_EVENT="$PWD/sparse-casefold/.git/out.trace" \ + git -C sparse-casefold status --porcelain=v2 \ + --untracked-files=all >sparse-casefold/.git/out.actual && + test_cmp sparse-casefold/.git/out.expect \ + sparse-casefold/.git/out.actual && + test_cmp_bin sparse-casefold/.git/out.index \ + sparse-casefold/.git/index && + ! test_trace2_data fsmonitor untracked/targeted-refresh 1 \ + Date: Sat, 29 Aug 2026 18:43:51 -0500 Subject: [PATCH 424/432] fsmonitor: make Darwin provider fences cancellable on APFS The provider fence added in 7bed8a334f (fsmonitor: fence Darwin callbacks before answering queries, 2026-08-27) calls FSEventStreamFlushSync() from a long-lived worker. The client gives that worker one second before it retires the daemon. Under sustained status traffic, the synchronous provider call can cross that deadline and return immediately afterward. The timeout still forces a daemon restart, and the next status conservatively scans the worktree. A retained trace showed this turning a clean status into a 17-second outlier. On local APFS and HFS volumes, register sticky vnode watches on each watched root and its canonical ancestors before starting the FSEvents stream. Use FSEventStreamFlushAsync() and wait until the callback has published through its returned event ID and a serial queue barrier. The worker waits on its existing condition variable, so the bounded timeout can interrupt it without racing an uncancellable provider call. WatchRoot notifications have event ID zero and cannot be represented by that monotonic token. Treat the kqueue poll as the fence's linearization point, and reject the fence if any watched namespace edge occurred or a watched root changed identity. This also covers a root or ancestor moving away and back before the fence completes. Fall back to the synchronous provider fence when the vnode proof cannot be installed, preserving the existing conservative behavior on unsupported filesystems and resource failures. Exercise the positive event-ID wait, the zero-ID rename ABA, the synchronous fallback, and 512 consecutive read-only status calls. Require every stress-test request to complete without restarting the daemon. --- builtin/fsmonitor--daemon.c | 16 +- compat/fsmonitor/fsm-listen-darwin.c | 353 +++++++++++++++++++++++++-- compat/fsmonitor/fsm-listen.h | 2 +- t/t7527-builtin-fsmonitor.sh | 181 +++++++++++++- 4 files changed, 519 insertions(+), 33 deletions(-) diff --git a/builtin/fsmonitor--daemon.c b/builtin/fsmonitor--daemon.c index f45e387cf66040..7fb7193999f76c 100644 --- a/builtin/fsmonitor--daemon.c +++ b/builtin/fsmonitor--daemon.c @@ -1637,11 +1637,10 @@ static int fsmonitor_run_daemon_1(struct fsmonitor_daemon_state *state) #ifdef __APPLE__ /* - * FlushSync has no cancellation API. If its bounded client wait - * expired, the client received a conservative response and stopped - * the IPC pool. Fail-stop before stream teardown can race the - * provider worker. Leave the socket pathname alone: a replacement - * daemon may already own it, and normal startup can steal a stale + * If the bounded provider-fence wait expired, the client received a + * conservative response and stopped the IPC pool. Fail-stop rather + * than running normal IPC cleanup: a replacement daemon may already + * own the socket pathname. Normal startup can steal a stale, * non-listening pathname after this process exits. */ if (fsm_listen__flush_failed(state)) { @@ -1658,10 +1657,9 @@ static int fsmonitor_run_daemon_1(struct fsmonitor_daemon_state *state) fsm_listen__stop_async(state); #ifdef __APPLE__ /* - * Normal shutdown can race a provider fence which started after - * the first check above. Do not join or tear down an uncancellable - * FlushSync worker; the client has already received a conservative - * response or the IPC pool has otherwise stopped accepting work. + * Normal shutdown can cancel a provider fence which started after + * the first check above. Preserve the same fail-stop IPC teardown + * so a replacement daemon cannot lose its socket pathname. */ if (fsm_listen__flush_failed(state)) { _exit(1); diff --git a/compat/fsmonitor/fsm-listen-darwin.c b/compat/fsmonitor/fsm-listen-darwin.c index f8643a2eebcd60..c291ef55ced05e 100644 --- a/compat/fsmonitor/fsm-listen-darwin.c +++ b/compat/fsmonitor/fsm-listen-darwin.c @@ -24,6 +24,7 @@ #endif #include "git-compat-util.h" +#include "abspath.h" #include "fsmonitor.h" #include "fsm-listen.h" #include "fsmonitor--daemon.h" @@ -34,6 +35,8 @@ #include "string-list.h" #include "trace.h" #include "trace2.h" +#include +#include #define FSMONITOR_FLUSH_TIMEOUT_MS 1000 @@ -60,6 +63,16 @@ struct fsm_listen_data pthread_mutex_t flush_lock; uint64_t flush_requested; uint64_t flush_finished; + uint64_t flush_barrier_requested; + uint64_t flush_barrier_finished; + FSEventStreamEventId flush_published_event_id; + uint64_t flush_published_event_epoch; + int watch_root_kq; + int *watch_root_fds; + size_t watch_root_fds_nr; + size_t watch_root_fds_alloc; + struct stat worktree_watch_identity; + struct stat gitdir_watch_identity; enum flush_worker_state { FLUSH_WORKER_NOT_STARTED = 0, FLUSH_WORKER_RUNNING, @@ -87,12 +100,185 @@ struct fsm_listen_data unsigned int shutdown_requested:1; unsigned int flush_sync_initialized:1; unsigned int flush_thread_created:1; + unsigned int use_async_flush:1; unsigned int test_deferred_published:1; unsigned int test_flush_bypass:1; unsigned int test_cookie_delayed:1; + unsigned int test_ignore_root_changed:1; unsigned long test_cookie_delay_ms; }; +static void release_watch_root_edges(struct fsm_listen_data *data) +{ + size_t i; + + for (i = 0; i < data->watch_root_fds_nr; i++) + close(data->watch_root_fds[i]); + FREE_AND_NULL(data->watch_root_fds); + data->watch_root_fds_nr = 0; + data->watch_root_fds_alloc = 0; + if (data->watch_root_kq >= 0) { + close(data->watch_root_kq); + data->watch_root_kq = -1; + } +} + +static int watch_root_fd_already_registered(struct fsm_listen_data *data, + int fd) +{ + struct stat candidate; + size_t i; + + if (fstat(fd, &candidate)) + return -1; + for (i = 0; i < data->watch_root_fds_nr; i++) { + struct stat registered; + + if (fstat(data->watch_root_fds[i], ®istered)) + return -1; + if (candidate.st_dev == registered.st_dev && + candidate.st_ino == registered.st_ino) + return 1; + } + return 0; +} + +static int watch_root_fd_supports_edges(int fd) +{ + struct statfs fs; + + if (fstatfs(fd, &fs)) + return 0; + return (fs.f_flags & MNT_LOCAL) && + (!strcmp(fs.f_fstypename, "apfs") || + !strcmp(fs.f_fstypename, "hfs")); +} + +static int add_watch_root_edges(struct fsm_listen_data *data, + const char *path) +{ + struct strbuf canonical = STRBUF_INIT; + struct kevent change; + const char *slash; + int duplicate; + int fd = -1; + int ret = -1; + + if (!strbuf_realpath(&canonical, path, 0)) + goto done; + + for (;;) { + fd = open(canonical.buf, O_EVTONLY | O_CLOEXEC); + if (fd < 0) + goto done; + if (!watch_root_fd_supports_edges(fd)) { + errno = ENOTSUP; + goto done; + } + duplicate = watch_root_fd_already_registered(data, fd); + if (duplicate < 0) + goto done; + if (duplicate) { + close(fd); + fd = -1; + } else { + EV_SET(&change, fd, EVFILT_VNODE, EV_ADD | EV_CLEAR, + NOTE_RENAME | NOTE_DELETE | NOTE_REVOKE, 0, NULL); + if (kevent(data->watch_root_kq, &change, 1, + NULL, 0, NULL) < 0) + goto done; + ALLOC_GROW(data->watch_root_fds, + data->watch_root_fds_nr + 1, + data->watch_root_fds_alloc); + data->watch_root_fds[data->watch_root_fds_nr++] = fd; + fd = -1; + } + + /* The vnode watch on the first component also covers its edge. */ + slash = find_last_dir_sep(canonical.buf); + if (!slash || slash == canonical.buf) + break; + strbuf_setlen(&canonical, slash - canonical.buf); + } + + ret = 0; +done: + if (fd >= 0) + close(fd); + strbuf_release(&canonical); + return ret; +} + +static int init_watch_root_edges(struct fsm_listen_data *data) +{ + data->watch_root_kq = kqueue(); + if (data->watch_root_kq < 0 || + fcntl(data->watch_root_kq, F_SETFD, FD_CLOEXEC) < 0 || + add_watch_root_edges(data, + data->state->path_worktree_watch.buf) || + (data->state->nr_paths_watching > 1 && + add_watch_root_edges(data, + data->state->path_gitdir_watch.buf))) { + release_watch_root_edges(data); + return -1; + } + return 0; +} + +static int watch_root_edge_seen(struct fsm_listen_data *data) +{ + struct kevent event; + struct timespec timeout = { 0 }; + int nr; + + nr = kevent(data->watch_root_kq, NULL, 0, &event, 1, &timeout); + if (!nr) + return 0; + + if (nr < 0) + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence watch-root poll failed: %s", + strerror(errno)); + else + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence observed watch-root edge " + "flags=0x%x fflags=0x%x", + event.flags, event.fflags); + return 1; +} + +static int watch_root_matches(const char *path, const struct stat *expected) +{ + struct stat actual; + + return !stat(path, &actual) && S_ISDIR(actual.st_mode) && + actual.st_dev == expected->st_dev && + actual.st_ino == expected->st_ino && + actual.st_birthtimespec.tv_sec == + expected->st_birthtimespec.tv_sec && + actual.st_birthtimespec.tv_nsec == + expected->st_birthtimespec.tv_nsec && + actual.st_gen == expected->st_gen; +} + +static int watch_roots_match(struct fsm_listen_data *data) +{ + return watch_root_matches(data->state->path_worktree_watch.buf, + &data->worktree_watch_identity) && + (data->state->nr_paths_watching == 1 || + watch_root_matches(data->state->path_gitdir_watch.buf, + &data->gitdir_watch_identity)); +} + +static void force_provider_shutdown(struct fsm_listen_data *data) +{ + pthread_mutex_lock(&data->dq_lock); + data->shutdown_style = FORCE_SHUTDOWN; + data->shutdown_requested = 1; + pthread_cond_broadcast(&data->dq_finished); + pthread_mutex_unlock(&data->dq_lock); +} + static void publish_test_deferred_batch(struct fsm_listen_data *data) { struct fsmonitor_batch *batch; @@ -138,6 +324,16 @@ static void drain_dispatch_queue(void *ctx UNUSED) { } +static void complete_provider_barrier(void *ctx) +{ + struct fsm_listen_data *data = ctx; + + pthread_mutex_lock(&data->flush_lock); + data->flush_barrier_finished++; + pthread_cond_broadcast(&data->flush_finished_cond); + pthread_mutex_unlock(&data->flush_lock); +} + static void *flush_worker_proc(void *ctx) { struct fsm_listen_data *data = ctx; @@ -145,8 +341,13 @@ static void *flush_worker_proc(void *ctx) trace2_thread_start("fsm-flush"); pthread_mutex_lock(&data->flush_lock); for (;;) { + FSEventStreamEventId target_event_id; + FSEventStreamEventId published_event_id; + uint64_t target_event_epoch; + uint64_t target_barrier; uint64_t requested; uint64_t previously_finished; + int event_pending; while (data->flush_requested == data->flush_finished && data->flush_state == FLUSH_WORKER_RUNNING) @@ -175,21 +376,94 @@ static void *flush_worker_proc(void *ctx) requested - previously_finished); trace2_data_intmax("fsmonitor", NULL, "darwin-fence/count", 1); - trace2_region_enter("fsmonitor", "darwin-flush-sync", - NULL); if (data->test_flush_delay_ms) sleep_millisec(data->test_flush_delay_ms); - FSEventStreamFlushSync(data->stream); - /* - * FlushSync guarantees that callbacks for earlier provider events - * have been invoked, but a callback dispatched onto our serial queue - * may still be running. Queue a synchronous no-op behind those - * callbacks so that their batches are published before the fence is - * reported complete. - */ - dispatch_sync_f(data->dq, NULL, drain_dispatch_queue); - trace2_region_leave("fsmonitor", "darwin-flush-sync", - NULL); + if (!data->use_async_flush) { + trace2_region_enter("fsmonitor", "darwin-flush-sync", + NULL); + FSEventStreamFlushSync(data->stream); + dispatch_sync_f(data->dq, NULL, drain_dispatch_queue); + trace2_region_leave("fsmonitor", "darwin-flush-sync", + NULL); + } else { + trace2_region_enter("fsmonitor", "darwin-flush-async", + NULL); + pthread_mutex_lock(&data->flush_lock); + target_event_epoch = data->flush_published_event_epoch; + published_event_id = data->flush_published_event_id; + pthread_mutex_unlock(&data->flush_lock); + target_event_id = FSEventStreamFlushAsync(data->stream); + /* A lower target belongs to the next event-ID epoch. */ + if (target_event_id && target_event_id < published_event_id) + target_event_epoch++; + pthread_mutex_lock(&data->flush_lock); + target_barrier = ++data->flush_barrier_requested; + /* + * The FSEvents Programming Guide defines FlushAsync's return + * value as the last pending event and tells callers to use that + * ID in the callback to detect completion. Wait until our + * callback has published through it. The serial queue barrier + * also covers a callback which advanced the stream ID before + * FlushAsync returned but is still publishing its batch. + */ + event_pending = + target_event_epoch > data->flush_published_event_epoch || + (target_event_epoch == + data->flush_published_event_epoch && + target_event_id > data->flush_published_event_id); + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence target=%"PRIu64 + " published=%"PRIu64" event-pending=%d", + target_event_id, + data->flush_published_event_id, + event_pending); + trace2_data_intmax("fsmonitor", NULL, + "darwin-fence/event-pending", + event_pending); + pthread_mutex_unlock(&data->flush_lock); + dispatch_async_f(data->dq, data, + complete_provider_barrier); + pthread_mutex_lock(&data->flush_lock); + while (data->flush_state == FLUSH_WORKER_RUNNING && + (data->flush_barrier_finished < target_barrier || + data->flush_published_event_epoch < + target_event_epoch || + (data->flush_published_event_epoch == + target_event_epoch && + data->flush_published_event_id < target_event_id))) + pthread_cond_wait(&data->flush_finished_cond, + &data->flush_lock); + if (data->flush_state != FLUSH_WORKER_RUNNING) { + pthread_mutex_unlock(&data->flush_lock); + trace2_region_leave("fsmonitor", + "darwin-flush-async", NULL); + break; + } + pthread_mutex_unlock(&data->flush_lock); + trace2_region_leave("fsmonitor", "darwin-flush-async", + NULL); + /* + * WatchRoot notifications carry event ID zero, so they cannot + * be represented by FlushAsync's monotonic completion token. + * Reject the fence if a watched path crossed a namespace edge + * or now names a different directory. A root change after this + * check is a post-fence mutation. + */ + if (watch_root_edge_seen(data) || + !watch_roots_match(data)) { + trace_printf_key(&trace_fsmonitor, + "Darwin provider fence rejected " + "changed watch root"); + pthread_mutex_lock(&data->flush_lock); + if (data->flush_state == FLUSH_WORKER_RUNNING) + data->flush_state = FLUSH_WORKER_STOPPING; + pthread_cond_broadcast( + &data->flush_finished_cond); + pthread_mutex_unlock(&data->flush_lock); + force_provider_shutdown(data); + break; + } + } publish_test_deferred_batch(data); trace_printf_key(&trace_fsmonitor, "Darwin provider fence complete request=%"PRIu64, @@ -253,9 +527,9 @@ static void stop_flush_worker(struct fsm_listen_data *data) return; /* - * FlushSync has no cancellation API. If shutdown races an in-flight - * fence, let the main thread fail-stop the process rather than joining - * a provider call which may never return or tearing down beneath it. + * Preserve the failed state until the main thread takes the fail-stop + * path. That path deliberately bypasses normal IPC cleanup so an old + * daemon cannot unlink a replacement daemon's socket. */ if (begin_flush_shutdown(data)) { trace_printf_key(&trace_fsmonitor, @@ -431,7 +705,7 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, size_t num_of_events, void *event_paths, const FSEventStreamEventFlags event_flags[], - const FSEventStreamEventId event_ids[] UNUSED) + const FSEventStreamEventId event_ids[]) { struct fsmonitor_daemon_state *state = ctx; struct fsm_listen_data *data = state->listen_data; @@ -444,6 +718,7 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, struct strbuf tmp = STRBUF_INIT; struct strbuf event_path = STRBUF_INIT; enum fsmonitor_path_type path_type; + int event_ids_wrapped = 0; /* * Build a list of all filesystem changes into a private/local @@ -460,6 +735,9 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, CFIndex path_size; int64_t file_id = 0; + if (event_flags[k] & kFSEventStreamEventFlagEventIdsWrapped) + event_ids_wrapped = 1; + /* * Extended events retain their inode even when their pathname has * already been removed by the time this callback runs. @@ -541,6 +819,11 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, */ trace_printf_key(&trace_fsmonitor, "event: root changed"); + if (data->test_ignore_root_changed) { + trace_printf_key(&trace_fsmonitor, + "test-ignore-root-change"); + continue; + } goto force_shutdown; } @@ -709,6 +992,17 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, sleep_millisec(data->test_cookie_delay_ms); } fsmonitor_publish(state, batch, &cookie_list); + pthread_mutex_lock(&data->flush_lock); + if (num_of_events && event_ids_wrapped) { + data->flush_published_event_epoch++; + data->flush_published_event_id = event_ids[num_of_events - 1]; + } else if (num_of_events && + data->flush_published_event_id < + event_ids[num_of_events - 1]) { + data->flush_published_event_id = event_ids[num_of_events - 1]; + } + pthread_cond_broadcast(&data->flush_finished_cond); + pthread_mutex_unlock(&data->flush_lock); string_list_clear(&cookie_list, 0); strbuf_release(&tmp); strbuf_release(&event_path); @@ -722,11 +1016,7 @@ static void fsevent_callback(ConstFSEventStreamRef streamRef UNUSED, strbuf_release(&tmp); strbuf_release(&event_path); - pthread_mutex_lock(&data->dq_lock); - data->shutdown_style = FORCE_SHUTDOWN; - data->shutdown_requested = 1; - pthread_cond_broadcast(&data->dq_finished); - pthread_mutex_unlock(&data->dq_lock); + force_provider_shutdown(data); strbuf_release(&tmp); return; @@ -769,6 +1059,7 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) CALLOC_ARRAY(data, 1); state->listen_data = data; data->state = state; + data->watch_root_kq = -1; data->test_cookie_delay_ms = git_env_ulong( "GIT_TEST_FSMONITOR_COOKIE_DELAY_MS", 0); data->test_flush_delay_ms = git_env_ulong( @@ -777,6 +1068,8 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) "GIT_TEST_FSMONITOR_FLUSH_COALESCE_DELAY_MS", 0); data->test_flush_bypass = git_env_bool( "GIT_TEST_FSMONITOR_FLUSH_SYNC_BYPASS", 0); + data->test_ignore_root_changed = git_env_bool( + "GIT_TEST_FSMONITOR_IGNORE_ROOT_CHANGED", 0); data->flush_timeout_ms = git_env_ulong( "GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS", FSMONITOR_FLUSH_TIMEOUT_MS); @@ -802,6 +1095,20 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) kCFStringEncodingUTF8); dir_array[data->nr_paths_watching++] = data->cfsr_gitdir_path; } + data->use_async_flush = !git_env_bool( + "GIT_TEST_FSMONITOR_FORCE_SYNC_FLUSH", 0); + if (data->use_async_flush && + (stat(state->path_worktree_watch.buf, + &data->worktree_watch_identity) || + (state->nr_paths_watching > 1 && + stat(state->path_gitdir_watch.buf, + &data->gitdir_watch_identity)) || + init_watch_root_edges(data))) { + trace_printf_key(&trace_fsmonitor, + "falling back to synchronous Darwin provider fence"); + data->use_async_flush = 0; + release_watch_root_edges(data); + } data->cfar_paths_to_watch = CFArrayCreate(NULL, dir_array, data->nr_paths_watching, @@ -827,6 +1134,7 @@ int fsm_listen__ctor(struct fsmonitor_daemon_state *state) error(_("Unable to create FSEventStream.")); free(data->test_defer_path); + release_watch_root_edges(data); FREE_AND_NULL(state->listen_data); return -1; } @@ -860,6 +1168,7 @@ void fsm_listen__dtor(struct fsmonitor_daemon_state *state) if (data->dq) dispatch_release(data->dq); + release_watch_root_edges(data); fsmonitor_batch__free_list(data->test_deferred_batch); free(data->test_defer_path); if (data->flush_sync_initialized) { diff --git a/compat/fsmonitor/fsm-listen.h b/compat/fsmonitor/fsm-listen.h index a8b6317e4f1305..b2ce76b515f274 100644 --- a/compat/fsmonitor/fsm-listen.h +++ b/compat/fsmonitor/fsm-listen.h @@ -52,7 +52,7 @@ enum fsm_listen_flush_result { enum fsm_listen_flush_result fsm_listen__flush_sync( struct fsmonitor_daemon_state *state); -/* True when a provider fence cannot be joined safely during teardown. */ +/* True when provider-fence failure requires fail-stop IPC teardown. */ int fsm_listen__flush_failed(struct fsmonitor_daemon_state *state); #endif diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 70512c2d9555b6..a6756925082e51 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -71,6 +71,42 @@ test_lazy_prereq FSMONITOR_LINUX ' test "$uname_s" = Linux ' +test_lazy_prereq DARWIN_ASYNC_FENCE ' + test "$uname_s" = Darwin || return 1 + git init test_darwin_async_fence || return 1 + + GIT_TRACE2_EVENT="$PWD/darwin-async-fence.trace2" && + export GIT_TRACE2_EVENT && + maybe_timeout 30 \ + git -C test_darwin_async_fence fsmonitor--daemon start \ + --start-timeout=10 + start_ret=$? + if test $start_ret -eq 0 + then + maybe_timeout 10 \ + test-tool -C test_darwin_async_fence \ + fsmonitor-client query --token 0 \ + >/dev/null 2>&1 + query_ret=$? + maybe_timeout 5 \ + git -C test_darwin_async_fence \ + fsmonitor--daemon stop 2>/dev/null || : + else + query_ret=1 + fi + unset GIT_TRACE2_EVENT + + test $start_ret -eq 0 && + test $query_ret -eq 0 && + test_grep "region_enter.*darwin-flush-async" \ + darwin-async-fence.trace2 && + test_grep ! "region_enter.*darwin-flush-sync" \ + darwin-async-fence.trace2 + ret=$? + rm -rf test_darwin_async_fence darwin-async-fence.trace2 + return $ret +' + test_lazy_prereq FOREIGN_FSMONITOR_GIT ' test -x /opt/homebrew/bin/git && /opt/homebrew/bin/git version @@ -485,7 +521,7 @@ test_expect_success MACOS 'complete a delayed Darwin provider fence within budge test_must_be_empty error ' -test_expect_success MACOS \ +test_expect_success MACOS,DARWIN_ASYNC_FENCE \ 'real Darwin provider fence rescues a delayed cookie callback' ' test_when_finished " stop_daemon_delete_repo test_delayed_callback; @@ -512,6 +548,10 @@ test_expect_success MACOS \ test_grep ! "Q/Q" actual-q && test_grep "cookie_wait timed out" delayed-callback.trace && test_grep "Darwin provider fence begin" delayed-callback.trace && + # FlushAsync may return zero after dequeuing the callback; the + # serial queue barrier must still wait for its publication. + test_grep "Darwin provider fence target=.* event-pending=[01]" \ + delayed-callback.trace && test_grep "cookie-seen:" delayed-callback.trace && test_grep "Darwin provider fence complete" \ delayed-callback.trace && @@ -852,6 +892,101 @@ test_expect_success MACOS \ git -C test_provider_root-away fsmonitor--daemon status ' +test_expect_success MACOS,DARWIN_ASYNC_FENCE \ + 'Darwin provider fence detects a zero-ID watched-root ancestor ABA' ' + test_when_finished " + stop_daemon_delete_repo test_provider_root_identity/repo; + stop_daemon_delete_repo test_provider_root_identity-away/repo; + rm -rf test_provider_root_identity \ + test_provider_root_identity-away; + rm -f provider-root-identity.trace + " && + + mkdir test_provider_root_identity && + git init test_provider_root_identity/repo && + ( + cd test_provider_root_identity/repo && + printf "base\n" >tracked && + git add tracked && + git commit -m base && + GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS=1500 && + GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS=5000 && + GIT_TEST_FSMONITOR_IGNORE_ROOT_CHANGED=1 && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_DELAY_MS && + export GIT_TEST_FSMONITOR_FLUSH_SYNC_TIMEOUT_MS && + export GIT_TEST_FSMONITOR_IGNORE_ROOT_CHANGED && + start_daemon --tf "$PWD/../../provider-root-identity.trace" \ + --tk true + ) && + token="builtin:${fsmonitor_cookie_token_prefix}test_00000001:0" && + : >provider-root-identity.trace && + { + test-tool -C test_provider_root_identity/repo \ + fsmonitor-client query \ + --token "$token" \ + >test_provider_root_identity/repo/.git/in-flight \ + 2>test_provider_root_identity/repo/.git/in-flight.err & + query_pid=$! + } && + seen= && + for i in $(test_seq 1 100) + do + if grep "Darwin provider fence begin" \ + provider-root-identity.trace >/dev/null 2>&1 + then + seen=1 && + break + fi && + sleep 0.05 || return 1 + done && + test "$seen" = 1 && + mv test_provider_root_identity test_provider_root_identity-away && + printf "changed\n" \ + >>test_provider_root_identity-away/repo/tracked && + mv test_provider_root_identity-away test_provider_root_identity && + wait "$query_pid" && + test_must_be_empty \ + test_provider_root_identity/repo/.git/in-flight.err && + nul_to_q test_provider_root_identity/repo/.git/in-flight-q && + test_grep "Q/Q$" \ + test_provider_root_identity/repo/.git/in-flight-q && + test_grep "provider fence observed watch-root edge" \ + provider-root-identity.trace && + test_grep "provider fence rejected changed watch root" \ + provider-root-identity.trace && + test_must_fail git -C test_provider_root_identity/repo \ + fsmonitor--daemon status +' + +test_expect_success MACOS \ + 'Darwin provider fence retains the synchronous fallback' ' + test_when_finished " + stop_daemon_delete_repo test_provider_sync_fallback; + rm -f provider-sync-fallback.trace \ + provider-sync-fallback.trace2 + " && + + git init test_provider_sync_fallback && + ( + cd test_provider_sync_fallback && + printf "base\n" >tracked && + git add tracked && + git commit -m base && + git config core.fsmonitor true && + GIT_TEST_FSMONITOR_FORCE_SYNC_FLUSH=1 && + export GIT_TEST_FSMONITOR_FORCE_SYNC_FLUSH && + start_daemon --tf "$PWD/../provider-sync-fallback.trace" \ + --t2 "$PWD/../provider-sync-fallback.trace2" && + git status --porcelain=v2 >.git/actual && + test_must_be_empty .git/actual && + test_grep "region_enter.*darwin-flush-sync" \ + ../provider-sync-fallback.trace2 && + test_grep ! "region_enter.*darwin-flush-async" \ + ../provider-sync-fallback.trace2 + ) +' + test_expect_success MACOS \ 'explicit shutdown drains an in-flight Darwin fence' ' test_when_finished "stop_daemon_delete_repo test_provider_stop" && @@ -1079,6 +1214,50 @@ test_expect_success MACOS,UNTRACKED_CACHE \ done ' +test_expect_success MACOS,UNTRACKED_CACHE,DARWIN_ASYNC_FENCE \ + 'repeated Darwin provider fences remain live' ' + test_when_finished " + stop_daemon_delete_repo test_provider_stress; + rm -f provider-stress.trace provider-stress.trace2 + " && + + git init test_provider_stress && + ( + cd test_provider_stress && + printf "base\\n" >tracked && + git add tracked && + git commit -m base && + git config core.fsmonitor true && + git config core.untrackedCache true && + start_daemon --tf "$PWD/../provider-stress.trace" \ + --t2 "$PWD/../provider-stress.trace2" && + git status --porcelain=v2 >.git/prime-1 && + git status --porcelain=v2 >.git/prime-2 && + test_must_be_empty .git/prime-1 && + test_must_be_empty .git/prime-2 && + + for i in $(test_seq 1 512) + do + GIT_OPTIONAL_LOCKS=0 git status --porcelain=v2 \ + >.git/actual || return 1 + test_must_be_empty .git/actual || return 1 + done && + requests=$(grep -c "Darwin provider fence requested" \ + ../provider-stress.trace) && + completed=$(grep -c "Darwin provider fence complete" \ + ../provider-stress.trace) && + test "$requests" -ge 512 && + test "$requests" = "$completed" && + test_grep ! "synchronous flush timed out" \ + ../provider-stress.trace && + test_grep "region_enter.*darwin-flush-async" \ + ../provider-stress.trace2 && + test_grep ! "region_enter.*darwin-flush-sync" \ + ../provider-stress.trace2 && + git fsmonitor--daemon status + ) +' + test_expect_success MACOS 'fresh unpinned batches honor the retention grace' ' test_when_finished "stop_daemon_delete_repo test_fresh_history" && From d65bfdc06ad9dd076fceebc9a7e0905e43d3fafe Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 30 Aug 2026 04:14:40 -0500 Subject: [PATCH 425/432] stash: reissue clean sidecar after rewriting the index An authenticated clean-status sidecar is bound to the identity of the index file it certifies. A stash push can restore a complete FSMonitor and untracked-cache proof after its child processes rewrite the index, but the existing sidecar still names the old inode. The next read-only status rejects it and takes the slower history path even though stash left the worktree clean. Remember whether stash started with a regular, singly linked sidecar. When optional locks are available and no post-index-change hook is configured, retain the status data gathered by proof repair, commit and reread the final index, then issue a replacement sidecar from that same certifying scan. This avoids a second worktree traversal while binding the proof to the final index identity. Other stash paths keep the existing repair behavior. Cover a scoped push with a subsequent read-only status that must take the clean-proof fast path. Let the existing writer-proof test accept both authenticated sidecar hits and coherent-history reuse, since both are valid read-only fast paths. --- builtin/stash.c | 37 +++++++-- t/t7519-status-fsmonitor.sh | 4 +- t/t7530-status-clean-sidecar.sh | 16 ++++ wt-status.c | 142 +++++++++++++++++++++++++------- wt-status.h | 4 + 5 files changed, 164 insertions(+), 39 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 87006050bcc409..77d0a4513c0f70 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -11,6 +11,7 @@ #include "gettext.h" #include "hash.h" #include "hex.h" +#include "hook.h" #include "object-name.h" #include "parse-options.h" #include "refs.h" @@ -400,7 +401,8 @@ static int reset_tree(struct object_id *i_tree, int update, int reset, } static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, - int worktree_updated) + int worktree_updated, + int reissue_sidecar) { struct lock_file lock = LOCK_INIT; int repaired; @@ -421,7 +423,10 @@ static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, the_repository->index->fsmonitor_has_run_once = 0; refresh_fsmonitor(the_repository->index); } - repaired = worktree_updated ? + repaired = worktree_updated && reissue_sidecar ? + wt_status_repair_fsmonitor_proof_after_worktree_update_with_sidecar( + the_repository, &lock, had_full_proof, + &stash_clean_digest) : worktree_updated ? wt_status_repair_fsmonitor_proof_after_worktree_update( the_repository, &lock, had_full_proof) : wt_status_repair_fsmonitor_proof_after_index_update( @@ -430,6 +435,8 @@ static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, rollback_lock_file(&lock); return error(_("could not repair index")); } + if (reissue_sidecar && worktree_updated && repaired > 0) + return 0; if (write_locked_index(the_repository->index, &lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) return error(_("could not write index")); @@ -437,6 +444,17 @@ static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, return 0; } +static int stash_clean_sidecar_present(void) +{ + struct stat st; + char *path = xstrfmt("%s.csts", repo_get_index_file(the_repository)); + int present = !lstat(path, &st) && S_ISREG(st.st_mode) && + st.st_nlink == 1; + + free(path); + return present; +} + static int create_index_from_tree(const struct object_id *tree_id, const char *index_path) { @@ -838,7 +856,7 @@ static int do_apply_stash(const char *prefix, struct stash_info *info, if (info->has_u && restore_untracked(&info->u_tree)) ret = error(_("could not restore untracked files from stash")); if (!ret && repair_stash_fsmonitor_proof_after_update( - had_full_proof, 1)) + had_full_proof, 1, 0)) ret = -1; if (!quiet) { @@ -1772,7 +1790,7 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED, ret = do_create_stash(&ps, &stash_msg_buf, 0, 0, NULL, 0, &info, NULL, 0); if (!ret && repair_stash_fsmonitor_proof_after_update( - had_full_proof, 0)) + had_full_proof, 0, 0)) ret = -1; if (!ret) printf_ln("%s", oid_to_hex(&info.w_commit)); @@ -1789,6 +1807,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 had_clean_sidecar; int had_full_proof; struct lock_file index_lock = LOCK_INIT; struct stash_info info = STASH_INFO_INIT; @@ -1828,6 +1847,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q repo_read_index_preload(the_repository, NULL, 0); had_full_proof = clean_status_has_persistent_fsmonitor_semantic_history( the_repository->index); + had_clean_sidecar = stash_clean_sidecar_present(); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); @@ -2025,6 +2045,13 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q goto done; } } + if (had_clean_sidecar && use_optional_locks() && + !hook_exists(the_repository, "post-index-change") && + repair_stash_fsmonitor_proof_after_update( + had_full_proof, 1, 1)) { + ret = -1; + goto done; + } goto done; } else { struct child_process cp = CHILD_PROCESS_INIT; @@ -2054,7 +2081,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q } if (preserve_clean_history && repair_stash_fsmonitor_proof_after_update( - had_full_proof, 1)) { + had_full_proof, 1, 0)) { ret = -1; goto done; } diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 31ae57216610f2..abbf645146c40f 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -4045,8 +4045,8 @@ test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OP test_must_be_empty "$gitdir/$label-$pass" && test_cmp_bin "$gitdir/$label-$pass.index" \ "$gitdir/index" && - test_trace2_data fsmonitor config/coherent 1 \ - <"$gitdir/$label-$pass.trace" && + assert_clean_status_fast \ + "$gitdir/$label-$pass.trace" && ! test_trace2_data fsmonitor untracked/proof-missing 1 \ <"$gitdir/$label-$pass.trace" && assert_no_full_worktree_scan \ diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index c67531a7a5fcd0..d7348dc874dc0b 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3562,4 +3562,20 @@ test_expect_success PERL_TEST_HELPERS \ ) ' +test_expect_success DURABLE_FSMONITOR \ + 'scoped stash publishes a sidecar for its final clean index' ' + test_when_finished "stop_daemon sidecar-scoped-stash" && + setup_repo sidecar-scoped-stash && + git -C sidecar-scoped-stash config core.autocrlf false && + issue_sidecar sidecar-scoped-stash && + assert_clean_sidecar_hit sidecar-scoped-stash \ + sidecar-scoped-stash scoped-stash-before && + test_write_lines changed >sidecar-scoped-stash/tracked && + GIT_TRACE2_EVENT="$PWD/scoped-stash.trace" \ + git -C sidecar-scoped-stash stash push -q -- tracked && + test_path_is_file sidecar-scoped-stash/.git/index.csts && + assert_clean_sidecar_hit sidecar-scoped-stash \ + sidecar-scoped-stash scoped-stash-after +' + test_done diff --git a/wt-status.c b/wt-status.c index 8d89a97a009130..d756bad3bd6c76 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2466,7 +2466,8 @@ int wt_status_refresh_index(struct wt_status *s, return ret; } -static int fsmonitor_proof_repair_is_eligible(struct repository *repo) +static int fsmonitor_proof_repair_is_eligible( + struct repository *repo, int allow_untracked_bootstrap) { struct index_state *istate = repo->index; const char *test_sequence = @@ -2481,7 +2482,8 @@ static int fsmonitor_proof_repair_is_eligible(struct repository *repo) getenv(ALTERNATE_DB_ENVIRONMENT) || istate->split_index || istate->sparse_index != INDEX_EXPANDED || fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC || - !istate->untracked || !istate->untracked->root || + (!allow_untracked_bootstrap && + (!istate->untracked || !istate->untracked->root)) || (!istate->fsmonitor_token_valid && !fsmonitor_pending_token_from_provider(istate))) return 0; @@ -2583,47 +2585,60 @@ int wt_status_fsmonitor_proof_needs_repair(struct repository *repo) { struct index_state *istate = repo->index; - if (!fsmonitor_proof_repair_is_eligible(repo)) + if (!fsmonitor_proof_repair_is_eligible(repo, 0)) return 0; return fsmonitor_pending_token_from_provider(istate) || !istate->fsmonitor_untracked_valid || !istate->untracked->root->valid_recursive; } +static void release_repair_status(struct wt_status *status) +{ + wt_status_collect_free_buffers(status); + string_list_clear(&status->change, 1); + string_list_clear(&status->untracked, 0); + string_list_clear(&status->ignored, 0); + free(status->branch); +} + static int repair_fsmonitor_proof( - struct repository *repo, const char *index_path, int force_refresh) + struct repository *repo, const char *index_path, int force_refresh, + struct wt_status *repaired_status) { struct index_state *istate = repo->index; - struct wt_status status; + struct wt_status local_status; + struct wt_status *status = repaired_status ? + repaired_status : &local_status; int no_pending, paired_untracked, valid_root, certifiable_index; int full_proof, repaired = 0; - if (!fsmonitor_proof_repair_is_eligible(repo)) + if (!fsmonitor_proof_repair_is_eligible( + repo, !!repaired_status)) return 0; if (!force_refresh && !wt_status_fsmonitor_proof_needs_repair(repo)) return 1; - wt_status_prepare(repo, &status); - status.proof_index_path = index_path; - status.allow_clean_status_shortcuts = 1; - status.certify_clean_status = 1; - wt_status_start_untracked_cache_preload(&status); + wt_status_prepare(repo, status); + status->proof_index_path = index_path; + status->allow_clean_status_shortcuts = 1; + status->certify_clean_status = 1; + wt_status_start_untracked_cache_preload(status); /* There is no subsequent diff to consume deferred bulk results. */ wt_status_refresh_index( - &status, + status, REFRESH_QUIET | REFRESH_UNMERGED, 1); - if (status.certify_active_filter_found) + if (status->certify_active_filter_found) goto done; /* A policy-file update can invalidate the cache during token closure. */ - wt_status_collect_untracked(&status); + wt_status_collect_untracked(status); /* Bind the rebuilt cache and refreshed entries to a fresh token. */ if (fsmonitor_reopen_token(istate)) wt_status_refresh_index( - &status, + status, REFRESH_QUIET | REFRESH_UNMERGED, 1); - if (status.certify_active_filter_found) + if (status->certify_active_filter_found) goto done; untracked_cache_recompute_fsmonitor_valid_recursive(istate->untracked); no_pending = !fsmonitor_has_pending_token(istate); @@ -2636,15 +2651,27 @@ static int repair_fsmonitor_proof( certifiable_index = clean_status_index_entries_are_certifiable(istate) || (index_path && locked_index_entries_are_certifiable(istate)); full_proof = clean_status_has_current_full_fsmonitor_proof(istate); - repaired = !status.certify_untracked_scan_failed && no_pending && + repaired = !status->certify_untracked_scan_failed && no_pending && paired_untracked && valid_root && certifiable_index && full_proof; + if (repaired_status && repaired) { + struct object_id oid; + + status->status_format = STATUS_FORMAT_PORCELAIN_V2; + status->show_branch = 0; + status->is_initial = + repo_get_oid(repo, status->reference, &oid) ? 1 : 0; + if (!status->is_initial) + oidcpy(&status->oid_commit, &oid); + wt_status_collect_changes_worktree(status); + if (status->is_initial) + wt_status_collect_changes_initial(status); + else + wt_status_collect_changes_index(status); + } done: - wt_status_collect_free_buffers(&status); - string_list_clear(&status.change, 1); - string_list_clear(&status.untracked, 0); - string_list_clear(&status.ignored, 0); - free(status.branch); + if (!repaired_status || !repaired) + release_repair_status(status); trace2_data_intmax("fsmonitor", repo, "history/writer-proof-repaired", repaired); return repaired; @@ -2652,7 +2679,7 @@ static int repair_fsmonitor_proof( int wt_status_repair_fsmonitor_proof(struct repository *repo) { - return repair_fsmonitor_proof(repo, NULL, 0); + return repair_fsmonitor_proof(repo, NULL, 0, NULL); } int wt_status_prepare_fsmonitor_proof_for_worktree_update( @@ -2673,21 +2700,37 @@ int wt_status_repair_fsmonitor_proof_at_path( { if (!index_path || !*index_path) return 0; - return repair_fsmonitor_proof(repo, index_path, 0); + return repair_fsmonitor_proof(repo, index_path, 0, NULL); } static int repair_fsmonitor_proof_after_update( struct repository *repo, struct lock_file *lock, int had_full_proof, - int allow_manifest_refresh) + int allow_manifest_refresh, struct wt_status *repaired_status) { const char *proof_index_path; int repaired; - if (!had_full_proof || !fsmonitor_proof_repair_is_eligible(repo) || - (!allow_manifest_refresh && - clean_status_worktree_manifest_needs_refresh(repo->index))) + if (!had_full_proof) { + trace2_data_string("fsmonitor", repo, + "history/writer-repair-skip", "no-history"); + return 0; + } + if (repaired_status && !repo->index->untracked) + add_untracked_cache(repo->index); + if (!fsmonitor_proof_repair_is_eligible( + repo, !!repaired_status)) { + trace2_data_string("fsmonitor", repo, + "history/writer-repair-skip", "ineligible"); return 0; - if (!wt_status_fsmonitor_proof_needs_repair(repo) && + } + if (!allow_manifest_refresh && + clean_status_worktree_manifest_needs_refresh(repo->index)) { + trace2_data_string("fsmonitor", repo, + "history/writer-repair-skip", "manifest"); + return 0; + } + if (!repaired_status && + !wt_status_fsmonitor_proof_needs_repair(repo) && clean_status_has_current_full_fsmonitor_proof(repo->index) && locked_index_entries_have_stat_data(repo->index)) return 1; @@ -2698,7 +2741,8 @@ static int repair_fsmonitor_proof_after_update( if (write_locked_index(repo->index, lock, PROVISIONAL_LOCK)) return -1; proof_index_path = get_lock_file_path(lock); - repaired = repair_fsmonitor_proof(repo, proof_index_path, 1); + repaired = repair_fsmonitor_proof( + repo, proof_index_path, 1, repaired_status); if (reopen_lock_file(lock) < 0) return -1; return repaired; @@ -2708,14 +2752,48 @@ int wt_status_repair_fsmonitor_proof_after_worktree_update( struct repository *repo, struct lock_file *lock, int had_full_proof) { return repair_fsmonitor_proof_after_update( - repo, lock, had_full_proof, 0); + repo, lock, had_full_proof, 0, NULL); +} + +int wt_status_repair_fsmonitor_proof_after_worktree_update_with_sidecar( + struct repository *repo, struct lock_file *lock, int had_full_proof, + const struct clean_status_config_digest *config) +{ + struct wt_status status = { 0 }; + int installed = 0; + int repaired = repair_fsmonitor_proof_after_update( + repo, lock, had_full_proof, 1, &status); + + if (repaired <= 0) + return repaired; + if (write_locked_index( + repo->index, lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) { + release_repair_status(&status); + return -1; + } + /* Rebind the repaired proof to the index inode just committed. */ + discard_index(repo->index); + if (repo_read_index(repo) < 0) + goto done; + if (repo_hold_locked_index(repo, lock, 0) < 0) + goto done; + if (clean_status_issue_sidecar(&status, config, lock, 1)) + installed = 1; + else + rollback_lock_file(lock); + +done: + release_repair_status(&status); + trace2_data_intmax("status", repo, + "clean-proof/writer-sidecar", installed); + return repaired; } int wt_status_repair_fsmonitor_proof_after_index_update( struct repository *repo, struct lock_file *lock, int had_full_proof) { return repair_fsmonitor_proof_after_update( - repo, lock, had_full_proof, 1); + repo, lock, had_full_proof, 1, NULL); } static void wt_status_release_attr_snapshot(struct wt_status *s) diff --git a/wt-status.h b/wt-status.h index 69d8acdf35bad9..61cfa11c28dc44 100644 --- a/wt-status.h +++ b/wt-status.h @@ -9,6 +9,7 @@ struct repository; struct stat; struct lock_file; +struct clean_status_config_digest; struct attr_source_snapshot; struct exclude_source_proof; struct wt_status_exclude_context; @@ -197,6 +198,9 @@ int wt_status_prepare_fsmonitor_proof_for_worktree_update( int wt_status_fsmonitor_proof_needs_repair(struct repository *repo); int wt_status_repair_fsmonitor_proof_after_worktree_update( struct repository *repo, struct lock_file *lock, int had_full_proof); +int wt_status_repair_fsmonitor_proof_after_worktree_update_with_sidecar( + struct repository *repo, struct lock_file *lock, int had_full_proof, + const struct clean_status_config_digest *config); int wt_status_repair_fsmonitor_proof_after_index_update( struct repository *repo, struct lock_file *lock, int had_full_proof); void wt_status_invalidate_refresh(struct wt_status *s); From 7d45de11ee3f51f48a83151f0768a82de16f6a84 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 30 Aug 2026 04:50:00 -0500 Subject: [PATCH 426/432] rebase: reissue clean sidecar after continuing An authenticated clean-status sidecar is bound to the identity of the index file it certifies. During an interactive rebase, the child commit run by "rebase --continue" can replace that index. The worktree and index are clean when the replay finishes, but the remaining sidecar still names the old inode. The next read-only status rejects it with a fast-index-mismatch and falls back to the slower history path. Remember whether the rebase started with a regular, singly linked sidecar and persistent FSMonitor proof history. After the replay finishes successfully, reread the final index and use the existing writer-proof repair to publish a replacement sidecar. Only do so when optional locks are available and no post-index-change hook is configured, matching the existing stash guardrails. Move the sidecar-presence check and sidecar-capable repair helper into wt-status so stash and sequencer can share them. Cover a conflicted interactive rebase whose continuation must publish a replacement sidecar and whose next read-only status must hit it. --- builtin/stash.c | 15 ++-------- sequencer.c | 50 ++++++++++++++++++++++++++++++++- t/t7530-status-clean-sidecar.sh | 29 +++++++++++++++++++ wt-status.c | 13 ++++++++- wt-status.h | 3 +- 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/builtin/stash.c b/builtin/stash.c index 77d0a4513c0f70..cacf688e1bfa9b 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -424,7 +424,7 @@ static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, refresh_fsmonitor(the_repository->index); } repaired = worktree_updated && reissue_sidecar ? - wt_status_repair_fsmonitor_proof_after_worktree_update_with_sidecar( + wt_status_repair_fsmonitor_proof_after_update_with_sidecar( the_repository, &lock, had_full_proof, &stash_clean_digest) : worktree_updated ? wt_status_repair_fsmonitor_proof_after_worktree_update( @@ -444,17 +444,6 @@ static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, return 0; } -static int stash_clean_sidecar_present(void) -{ - struct stat st; - char *path = xstrfmt("%s.csts", repo_get_index_file(the_repository)); - int present = !lstat(path, &st) && S_ISREG(st.st_mode) && - st.st_nlink == 1; - - free(path); - return present; -} - static int create_index_from_tree(const struct object_id *tree_id, const char *index_path) { @@ -1847,7 +1836,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q repo_read_index_preload(the_repository, NULL, 0); had_full_proof = clean_status_has_persistent_fsmonitor_semantic_history( the_repository->index); - had_clean_sidecar = stash_clean_sidecar_present(); + had_clean_sidecar = wt_status_clean_sidecar_present(the_repository); if (!include_untracked && ps->nr) { char *ps_matched = xcalloc(ps->nr, 1); diff --git a/sequencer.c b/sequencer.c index e2ddd1ae0401f2..fb2a6bb354bb25 100644 --- a/sequencer.c +++ b/sequencer.c @@ -5615,16 +5615,54 @@ static int commit_staged_changes(struct repository *r, return ret; } +static int reissue_clean_sidecar_after_rebase( + struct repository *r, int had_full_proof, + const struct clean_status_config_digest *config) +{ + struct lock_file lock = LOCK_INIT; + int repaired; + + if (repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) + return error(_("could not write index")); + /* A replayed commit may have replaced the index in a child process. */ + discard_index(r->index); + if (repo_read_index(r) < 0) { + rollback_lock_file(&lock); + return error(_("could not read index")); + } + if (!r->index->fsmonitor_token_valid) { + r->index->fsmonitor_has_run_once = 0; + refresh_fsmonitor(r->index); + } + repaired = wt_status_repair_fsmonitor_proof_after_update_with_sidecar( + r, &lock, had_full_proof, config); + if (repaired < 0) { + rollback_lock_file(&lock); + return error(_("could not repair index")); + } + if (!repaired) + rollback_lock_file(&lock); + return 0; +} + int sequencer_continue(struct repository *r, struct replay_opts *opts) { struct todo_list todo_list = TODO_LIST_INIT; - int res; + int res, reissue_sidecar; + int had_clean_sidecar = wt_status_clean_sidecar_present(r); + int had_full_proof = 0; if (read_and_refresh_cache(r, opts)) return -1; if (read_populate_opts(opts)) return -1; + reissue_sidecar = is_rebase_i(opts) && had_clean_sidecar; + if (reissue_sidecar) + had_full_proof = + clean_status_has_persistent_fsmonitor_semantic_history( + r->index) || + clean_status_has_worktree_manifest_history(r->index); if (is_rebase_i(opts)) { if ((res = read_populate_todo(r, &todo_list, opts))) goto release_todo_list; @@ -5672,6 +5710,16 @@ int sequencer_continue(struct repository *r, struct replay_opts *opts) } res = pick_commits(r, &todo_list, opts); + if (!res && reissue_sidecar && had_full_proof && + use_optional_locks() && + !hook_exists(r, "post-index-change")) { + struct clean_status_config_digest digest; + + if (!clean_status_config_read_repository(r, &digest) && + reissue_clean_sidecar_after_rebase( + r, had_full_proof, &digest)) + res = -1; + } release_todo_list: todo_list_release(&todo_list); return res; diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index d7348dc874dc0b..19073e76b8f801 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3578,4 +3578,33 @@ test_expect_success DURABLE_FSMONITOR \ sidecar-scoped-stash scoped-stash-after ' +test_expect_success DURABLE_FSMONITOR \ + 'rebase --continue publishes a sidecar for its final clean index' ' + test_when_finished "stop_daemon sidecar-rebase-continue" && + setup_repo sidecar-rebase-continue && + git -C sidecar-rebase-continue config core.autocrlf false && + issue_sidecar sidecar-rebase-continue && + git -C sidecar-rebase-continue branch topic && + git -C sidecar-rebase-continue checkout -q -b upstream && + test_write_lines upstream >sidecar-rebase-continue/tracked && + git -C sidecar-rebase-continue add tracked && + git -C sidecar-rebase-continue commit -m upstream && + git -C sidecar-rebase-continue checkout -q topic && + test_write_lines topic >sidecar-rebase-continue/tracked && + git -C sidecar-rebase-continue add tracked && + git -C sidecar-rebase-continue commit -m topic && + test_must_fail git -C sidecar-rebase-continue rebase upstream && + test_path_is_file sidecar-rebase-continue/.git/index.csts && + test_write_lines resolved >sidecar-rebase-continue/tracked && + git -C sidecar-rebase-continue add tracked && + GIT_EDITOR=true \ + GIT_TRACE2_EVENT="$PWD/rebase-continue.trace" \ + git -C sidecar-rebase-continue rebase --continue && + test_trace2_data status clean-proof/writer-sidecar 1 \ + Date: Sun, 30 Aug 2026 05:27:49 -0500 Subject: [PATCH 427/432] status: issue clean proofs in linked worktrees The clean-status sidecar path accepts only the main worktree. A newly created linked worktree therefore cannot publish a proof after a full clean scan. Later read-only status commands rescan the worktree even though each linked worktree has its own index and sidecar path. Accept a linked worktree only when its per-worktree gitdir remains registered in the common directory and the registered path names the current worktree. Reject an alternate worktree paired with a linked- worktree gitdir; the repository fingerprint continues to bind the resolved worktree, gitdir, common directory, index, and filesystem identities. New worktrees are commonly probed with "git status --short". Let the exact top-level --short and -s forms certify empty output. A fresh worktree index can still be racy, so write and re-read it before saving resumable history and issuing the sidecar. This binds both proofs to the new on-disk index epoch. Cover issuance and optional-lock-free reuse in a registered linked worktree, and verify that an impostor worktree sharing its gitdir falls back. --- builtin/commit.c | 50 +++++++++++++++++++++++++++----- clean-status-fast.c | 12 +------- clean-status-sidecar-issue.c | 14 +++++---- clean-status-sidecar.c | 31 ++++++++++++++++---- clean-status-sidecar.h | 1 + clean-status.h | 2 +- t/t7519-status-fsmonitor.sh | 4 ++- t/t7527-builtin-fsmonitor.sh | 17 +++++++---- t/t7530-status-clean-sidecar.sh | 51 +++++++++++++++++++++++++++++++++ 9 files changed, 145 insertions(+), 37 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 97a9fec1a89476..8112fbf37e6694 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1898,8 +1898,13 @@ 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 short_clean_command = argc == 2 && + (!strcmp(argv[1], "--short") || !strcmp(argv[1], "-s")) && + (!prefix || !*prefix); int exact_clean_query; int normal_clean_query; + int short_clean_query; + int certifying_clean_query; int reusable_clean_query; int normal_has_head; int reissue_clean_sidecar = 0; @@ -1907,6 +1912,8 @@ struct repository *repo UNUSED) int sidecar_provider_reset = 0; int reissue_after_write = 0; int issue_exact_after_write = 0; + int issue_certifying_after_write = 0; + int reload_racy_after_write = 0; int exact_after_write_candidate = 0; int save_history_after_write = 0; int deferred_scoped_history = 0; @@ -2020,6 +2027,14 @@ struct repository *repo UNUSED) !s.submodule_summary && s.show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && !repo_config_values(the_repository)->apply_sparse_checkout; + short_clean_query = short_clean_command && + status_format == STATUS_FORMAT_SHORT && 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; + certifying_clean_query = normal_clean_query || short_clean_query; reusable_clean_query = normal_has_head && !s.show_ignored_mode && !s.submodule_summary && /* A clean merge still prints a staged-changes header with -vv. */ @@ -2030,7 +2045,7 @@ struct repository *repo UNUSED) !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; + s.certify_clean_status = exact_clean_query || short_clean_query; if (reusable_clean_query && clean_status_try_sidecar(the_repository, &clean_digest, &repository_inputs_changed, @@ -2041,7 +2056,7 @@ struct repository *repo UNUSED) return 0; } } - if (normal_clean_query && optional_status_writes && + if (certifying_clean_query && optional_status_writes && clean_status_identity_is_durable()) reissue_clean_sidecar = clean_status_sidecar_needs_reissue( @@ -2091,7 +2106,7 @@ struct repository *repo UNUSED) clean_status_capture_external_history_source( the_repository->index); } - if (normal_clean_query && optional_status_writes && + if (certifying_clean_query && optional_status_writes && clean_status_identity_is_durable() && (reissue_clean_sidecar || clean_status_external_history_was_restored( @@ -2165,6 +2180,8 @@ struct repository *repo UNUSED) the_repository->index); int external_saved = 0; int persist_restored_boundary = 0; + int racy_fresh_history = !external_restored && + has_racy_timestamp(the_repository->index); int preserve_entry_changes = (!external_restored && (the_repository->index->cache_changed & CE_ENTRY_CHANGED)) || @@ -2194,6 +2211,11 @@ struct repository *repo UNUSED) else if (deferred_history && !hook_exists(the_repository, "post-index-change")) save_history_after_write = 1; + if (certifying_clean_query && racy_fresh_history && + !s.change.nr && !s.untracked.nr && !s.ignored.nr && + !external_saved && + !hook_exists(the_repository, "post-index-change")) + save_history_after_write = reload_racy_after_write = 1; if (external_restored && !external_saved && clean_status_external_history_owns_index( the_repository->index) && @@ -2202,7 +2224,7 @@ struct repository *repo UNUSED) trace2_data_intmax("fsmonitor", the_repository, "history/external-racy-index-persisted", 1); } - reissue_after_write = normal_clean_query && + reissue_after_write = certifying_clean_query && reissue_clean_sidecar && preserve_entry_changes && !external_restored && !persist_restored_boundary && !hook_exists(the_repository, "post-index-change"); @@ -2235,7 +2257,7 @@ struct repository *repo UNUSED) } } else if (!preserve_entry_changes && !persist_restored_boundary && - normal_clean_query && + certifying_clean_query && (external_restored || reissue_clean_sidecar) && clean_status_issue_sidecar( &s, &clean_digest, &index_lock, 1)) { @@ -2260,6 +2282,16 @@ struct repository *repo UNUSED) &written_index); clean_status_index_adopt_write_receipt(the_repository->index, &written_index); + if (reload_racy_after_write) { + /* + * Re-read the committed index before checkpointing a fresh racy + * checkout. The old in-memory epoch cannot authenticate the new + * file even though this status established its clean contents. + */ + discard_index(the_repository->index); + if (repo_read_index(the_repository) < 0) + save_history_after_write = 0; + } if (save_history_after_write && !hook_exists(the_repository, "post-index-change") && repo_hold_locked_index(the_repository, &index_lock, 0) >= 0) { @@ -2269,14 +2301,18 @@ struct repository *repo UNUSED) "history/external-postwrite-stored", 1); if (exact_after_write_candidate) issue_exact_after_write = 1; + else if (certifying_clean_query) + issue_certifying_after_write = 1; } rollback_lock_file(&index_lock); } - if ((reissue_after_write || issue_exact_after_write) && + if ((reissue_after_write || issue_exact_after_write || + issue_certifying_after_write) && repo_hold_locked_index(the_repository, &index_lock, 0) >= 0) { if (clean_status_issue_sidecar( &s, &clean_digest, &index_lock, - reissue_after_write)) + reissue_after_write || + issue_certifying_after_write)) trace2_data_intmax("status", the_repository, reissue_after_write ? "clean-proof/postwrite-reissued" : diff --git a/clean-status-fast.c b/clean-status-fast.c index 57b6d19d4b0179..c81fdda80f648a 100644 --- a/clean-status-fast.c +++ b/clean-status-fast.c @@ -14,7 +14,6 @@ #include "repository.h" #include "semantic-verify-internal.h" #include "trace2.h" -#include "worktree.h" #include "wrapper.h" #if !EXCLUDE_SOURCE_PROOF_HAS_ANCHORED_OPEN @@ -188,15 +187,6 @@ static int fast_path_test_barrier(void) 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, @@ -222,7 +212,7 @@ int clean_status_try_sidecar( (config->filter_configured && config->normalized_filter_disable) || getenv(INDEX_ENVIRONMENT) || is_bare_repository(repo) || !repo_get_work_tree(repo) || - !current_worktree_is_main(repo) || + !clean_status_worktree_shape_supported(repo) || fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC) { trace_miss(repo, "fast-repository-shape"); goto done; diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index 91cd4bc9960d25..43ed7e541e8936 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -44,13 +44,15 @@ static int issue_test_barrier(void) } static int output_is_certifiable(const struct wt_status *status, - int normal_clean_query) + int certifying_clean_query) { return (status->status_format == STATUS_FORMAT_PORCELAIN_V2 || - (normal_clean_query && - status->status_format == STATUS_FORMAT_NONE)) && + (certifying_clean_query && + (status->status_format == STATUS_FORMAT_NONE || + status->status_format == STATUS_FORMAT_SHORT))) && !status->pathspec.nr && !status->show_branch && - (!status->show_stash || normal_clean_query) && + (!status->show_stash || + status->status_format == STATUS_FORMAT_NONE) && !status->show_ignored_mode && !status->null_termination && !status->verbose && status->show_untracked_files == SHOW_NORMAL_UNTRACKED_FILES && @@ -215,7 +217,7 @@ int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, struct lock_file *index_lock, - int normal_clean_query) + int certifying_clean_query) { struct repository *repo = status->repo; struct index_state *istate = repo->index; @@ -230,7 +232,7 @@ int clean_status_issue_sidecar( if (!is_lock_file_locked(index_lock) || !config->finalized || - !output_is_certifiable(status, normal_clean_query)) { + !output_is_certifiable(status, certifying_clean_query)) { trace_miss(repo, "issue-command-or-output"); goto done; } diff --git a/clean-status-sidecar.c b/clean-status-sidecar.c index b40959bcd301ee..24d0618b18b2bb 100644 --- a/clean-status-sidecar.c +++ b/clean-status-sidecar.c @@ -8,6 +8,7 @@ #include "attr-fingerprint.h" #include "clean-status-index.h" #include "clean-status-sidecar.h" +#include "dir.h" #include "fsmonitor-clean-proof.h" #include "hash-framing.h" #include "lockfile.h" @@ -430,12 +431,32 @@ int clean_status_sidecar_install( return ret; } -static int current_worktree_is_main(struct repository *repo) +int clean_status_worktree_shape_supported(struct repository *repo) { - struct worktree *worktree = get_current_worktree(repo); - int ret = worktree && is_main_worktree(worktree); + struct worktree *current = get_current_worktree(repo); + struct worktree *registered = NULL; + int ret = 0; - free_worktree(worktree); + if (!current) + goto done; + if (is_main_worktree(current)) { + ret = 1; + goto done; + } + if (!current->id) + goto done; + /* + * A linked worktree has its own index and sidecar. Accept it only when + * both repository paths still name the worktree registered in the common + * directory; an ad-hoc GIT_DIR/GIT_WORK_TREE pairing must fall back. + */ + registered = get_linked_worktree(repo, current->id, 1); + ret = registered && registered->is_current && + !fspathcmp(current->path, registered->path); + +done: + free_worktree(registered); + free_worktree(current); return ret; } @@ -479,7 +500,7 @@ int clean_status_repository_fingerprint( !index || index->fd < 0 || !scanned_worktree || is_bare_repository(repo) || !repo_get_work_tree(repo) || - !current_worktree_is_main(repo) || + !clean_status_worktree_shape_supported(repo) || repo_has_replace_refs_uncached(repo)) goto done; diff --git a/clean-status-sidecar.h b/clean-status-sidecar.h index a496246c28ddff..fe3829d86cc345 100644 --- a/clean-status-sidecar.h +++ b/clean-status-sidecar.h @@ -70,6 +70,7 @@ 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_worktree_shape_supported(struct repository *repo); int clean_status_repository_fingerprint( struct repository *repo, const struct attr_source_snapshot *attrs, diff --git a/clean-status.h b/clean-status.h index dacaaf28debfc3..76657895ff0bbb 100644 --- a/clean-status.h +++ b/clean-status.h @@ -116,7 +116,7 @@ int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, struct lock_file *index_lock, - int normal_clean_query); + int certifying_clean_query); int clean_status_try_sidecar( struct repository *repo, const struct clean_status_config_digest *config, diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index abbf645146c40f..58eb4547ac143a 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1646,6 +1646,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP test_must_be_empty "$gitdir/$mode.checkpoint" && test_trace2_data fsmonitor history/external-stored 1 \ <"$gitdir/$mode.checkpoint.trace" && + test_path_is_file "$gitdir/index.csts" && + rm "$gitdir/index.csts" && find "$gitdir" -maxdepth 1 -type f \ -name "index.csh1.*" >"$gitdir/$mode.csh" && test_line_count = 1 "$gitdir/$mode.csh" && @@ -1822,7 +1824,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP extension/fsmn/read/token builtin:test:3 \ <"$gitdir/$mode.checkout.trace" && test_trace2_data index \ - extension/fsmn/read/token builtin:test:1 \ + extension/fsmn/read/token builtin:test:2 \ <"$gitdir/$mode.checkout.trace" && test_region ! index do_write_index \ "$gitdir/$mode.checkout.trace" && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index a6756925082e51..aadb29adccedfe 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -2603,14 +2603,15 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep -q "fsmonitor_refresh_callback.*FILE-4-A.*pos 6" "$PWD/file_case_wrong-try1.log" && test_grep -q "fsmonitor_refresh_callback.*file-4-a.*pos -9" "$PWD/file_case_wrong-try1.log" && - # FSM refresh will have invalidated the FSM bit and cause a regular - # (real) scan of these tracked files, so they should have "H" status. - # (We will not see a "h" status until the next refresh (on the next - # command).) + # FSM refresh invalidates the FSM bit and causes a regular (real) scan + # of these tracked files. Authenticated external history may retain the + # refreshed bit for the following reader, so either marker is valid. git -C file_case_wrong ls-files -f >"$PWD/file_case_wrong-lsf1.out" && - test_grep -q "H dir1/dir2/dir3/file-3-a" "$PWD/file_case_wrong-lsf1.out" && - test_grep -q "H dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-lsf1.out" && + test_grep -E -q "^[Hh] dir1/dir2/dir3/file-3-a$" \ + "$PWD/file_case_wrong-lsf1.out" && + test_grep -E -q "^[Hh] dir1/dir2/dir4/FILE-4-A$" \ + "$PWD/file_case_wrong-lsf1.out" && # Try the status again. We assume that the above status command @@ -5541,6 +5542,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty .git/baseline && test_trace2_data fsmonitor history/external-stored 1 \ <"$TRASH_DIRECTORY/restored-racy-baseline.trace" && + test_path_is_file .git/index.csts && + rm .git/index.csts && cp .git/index .git/owned.before && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT="$TRASH_DIRECTORY/restored-racy-checkpoint.trace" \ @@ -5548,6 +5551,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty .git/checkpoint && test_trace2_data fsmonitor history/external-stored 1 \ <"$TRASH_DIRECTORY/restored-racy-checkpoint.trace" && + test_path_is_file .git/index.csts && + rm .git/index.csts && cp .git/owned.before .git/index && git -c core.fsmonitor=false update-index \ --no-fsmonitor --force-write-index && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 19073e76b8f801..82decb343de393 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3607,4 +3607,55 @@ test_expect_success DURABLE_FSMONITOR \ sidecar-rebase-continue rebase-continue-after ' +test_expect_success DURABLE_FSMONITOR \ + 'a registered linked worktree can issue and consume a clean sidecar' ' + test_when_finished "stop_daemon sidecar-linked-review" && + test_when_finished "stop_daemon sidecar-linked-main" && + setup_repo sidecar-linked-main && + git -C sidecar-linked-main config core.autocrlf false && + git -C sidecar-linked-main config core.untrackedCache true && + git -C sidecar-linked-main worktree add -q \ + -b sidecar-linked-topic ../sidecar-linked-review && + linked_gitdir=$(git -C sidecar-linked-review \ + rev-parse --absolute-git-dir) && + linked_index=$linked_gitdir/index && + test_path_is_missing "$linked_index.csts" && + test_env GIT_TRACE2_EVENT="$PWD/linked-short.trace" \ + bulk_status -C sidecar-linked-review status --short \ + >linked-short.actual && + test_must_be_empty linked-short.actual && + test_trace2_data status clean-proof/sidecar 1 \ + linked.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/linked-hit.trace" \ + git -C sidecar-linked-review status --porcelain=v2 \ + >linked.actual && + test_cmp linked.expect linked.actual && + test_cmp_bin linked.index "$linked_index" && + test_trace2_data status clean-proof/hit 1 linked-impostor.expect && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/linked-impostor.trace" \ + git --git-dir="$linked_gitdir" \ + --work-tree="$PWD/sidecar-linked-impostor" \ + status --porcelain=v2 >linked-impostor.actual && + test_cmp linked-impostor.expect linked-impostor.actual && + test_cmp_bin linked.index "$linked_index" && + test_trace2_data status clean-proof/miss fast-repository-shape \ + Date: Sun, 30 Aug 2026 05:44:33 -0500 Subject: [PATCH 428/432] status: make post-operation sidecar issuance fail closed Stash and rebase may repair and reissue a clean-status sidecar after their primary operation has completed. A lock, read, or proof-repair failure in that optional work currently replaces the successful command result. The command then reports failure even though it has already updated the repository and worktree. The repair path also commits its updated index before rereading it and issuing the sidecar. Another writer can replace the index in that gap. The old clean scan could then be bound to the replacement index. A later status could hide a newly staged change. Treat sidecar repair as best-effort after stash and rebase complete. Retain a descriptor-backed snapshot of the index produced by each postwrite clean scan. Require it to match both the reread index state and the canonical index path before installing the sidecar. A failed repair or intervening write therefore omits the cache and falls back to ordinary status without changing the primary command result. The normal sidecar-hit path remains unchanged. Cover lock contention after successful stash and rebase operations, and replace the index at deterministic postwrite barriers in both repair and status issuance paths. --- builtin/commit.c | 29 +++++- builtin/stash.c | 31 +++++- clean-status-sidecar-issue.c | 30 +++++- clean-status.h | 2 + read-cache-ll.h | 4 + read-cache.c | 22 +++-- sequencer.c | 30 ++++-- t/t7519-status-fsmonitor.sh | 1 + t/t7530-status-clean-sidecar.sh | 161 ++++++++++++++++++++++++++++++++ wt-status.c | 19 +++- 10 files changed, 299 insertions(+), 30 deletions(-) diff --git a/builtin/commit.c b/builtin/commit.c index 8112fbf37e6694..3687b5f07eca52 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -1916,12 +1916,17 @@ struct repository *repo UNUSED) int reload_racy_after_write = 0; int exact_after_write_candidate = 0; int save_history_after_write = 0; + int postwrite_sidecar_candidate = 0; + int postwrite_sidecar_pinned = 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, }; + struct clean_status_index_snapshot postwrite_sidecar_source = { + .fd = -1, + }; struct clean_status_index_write_receipt written_index = CLEAN_STATUS_INDEX_WRITE_RECEIPT_INIT; struct object_id oid; @@ -2247,7 +2252,7 @@ struct repository *repo UNUSED) } else if (exact_clean_query) { if (!preserve_entry_changes && external_saved && clean_status_issue_sidecar( - &s, &clean_digest, &index_lock, 0)) + &s, &clean_digest, &index_lock, NULL, 0)) fd = -1; else if (!preserve_entry_changes && !persist_restored_boundary && @@ -2260,7 +2265,7 @@ struct repository *repo UNUSED) certifying_clean_query && (external_restored || reissue_clean_sidecar) && clean_status_issue_sidecar( - &s, &clean_digest, &index_lock, 1)) { + &s, &clean_digest, &index_lock, NULL, 1)) { fd = -1; } else if (!preserve_entry_changes && !persist_restored_boundary && @@ -2278,10 +2283,25 @@ struct repository *repo UNUSED) "history/scoped-source-epoch-mismatch", 1); } if (0 <= fd) { + postwrite_sidecar_candidate = + reissue_after_write || issue_exact_after_write || + save_history_after_write; repo_update_index_if_able_with_receipt(the_repository, &index_lock, &written_index); + if (postwrite_sidecar_candidate) + postwrite_sidecar_pinned = + !clean_status_sidecar_postwrite_test_barrier(); clean_status_index_adopt_write_receipt(the_repository->index, &written_index); + if (postwrite_sidecar_pinned && + clean_status_index_snapshot_pin( + &postwrite_sidecar_source, + the_repository->index)) { + postwrite_sidecar_pinned = 0; + trace2_data_string("status", the_repository, + "clean-proof/miss", + "postwrite-index-raced"); + } if (reload_racy_after_write) { /* * Re-read the committed index before checkpointing a fresh racy @@ -2306,11 +2326,13 @@ struct repository *repo UNUSED) } rollback_lock_file(&index_lock); } - if ((reissue_after_write || issue_exact_after_write || + if (postwrite_sidecar_pinned && + (reissue_after_write || issue_exact_after_write || issue_certifying_after_write) && repo_hold_locked_index(the_repository, &index_lock, 0) >= 0) { if (clean_status_issue_sidecar( &s, &clean_digest, &index_lock, + &postwrite_sidecar_source, reissue_after_write || issue_certifying_after_write)) trace2_data_intmax("status", the_repository, @@ -2322,6 +2344,7 @@ struct repository *repo UNUSED) } } clean_status_index_write_receipt_release(&written_index); + clean_status_index_snapshot_release(&postwrite_sidecar_source); clean_status_index_snapshot_release(&scoped_history_source); if (s.relative_paths) diff --git a/builtin/stash.c b/builtin/stash.c index cacf688e1bfa9b..6f2ef4ac9aed7b 100644 --- a/builtin/stash.c +++ b/builtin/stash.c @@ -20,6 +20,7 @@ #include "unpack-trees.h" #include "merge-ort-wrappers.h" #include "strvec.h" +#include "trace2.h" #include "run-command.h" #include "dir.h" #include "entry.h" @@ -405,18 +406,33 @@ static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, int reissue_sidecar) { struct lock_file lock = LOCK_INIT; + int optional_reissue = worktree_updated && reissue_sidecar; + int lock_flags = optional_reissue ? 0 : LOCK_REPORT_ON_ERROR; int repaired; if (!had_full_proof) return 0; - if (repo_hold_locked_index(the_repository, &lock, - LOCK_REPORT_ON_ERROR) < 0) + /* A failed cache reissue must not change a completed stash result. */ + if (repo_hold_locked_index(the_repository, &lock, lock_flags) < 0) { + if (optional_reissue) { + trace2_data_string("status", the_repository, + "clean-proof/writer-sidecar-skip", + "index-lock"); + return 0; + } return error(_("could not write index")); + } /* Child commands and canonical publications may have replaced the inode. */ discard_index(the_repository->index); if (repo_read_index(the_repository) < 0) { rollback_lock_file(&lock); + if (optional_reissue) { + trace2_data_string("status", the_repository, + "clean-proof/writer-sidecar-skip", + "index-read"); + return 0; + } return error(_("could not read index")); } if (!the_repository->index->fsmonitor_token_valid) { @@ -433,10 +449,19 @@ static int repair_stash_fsmonitor_proof_after_update(int had_full_proof, the_repository, &lock, had_full_proof); if (repaired < 0) { rollback_lock_file(&lock); + if (optional_reissue) { + trace2_data_string("status", the_repository, + "clean-proof/writer-sidecar-skip", + "proof-repair"); + return 0; + } return error(_("could not repair index")); } - if (reissue_sidecar && worktree_updated && repaired > 0) + if (optional_reissue) { + if (!repaired) + rollback_lock_file(&lock); return 0; + } if (write_locked_index(the_repository->index, &lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) return error(_("could not write index")); diff --git a/clean-status-sidecar-issue.c b/clean-status-sidecar-issue.c index 43ed7e541e8936..6f89c627fbbcc1 100644 --- a/clean-status-sidecar-issue.c +++ b/clean-status-sidecar-issue.c @@ -24,12 +24,11 @@ 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) +static int sidecar_test_barrier(const char *ready_name, + const char *resume_name) { - const char *ready = - getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_READY"); - const char *resume = - getenv("GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_RESUME"); + const char *ready = getenv(ready_name); + const char *resume = getenv(resume_name); struct strbuf buf = STRBUF_INIT; int ret; @@ -43,6 +42,20 @@ static int issue_test_barrier(void) return ret; } +static int issue_test_barrier(void) +{ + return sidecar_test_barrier( + "GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_READY", + "GIT_TEST_STATUS_CLEAN_SIDECAR_ISSUE_BARRIER_RESUME"); +} + +int clean_status_sidecar_postwrite_test_barrier(void) +{ + return sidecar_test_barrier( + "GIT_TEST_STATUS_CLEAN_SIDECAR_POSTWRITE_BARRIER_READY", + "GIT_TEST_STATUS_CLEAN_SIDECAR_POSTWRITE_BARRIER_RESUME"); +} + static int output_is_certifiable(const struct wt_status *status, int certifying_clean_query) { @@ -217,6 +230,7 @@ int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, struct lock_file *index_lock, + const struct clean_status_index_snapshot *scanned_index, int certifying_clean_query) { struct repository *repo = status->repo; @@ -251,6 +265,12 @@ int clean_status_issue_sidecar( trace_miss(repo, "issue-test-barrier"); goto done; } + if (scanned_index && + !clean_status_index_snapshot_still_matches( + scanned_index, istate)) { + trace_miss(repo, "issue-index-raced"); + goto done; + } if (!status->attr_source_snapshot || clean_status_index_snapshot_pin(&index, istate) || clean_status_repository_fingerprint( diff --git a/clean-status.h b/clean-status.h index 76657895ff0bbb..2c462af91175c3 100644 --- a/clean-status.h +++ b/clean-status.h @@ -116,7 +116,9 @@ int clean_status_issue_sidecar( struct wt_status *status, const struct clean_status_config_digest *config, struct lock_file *index_lock, + const struct clean_status_index_snapshot *scanned_index, int certifying_clean_query); +int clean_status_sidecar_postwrite_test_barrier(void); int clean_status_try_sidecar( struct repository *repo, const struct clean_status_config_digest *config, diff --git a/read-cache-ll.h b/read-cache-ll.h index 62616b02be58d3..87ddfa59fb3114 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -364,6 +364,10 @@ int is_index_unborn(struct index_state *); * reopen and replace before commit; it therefore defers post-index-change. */ int write_locked_index(struct index_state *, struct lock_file *lock, unsigned flags); +/* Also retain a fail-closed receipt for the canonical file committed. */ +int write_locked_index_with_receipt( + struct index_state *, struct lock_file *, unsigned flags, + struct clean_status_index_write_receipt *); /* Commit's close-only main-index write and optional historical-only repair. */ int write_locked_index_for_commit( diff --git a/read-cache.c b/read-cache.c index 87ae9b508ed8d3..5851ab1bce4787 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3384,7 +3384,7 @@ int has_racy_timestamp(struct index_state *istate) return 0; } -static int write_locked_index_with_receipt( +static int write_locked_index_with_receipt_and_checkpoint( struct index_state *istate, struct lock_file *lock, unsigned flags, struct clean_status_index_write_receipt *receipt, struct clean_status_commit_checkpoint *checkpoint); @@ -3398,8 +3398,8 @@ void repo_update_index_if_able_with_receipt( if ((repo->index->cache_changed || has_racy_timestamp(repo->index)) && repo_verify_index(repo)) - write_locked_index_with_receipt(repo->index, lockfile, - COMMIT_LOCK, receipt, NULL); + write_locked_index_with_receipt_and_checkpoint( + repo->index, lockfile, COMMIT_LOCK, receipt, NULL); else rollback_lock_file(lockfile); } @@ -4101,7 +4101,7 @@ static int too_many_not_shared_entries(struct index_state *istate) return (int64_t)istate->cache_nr * max_split < (int64_t)not_shared * 100; } -static int write_locked_index_with_receipt( +static int write_locked_index_with_receipt_and_checkpoint( struct index_state *istate, struct lock_file *lock, unsigned flags, struct clean_status_index_write_receipt *receipt, struct clean_status_commit_checkpoint *checkpoint) @@ -4201,7 +4201,16 @@ 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, NULL); + return write_locked_index_with_receipt_and_checkpoint( + istate, lock, flags, NULL, NULL); +} + +int write_locked_index_with_receipt( + struct index_state *istate, struct lock_file *lock, unsigned flags, + struct clean_status_index_write_receipt *receipt) +{ + return write_locked_index_with_receipt_and_checkpoint( + istate, lock, flags, receipt, NULL); } int write_locked_index_for_commit( @@ -4214,7 +4223,8 @@ int write_locked_index_for_commit( 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); + ret = write_locked_index_with_receipt_and_checkpoint( + istate, lock, 0, NULL, candidate); if (ret) clean_status_release_commit_checkpoint(candidate); else diff --git a/sequencer.c b/sequencer.c index fb2a6bb354bb25..e9a319bf06c1d5 100644 --- a/sequencer.c +++ b/sequencer.c @@ -36,6 +36,7 @@ #include "strvec.h" #include "quote.h" #include "trailer.h" +#include "trace2.h" #include "log-tree.h" #include "wt-status.h" #include "hashmap.h" @@ -5615,20 +5616,28 @@ static int commit_staged_changes(struct repository *r, return ret; } -static int reissue_clean_sidecar_after_rebase( +static void reissue_clean_sidecar_after_rebase( struct repository *r, int had_full_proof, const struct clean_status_config_digest *config) { struct lock_file lock = LOCK_INIT; int repaired; - if (repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) - return error(_("could not write index")); + /* The rebase has completed; a missing sidecar safely falls back. */ + if (repo_hold_locked_index(r, &lock, 0) < 0) { + trace2_data_string("status", r, + "clean-proof/writer-sidecar-skip", + "index-lock"); + return; + } /* A replayed commit may have replaced the index in a child process. */ discard_index(r->index); if (repo_read_index(r) < 0) { rollback_lock_file(&lock); - return error(_("could not read index")); + trace2_data_string("status", r, + "clean-proof/writer-sidecar-skip", + "index-read"); + return; } if (!r->index->fsmonitor_token_valid) { r->index->fsmonitor_has_run_once = 0; @@ -5638,11 +5647,13 @@ static int reissue_clean_sidecar_after_rebase( r, &lock, had_full_proof, config); if (repaired < 0) { rollback_lock_file(&lock); - return error(_("could not repair index")); + trace2_data_string("status", r, + "clean-proof/writer-sidecar-skip", + "proof-repair"); + return; } if (!repaired) rollback_lock_file(&lock); - return 0; } int sequencer_continue(struct repository *r, struct replay_opts *opts) @@ -5715,10 +5726,9 @@ int sequencer_continue(struct repository *r, struct replay_opts *opts) !hook_exists(r, "post-index-change")) { struct clean_status_config_digest digest; - if (!clean_status_config_read_repository(r, &digest) && - reissue_clean_sidecar_after_rebase( - r, had_full_proof, &digest)) - res = -1; + if (!clean_status_config_read_repository(r, &digest)) + reissue_clean_sidecar_after_rebase( + r, had_full_proof, &digest); } release_todo_list: todo_list_release(&todo_list); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 58eb4547ac143a..6476815f8c6272 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -2752,6 +2752,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ $tokens{"FSMN"} eq $tokens{"FSCF"}; EOF perl .git/check-mixed-writer-proof.pl <.git/index && + rm -f .git/index.csts && for run in first second do cp .git/index ".git/readonly-$run.index" && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index 82decb343de393..a0a3900d439b99 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -233,6 +233,50 @@ start_issue_raced_status () { wait_for_fast_ready } +start_postwrite_raced_stash () { + repo=$1 && + ready=$TRASH_DIRECTORY/$repo.postwrite-ready && + resume=$TRASH_DIRECTORY/$repo.postwrite-resume && + race_trace=$TRASH_DIRECTORY/$repo.postwrite-trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + : >"$ready" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + test_env \ + GIT_TEST_STATUS_CLEAN_SIDECAR_POSTWRITE_BARRIER_READY="$ready" \ + GIT_TEST_STATUS_CLEAN_SIDECAR_POSTWRITE_BARRIER_RESUME="$resume" \ + GIT_TRACE2_EVENT="$race_trace" \ + git -C "$repo" stash push -q --keep-index -- tracked \ + >raced.actual 9>&- & + status_pid=$! + } && + wait_for_fast_ready +} + +start_postwrite_raced_status () { + repo=$1 && + ready=$TRASH_DIRECTORY/$repo.postwrite-ready && + resume=$TRASH_DIRECTORY/$repo.postwrite-resume && + race_trace=$TRASH_DIRECTORY/$repo.postwrite-trace && + status_pid= && + rm -f "$ready" "$resume" "$race_trace" && + : >"$ready" && + mkfifo "$resume" && + exec 9<>"$resume" && + { + test_env \ + GIT_TEST_STATUS_CLEAN_SIDECAR_POSTWRITE_BARRIER_READY="$ready" \ + GIT_TEST_STATUS_CLEAN_SIDECAR_POSTWRITE_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 @@ -3607,6 +3651,123 @@ test_expect_success DURABLE_FSMONITOR \ sidecar-rebase-continue rebase-continue-after ' +test_expect_success DURABLE_FSMONITOR \ + 'scoped stash ignores optional sidecar lock contention' ' + test_when_finished "stop_daemon sidecar-scoped-stash-lock" && + test_when_finished "rm -f sidecar-scoped-stash-lock/.git/index.lock" && + setup_repo sidecar-scoped-stash-lock && + git -C sidecar-scoped-stash-lock config core.autocrlf false && + issue_sidecar sidecar-scoped-stash-lock && + assert_clean_sidecar_hit sidecar-scoped-stash-lock \ + sidecar-scoped-stash-lock scoped-stash-lock-before && + cp sidecar-scoped-stash-lock/tracked scoped-stash-lock.expect && + write_script sidecar-scoped-stash-lock/.git/hooks/post-checkout <<-\EOF && + : >.git/index.lock + EOF + test_write_lines changed >sidecar-scoped-stash-lock/tracked && + GIT_TRACE2_EVENT="$PWD/scoped-stash-lock.trace" \ + git -C sidecar-scoped-stash-lock stash push -q \ + --keep-index -- tracked 2>scoped-stash-lock.err && + test_trace2_data status clean-proof/writer-sidecar-skip index-lock \ + sidecar-rebase-lock/tracked && + git -C sidecar-rebase-lock add tracked && + git -C sidecar-rebase-lock commit -m upstream && + git -C sidecar-rebase-lock checkout -q topic && + test_write_lines topic >sidecar-rebase-lock/tracked && + git -C sidecar-rebase-lock add tracked && + git -C sidecar-rebase-lock commit -m topic && + test_must_fail git -C sidecar-rebase-lock rebase upstream && + test_write_lines resolved >rebase-lock.expect && + cp rebase-lock.expect sidecar-rebase-lock/tracked && + git -C sidecar-rebase-lock add tracked && + write_script sidecar-rebase-lock/.git/hooks/post-rewrite <<-\EOF && + : >.git/index.lock + EOF + GIT_EDITOR=true \ + GIT_TRACE2_EVENT="$PWD/rebase-lock.trace" \ + git -C sidecar-rebase-lock rebase --continue \ + 2>rebase-lock.err && + test_trace2_data status clean-proof/writer-sidecar-skip index-lock \ + sidecar-postwrite-race/tracked && + start_postwrite_raced_stash sidecar-postwrite-race && + test_write_lines concurrent >sidecar-postwrite-race/tracked && + git -C sidecar-postwrite-race add tracked && + GIT_OPTIONAL_LOCKS=1 git -C sidecar-postwrite-race \ + status --porcelain=v2 >postwrite-race.expect && + test_file_not_empty postwrite-race.expect && + finish_fast_raced_status && + test_must_be_empty raced.actual && + test_trace2_data status clean-proof/writer-sidecar 0 \ + <"$race_trace" && + assert_clean_sidecar_fallback sidecar-postwrite-race \ + sidecar-postwrite-race postwrite-race-after +' + +test_expect_success DURABLE_FSMONITOR \ + 'status cannot bind a clean scan to a replaced postwrite index' ' + test_when_finished "stop_daemon sidecar-status-postwrite-race" && + test_when_finished "cleanup_fast_race" && + setup_repo sidecar-status-postwrite-race && + git -C sidecar-status-postwrite-race config core.autocrlf false && + prime_semantic_history sidecar-status-postwrite-race && + test-tool chmtime -60 sidecar-status-postwrite-race/tracked && + test-tool -C sidecar-status-postwrite-race \ + fsmonitor-client flush >status-postwrite-race.flush && + start_postwrite_raced_status sidecar-status-postwrite-race && + test_write_lines concurrent >sidecar-status-postwrite-race/tracked && + git -C sidecar-status-postwrite-race add tracked && + GIT_OPTIONAL_LOCKS=1 git -C sidecar-status-postwrite-race \ + status --porcelain=v2 >status-postwrite-race.expect && + test_file_not_empty status-postwrite-race.expect && + finish_fast_raced_status && + test_must_be_empty raced.actual && + test_trace2_data status clean-proof/miss postwrite-index-raced \ + <"$race_trace" && + assert_clean_sidecar_fallback sidecar-status-postwrite-race \ + sidecar-status-postwrite-race status-postwrite-race-after \ + --porcelain=v2 && + test_cmp status-postwrite-race.expect \ + status-postwrite-race-after.actual +' + test_expect_success DURABLE_FSMONITOR \ 'a registered linked worktree can issue and consume a clean sidecar' ' test_when_finished "stop_daemon sidecar-linked-review" && diff --git a/wt-status.c b/wt-status.c index 895b822789d45d..262607468472ac 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2760,29 +2760,42 @@ int wt_status_repair_fsmonitor_proof_after_update_with_sidecar( const struct clean_status_config_digest *config) { struct wt_status status = { 0 }; + struct clean_status_index_snapshot scanned_index = { .fd = -1 }; + struct clean_status_index_write_receipt written_index = + CLEAN_STATUS_INDEX_WRITE_RECEIPT_INIT; int installed = 0; int repaired = repair_fsmonitor_proof_after_update( repo, lock, had_full_proof, 1, &status); if (repaired <= 0) return repaired; - if (write_locked_index( - repo->index, lock, COMMIT_LOCK | SKIP_IF_UNCHANGED)) { + if (write_locked_index_with_receipt( + repo->index, lock, COMMIT_LOCK | SKIP_IF_UNCHANGED, + &written_index)) { + clean_status_index_write_receipt_release(&written_index); release_repair_status(&status); return -1; } + if (clean_status_sidecar_postwrite_test_barrier()) + goto done; + clean_status_index_adopt_write_receipt(repo->index, &written_index); + if (clean_status_index_snapshot_pin(&scanned_index, repo->index)) + goto done; /* Rebind the repaired proof to the index inode just committed. */ discard_index(repo->index); if (repo_read_index(repo) < 0) goto done; if (repo_hold_locked_index(repo, lock, 0) < 0) goto done; - if (clean_status_issue_sidecar(&status, config, lock, 1)) + if (clean_status_issue_sidecar( + &status, config, lock, &scanned_index, 1)) installed = 1; else rollback_lock_file(lock); done: + clean_status_index_write_receipt_release(&written_index); + clean_status_index_snapshot_release(&scanned_index); release_repair_status(&status); trace2_data_intmax("status", repo, "clean-proof/writer-sidecar", installed); From 5e6128efbcdbf3f5f886b7ed74be78cf2224637b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 30 Aug 2026 15:17:49 -0500 Subject: [PATCH 429/432] t: make clean-status sidecar checks platform-safe Clean-status sidecars require a durable index identity on local APFS. The history behavior is still valid on other filesystems, but two tests required index.csts after their substantive assertions passed and failed during cleanup on Linux. Hardlink metadata events can also arrive before status refreshes the index. If that refresh creates a racy index, status may conservatively withhold a sidecar until a later scan restores its process-local proof. Requiring immediate reissuance made the test depend on provider timing. Gate sidecar removal on local APFS. For the racy-index case, accept either immediate reissuance or the conservative fallback, but require clean output and recovery to a new proof within three status calls. --- t/lib-semantic-verify.sh | 15 +++++++++++++++ t/t7519-status-fsmonitor.sh | 4 ++-- t/t7527-builtin-fsmonitor.sh | 6 ++---- t/t7530-status-clean-sidecar.sh | 33 +++++++++++++++++++++++++++++---- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/t/lib-semantic-verify.sh b/t/lib-semantic-verify.sh index b46fd064711daf..710eb56747a393 100644 --- a/t/lib-semantic-verify.sh +++ b/t/lib-semantic-verify.sh @@ -7,3 +7,18 @@ test_lazy_prereq SEMANTIC_VERIFY_ANCHORED_OPEN ' test_grep "^tracked raw-clean " actual ) ' + +test_lazy_prereq CLEAN_STATUS_SIDECAR ' + test_have_prereq MACOS && + /bin/df -l -t apfs "$TRASH_DIRECTORY" >/dev/null +' + +test_remove_clean_status_sidecar () { + if test_have_prereq CLEAN_STATUS_SIDECAR + then + test_path_is_file "$1" && + rm "$1" + else + test_path_is_missing "$1" + fi +} diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 6476815f8c6272..9ad0177240ff8d 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -1646,8 +1646,8 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELP test_must_be_empty "$gitdir/$mode.checkpoint" && test_trace2_data fsmonitor history/external-stored 1 \ <"$gitdir/$mode.checkpoint.trace" && - test_path_is_file "$gitdir/index.csts" && - rm "$gitdir/index.csts" && + test_remove_clean_status_sidecar \ + "$gitdir/index.csts" && find "$gitdir" -maxdepth 1 -type f \ -name "index.csh1.*" >"$gitdir/$mode.csh" && test_line_count = 1 "$gitdir/$mode.csh" && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index aadb29adccedfe..b51469d0aedbd2 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -5542,8 +5542,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty .git/baseline && test_trace2_data fsmonitor history/external-stored 1 \ <"$TRASH_DIRECTORY/restored-racy-baseline.trace" && - test_path_is_file .git/index.csts && - rm .git/index.csts && + test_remove_clean_status_sidecar .git/index.csts && cp .git/index .git/owned.before && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ GIT_TRACE2_EVENT="$TRASH_DIRECTORY/restored-racy-checkpoint.trace" \ @@ -5551,8 +5550,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_must_be_empty .git/checkpoint && test_trace2_data fsmonitor history/external-stored 1 \ <"$TRASH_DIRECTORY/restored-racy-checkpoint.trace" && - test_path_is_file .git/index.csts && - rm .git/index.csts && + test_remove_clean_status_sidecar .git/index.csts && cp .git/owned.before .git/index && git -c core.fsmonitor=false update-index \ --no-fsmonitor --force-write-index && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index a0a3900d439b99..e9c8eb2abd6363 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -1051,10 +1051,35 @@ test_expect_success DURABLE_FSMONITOR,PERL_TEST_HELPERS \ hardlink-sidecar-repair-reissue.trace && test_grep "\"label\":\"do_write_index\"" \ hardlink-sidecar-repair-reissue.trace && - test_trace2_data status clean-proof/sidecar 1 \ - "hardlink-sidecar-recovery-$recovery_attempt.actual" && + test_grep "nothing to commit, working tree clean" \ + "hardlink-sidecar-recovery-$recovery_attempt.actual" || + return 1 + if test_trace2_data status clean-proof/sidecar 1 \ + <"hardlink-sidecar-recovery-$recovery_attempt.trace" + then + hardlink_sidecar_recovered=1 && + break + fi + done && + test "$hardlink_sidecar_recovered" = 1 + fi && ! test_cmp sidecar-hardlink-stale-stat/.git/index.before-repair \ sidecar-hardlink-stale-stat/.git/index && assert_clean_sidecar_hit sidecar-hardlink-stale-stat \ From 8055b6c7da409eb1bd2001cfc2694c15114d5642 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 30 Aug 2026 15:18:02 -0500 Subject: [PATCH 430/432] status: preserve authenticated proofs across owned index writes b65fc919c0 (status: retain the identity of an index it rewrites, 2026-08-17) lets status keep a race-proof receipt for an index it rewrites. Receipt preparation duplicates the writer descriptor so it can hash the final bytes, and requires the in-memory checksum to match the configured null trailer. Three owned write paths can leave an otherwise valid index without a usable clean proof. A worktree-update repair first writes a checksummed provisional index, then reopens the lockfile write-only before the final skipHash write while the index still records the provisional checksum. Receipt preparation rejects both states, so scoped stash cannot publish a sidecar for the index it installs. Worktree add creates its linked index before the new worktree has a closed FSMonitor provider epoch. The index has FSMonitor and untracked-cache extensions, but lacks the authenticated clean-config proof. Later read-only status processes remain correct, but cannot persist that proof and repeat the full fallback on every invocation. A post-checkout hook can also change worktree-specific configuration. Checking the invoking worktree's settings after the hook can therefore skip priming when the hook enables FSMonitor and the untracked cache only in the new worktree. A clean non-fast-forward merge repairs its authenticated index proof before committing, but leaves the existing sidecar bound to the old index and HEAD tree. The next read-only status rejects it with a fast-index-mismatch and scans the semantic manifest. Only a later writable status can replace the stale sidecar. On Apple, add a read-write reopen operation only for the provisional index lock so receipt preparation can read the final index. Fall back to the original write-only reopen when read access is unavailable, allowing the write to succeed without a receipt. Other platforms retain the write-only reopen. Finish provisional writes through a cold helper that restores the null object ID before the receipt-aware write. After worktree add successfully runs the post-checkout hook, read the linked worktree's effective FSMonitor and untracked-cache settings. If both features are enabled, run one silent status to establish the provider epoch and persist the complete proof. Do so only when the caller permits optional locks; never override an explicit --no-optional-locks request. Factor the best-effort sidecar reissue used by rebase into wt-status. After a successful merge commit has installed its final HEAD and index, use that helper to authenticate the settled state. Sidecar failure still falls back to ordinary status and never changes the merge result. Cover receipt publication and adoption after scoped stash, preserve the generic write-only tempfile contract, and require worktree add to honor post-checkout index writes, linked-worktree configuration, and disabled optional locks. Also require a clean non-fast-forward merge to publish a sidecar that its next read-only status can consume. --- Makefile | 2 + builtin/merge.c | 12 +++ builtin/worktree.c | 51 +++++++++++- clean-status-index-provisional.c | 16 ++++ clean-status-index.h | 6 ++ lockfile.h | 5 ++ meson.build | 1 + sequencer.c | 42 +--------- t/helper/test-mktemp.c | 17 ++++ t/t0070-fundamental.sh | 5 ++ t/t7519-status-fsmonitor.sh | 136 +++++++++++++++++++++++++++++++ t/t7530-status-clean-sidecar.sh | 31 ++++++- tempfile.c | 14 +++- tempfile.h | 7 ++ wt-status.c | 50 +++++++++++- wt-status.h | 3 + 16 files changed, 352 insertions(+), 46 deletions(-) create mode 100644 clean-status-index-provisional.c diff --git a/Makefile b/Makefile index fd867037fcbc4e..cd39446750b87f 100644 --- a/Makefile +++ b/Makefile @@ -2580,6 +2580,8 @@ LIBS = $(filter-out %.o, $(GITLIBS)) $(EXTLIBS) BASIC_CFLAGS += $(COMPAT_CFLAGS) LIB_OBJS += $(COMPAT_OBJS) +# Keep provisional-index glue after every existing library object. +LIB_OBJS += clean-status-index-provisional.o # Quote for C diff --git a/builtin/merge.c b/builtin/merge.c index 3c8dc78f3d07f4..a8438823c591b8 100644 --- a/builtin/merge.c +++ b/builtin/merge.c @@ -1431,6 +1431,7 @@ int cmd_merge(int argc, struct strbuf buf = STRBUF_INIT; int i, ret = 0, head_subsumed; int best_cnt = -1, merge_was_ok = 0, automerge_was_ok = 0; + int merge_committed = 0, reissue_sidecar = 0; int repair_after_merge = 0; struct commit_list *common = NULL; const char *best_strategy = NULL, *wt_strategy = NULL; @@ -1536,6 +1537,8 @@ int cmd_merge(int argc, !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); + reissue_sidecar = + wt_status_clean_sidecar_present(the_repository); } if (repo_read_index_unmerged(the_repository)) @@ -1800,6 +1803,8 @@ int cmd_merge(int argc, &remoteheads->item->object.oid)) { ret = merge_trivial(head_commit, remoteheads, repair_after_merge); + if (!ret) + merge_committed = 1; goto done; } printf(_("Nope.\n")); @@ -1909,6 +1914,8 @@ int cmd_merge(int argc, ret = finish_automerge(head_commit, head_subsumed, common, remoteheads, &result_tree, wt_strategy); + if (!ret) + merge_committed = 1; goto done; } @@ -1957,6 +1964,11 @@ int cmd_merge(int argc, printf(_("When finished, apply stashed changes with `git stash pop`\n")); done: + if (!ret && merge_committed && reissue_sidecar && + repair_after_merge && use_optional_locks() && + !hook_exists(the_repository, "post-index-change")) + wt_status_reissue_clean_sidecar_after_worktree_update( + the_repository, repair_after_merge, &clean_digest); if (!automerge_was_ok) { commit_list_free(common); commit_list_free(remoteheads); diff --git a/builtin/worktree.c b/builtin/worktree.c index 654d27c3e1ce99..54600ac27da05e 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -9,6 +9,7 @@ #include "copy.h" #include "dir.h" #include "environment.h" +#include "fsmonitor-settings.h" #include "gettext.h" #include "hex.h" #include "object-file.h" @@ -21,12 +22,14 @@ #include "refs.h" #include "remote.h" #include "run-command.h" +#include "repo-settings.h" #include "hook.h" #include "sigchain.h" #include "submodule.h" #include "utf8.h" #include "worktree.h" #include "quote.h" +#include "trace2.h" #define BUILTIN_WORKTREE_ADD_USAGE \ N_("git worktree add [-f] [--detach] [--checkout] [--lock [--reason ]]\n" \ @@ -409,6 +412,46 @@ static int checkout_worktree(const struct add_opts *opts, return run_command(&cp); } +static void prime_worktree_clean_status_proof(const char *path) +{ + struct child_process cp = CHILD_PROCESS_INIT; + int ret; + + /* + * The checkout creates the linked index before it has a provider epoch + * from which to certify the worktree. Establish that epoch while this + * writer can still update the index; a later read-only status cannot + * persist the missing proof. + */ + cp.git_cmd = 1; + cp.dir = path; + cp.no_stdin = 1; + cp.no_stdout = 1; + cp.no_stderr = 1; + strvec_pushl(&cp.args, "status", "--porcelain=v2", + "--untracked-files=normal", NULL); + strvec_push(&cp.env, GIT_DIR_ENVIRONMENT); + strvec_push(&cp.env, GIT_WORK_TREE_ENVIRONMENT); + ret = run_command(&cp); + trace2_data_intmax("worktree", the_repository, + "add/clean-status-primed", !ret); +} + +static int worktree_clean_status_proof_is_enabled(const char *git_dir, + const char *work_tree) +{ + struct repository repo; + int enabled; + + if (repo_init(&repo, git_dir, work_tree)) + return 0; + prepare_repo_settings(&repo); + enabled = fsm_settings__get_mode(&repo) == FSMONITOR_MODE_IPC && + repo.settings.core_untracked_cache == UNTRACKED_CACHE_WRITE; + repo_clear(&repo); + return enabled; +} + static int make_worktree_orphan(const char * ref, const struct add_opts *opts, struct strvec *child_env) { @@ -593,7 +636,6 @@ static int add_worktree(const char *path, const char *refname, if (opts->checkout && (ret = checkout_worktree(opts, &child_env))) goto done; - is_junk = 0; FREE_AND_NULL(junk_work_tree); FREE_AND_NULL(junk_git_dir); @@ -622,6 +664,13 @@ static int add_worktree(const char *path, const char *refname, ret = run_hooks_opt(the_repository, "post-checkout", &opt); } + if (!ret && opts->checkout && use_optional_locks() && + !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(DB_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && + worktree_clean_status_proof_is_enabled(sb_repo.buf, path)) + prime_worktree_clean_status_proof(path); strvec_clear(&child_env); strbuf_release(&sb); diff --git a/clean-status-index-provisional.c b/clean-status-index-provisional.c new file mode 100644 index 00000000000000..5e7b920810a838 --- /dev/null +++ b/clean-status-index-provisional.c @@ -0,0 +1,16 @@ +#include "git-compat-util.h" +#include "clean-status-index.h" +#include "lockfile.h" +#include "read-cache-ll.h" +#include "repository.h" + +int clean_status_write_index_after_provisional( + struct index_state *istate, struct lock_file *lock, unsigned flags, + struct clean_status_index_write_receipt *receipt) +{ + /* Let receipt preparation authenticate the final null trailer. */ + if (istate->repo->settings.index_skip_hash) + oidcpy(&istate->oid, istate->repo->hash_algo->null_oid); + return write_locked_index_with_receipt( + istate, lock, flags, receipt); +} diff --git a/clean-status-index.h b/clean-status-index.h index 4cb60df0dedd40..d2c6e88c896441 100644 --- a/clean-status-index.h +++ b/clean-status-index.h @@ -5,6 +5,7 @@ #include "hash.h" struct index_state; +struct lock_file; struct repository; /* @@ -52,6 +53,11 @@ int clean_status_index_adopt_write_receipt( void clean_status_index_write_receipt_release( struct clean_status_index_write_receipt *receipt); +/* Finish a provisional write without widening the ordinary status hot path. */ +int clean_status_write_index_after_provisional( + struct index_state *istate, struct lock_file *lock, unsigned flags, + 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/lockfile.h b/lockfile.h index 1667612674b52a..660217e3269ac6 100644 --- a/lockfile.h +++ b/lockfile.h @@ -344,6 +344,11 @@ static inline int reopen_lock_file(struct lock_file *lk) return reopen_tempfile(lk->tempfile); } +static inline int reopen_lock_file_for_readwrite(struct lock_file *lk) +{ + return reopen_tempfile_for_readwrite(lk->tempfile); +} + /* * Commit the change represented by `lk`: close the file descriptor * and/or file pointer if they are still open and rename the lockfile diff --git a/meson.build b/meson.build index bad1fd85101cb3..da072f54758d29 100644 --- a/meson.build +++ b/meson.build @@ -340,6 +340,7 @@ libgit_sources = [ 'clean-status-history.c', 'clean-status-identity.c', 'clean-status-index.c', + 'clean-status-index-provisional.c', 'clean-status-manifest.c', 'clean-status-sidecar.c', 'clean-status-fast.c', diff --git a/sequencer.c b/sequencer.c index e9a319bf06c1d5..94ecd2dd2c1605 100644 --- a/sequencer.c +++ b/sequencer.c @@ -5616,46 +5616,6 @@ static int commit_staged_changes(struct repository *r, return ret; } -static void reissue_clean_sidecar_after_rebase( - struct repository *r, int had_full_proof, - const struct clean_status_config_digest *config) -{ - struct lock_file lock = LOCK_INIT; - int repaired; - - /* The rebase has completed; a missing sidecar safely falls back. */ - if (repo_hold_locked_index(r, &lock, 0) < 0) { - trace2_data_string("status", r, - "clean-proof/writer-sidecar-skip", - "index-lock"); - return; - } - /* A replayed commit may have replaced the index in a child process. */ - discard_index(r->index); - if (repo_read_index(r) < 0) { - rollback_lock_file(&lock); - trace2_data_string("status", r, - "clean-proof/writer-sidecar-skip", - "index-read"); - return; - } - if (!r->index->fsmonitor_token_valid) { - r->index->fsmonitor_has_run_once = 0; - refresh_fsmonitor(r->index); - } - repaired = wt_status_repair_fsmonitor_proof_after_update_with_sidecar( - r, &lock, had_full_proof, config); - if (repaired < 0) { - rollback_lock_file(&lock); - trace2_data_string("status", r, - "clean-proof/writer-sidecar-skip", - "proof-repair"); - return; - } - if (!repaired) - rollback_lock_file(&lock); -} - int sequencer_continue(struct repository *r, struct replay_opts *opts) { struct todo_list todo_list = TODO_LIST_INIT; @@ -5727,7 +5687,7 @@ int sequencer_continue(struct repository *r, struct replay_opts *opts) struct clean_status_config_digest digest; if (!clean_status_config_read_repository(r, &digest)) - reissue_clean_sidecar_after_rebase( + wt_status_reissue_clean_sidecar_after_worktree_update( r, had_full_proof, &digest); } release_todo_list: diff --git a/t/helper/test-mktemp.c b/t/helper/test-mktemp.c index da195640a9dcc8..3edd08de482bf3 100644 --- a/t/helper/test-mktemp.c +++ b/t/helper/test-mktemp.c @@ -3,11 +3,28 @@ */ #include "test-tool.h" #include "git-compat-util.h" +#include "tempfile.h" int cmd__mktemp(int argc, const char **argv) { char *template; int fd; + struct tempfile *tempfile; + + if (argc == 3 && !strcmp(argv[1], "--reopen-write-only")) { + tempfile = mks_tempfile_m(argv[2], 0200); + if (!tempfile) + die_errno("unable to create tempfile"); + if (close_tempfile_gently(tempfile)) + die_errno("unable to close tempfile"); + if (reopen_tempfile_for_readwrite(tempfile) >= 0) + die("unexpectedly reopened write-only tempfile for reading"); + if (reopen_tempfile(tempfile) < 0) + die_errno("unable to reopen write-only tempfile"); + if (delete_tempfile(&tempfile)) + die_errno("unable to delete tempfile"); + return 0; + } if (argc != 2) usage("Expected 1 parameter defining the temporary file template"); diff --git a/t/t0070-fundamental.sh b/t/t0070-fundamental.sh index 8f573c2a0e7f11..3878a413ed3c64 100755 --- a/t/t0070-fundamental.sh +++ b/t/t0070-fundamental.sh @@ -21,6 +21,11 @@ test_expect_success POSIXPERM,SANITY 'mktemp to unwritable directory prints file test_grep "cannotwrite/test" err ' +test_expect_success POSIXPERM,SANITY \ + 'reopen tempfile retains its write-only contract' ' + test-tool mktemp --reopen-write-only write-only-XXXXXX +' + test_expect_success 'git_mkstemps_mode does not fail if fd 0 is not open' ' git commit --allow-empty -m message <&- ' diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 9ad0177240ff8d..8980f85d0f3906 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -8164,6 +8164,142 @@ test_expect_success LINUX_SCOPED_HISTORY,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORE ) ' +test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'worktree add primes a read-only clean status proof' ' + test_when_finished "rm -rf worktree-add-proof worktree-add-proof-linked" && + test_when_finished \ + "git -C worktree-add-proof-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo worktree-add-proof && + ( + cd worktree-add-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines tracked >tracked && + git add tracked && + git commit -qm base && + git config core.fsmonitor true && + git config core.untrackedCache true && + GIT_TRACE2_EVENT="$PWD/.git/worktree-add.trace" \ + git worktree add --detach \ + ../worktree-add-proof-linked HEAD && + worktree="$PWD/../worktree-add-proof-linked" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + test_trace2_data worktree add/clean-status-primed 1 \ + <.git/worktree-add.trace && + test_fsmonitor_full_proof "$gitdir/index" paired && + cp "$gitdir/index" "$gitdir/before.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status.actual" && + test_must_be_empty "$gitdir/status.actual" && + test_cmp_bin "$gitdir/before.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_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'worktree add primes after a post-checkout index write' ' + test_when_finished "rm -rf worktree-add-hook-proof worktree-add-hook-proof-linked" && + test_when_finished \ + "git -C worktree-add-hook-proof-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo worktree-add-hook-proof && + ( + cd worktree-add-hook-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines tracked >tracked && + git add tracked && + git commit -qm base && + git config core.fsmonitor true && + git config core.untrackedCache true && + write_script .git/hooks/post-checkout <<-\EOF && + git update-index --no-fsmonitor + EOF + GIT_TRACE2_EVENT="$PWD/.git/worktree-add.trace" \ + git worktree add --detach \ + ../worktree-add-hook-proof-linked HEAD && + worktree="$PWD/../worktree-add-hook-proof-linked" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + test_trace2_data worktree add/clean-status-primed 1 \ + <.git/worktree-add.trace && + cp "$gitdir/index" "$gitdir/before.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status.actual" && + test_must_be_empty "$gitdir/status.actual" && + test_cmp_bin "$gitdir/before.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_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'worktree add primes from linked worktree config after post-checkout' ' + test_when_finished "rm -rf worktree-add-config-proof worktree-add-config-proof-linked" && + test_create_repo worktree-add-config-proof && + ( + cd worktree-add-config-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines tracked >tracked && + git add tracked && + git commit -qm base && + git config extensions.worktreeConfig true && + git config --worktree core.fsmonitor false && + git config --worktree core.untrackedCache false && + write_script .git/hooks/post-checkout <<-\EOF && + git update-index --no-fsmonitor && + git config --worktree core.fsmonitor true && + git config --worktree core.untrackedCache true + EOF + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$PWD/.git/worktree-add.trace" \ + git worktree add --detach \ + ../worktree-add-config-proof-linked HEAD && + worktree="$PWD/../worktree-add-config-proof-linked" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + test_trace2_data worktree add/clean-status-primed 1 \ + <.git/worktree-add.trace && + test_fsmonitor_full_proof "$gitdir/index" paired && + cp "$gitdir/index" "$gitdir/before.index" && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ + GIT_TRACE2_EVENT="$gitdir/status.trace" \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/status.actual" && + test_must_be_empty "$gitdir/status.actual" && + test_cmp_bin "$gitdir/before.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_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE \ + 'worktree add honors disabled optional locks' ' + test_when_finished "rm -rf worktree-add-no-locks worktree-add-no-locks-linked" && + test_create_repo worktree-add-no-locks && + ( + cd worktree-add-no-locks && + test_write_lines tracked >tracked && + git add tracked && + git commit -qm base && + git config core.fsmonitor true && + git config core.untrackedCache true && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/worktree-add.trace" \ + git worktree add --detach \ + ../worktree-add-no-locks-linked HEAD && + ! test_trace2_data worktree add/clean-status-primed 1 \ + <.git/worktree-add.trace + ) +' + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'repeated provider resets fall back before an unclosable rescan' ' test_when_finished "rm -rf builtin-closure-terminal-reset" && diff --git a/t/t7530-status-clean-sidecar.sh b/t/t7530-status-clean-sidecar.sh index e9c8eb2abd6363..855ddad7c700f5 100755 --- a/t/t7530-status-clean-sidecar.sh +++ b/t/t7530-status-clean-sidecar.sh @@ -3632,16 +3632,23 @@ test_expect_success PERL_TEST_HELPERS \ ' test_expect_success DURABLE_FSMONITOR \ - 'scoped stash publishes a sidecar for its final clean index' ' + 'scoped stash authenticates its final skipHash index' ' test_when_finished "stop_daemon sidecar-scoped-stash" && setup_repo sidecar-scoped-stash && git -C sidecar-scoped-stash config core.autocrlf false && + git -C sidecar-scoped-stash config feature.manyFiles true && issue_sidecar sidecar-scoped-stash && assert_clean_sidecar_hit sidecar-scoped-stash \ sidecar-scoped-stash scoped-stash-before && test_write_lines changed >sidecar-scoped-stash/tracked && GIT_TRACE2_EVENT="$PWD/scoped-stash.trace" \ git -C sidecar-scoped-stash stash push -q -- tracked && + test_trace2_data fsmonitor history/own-write-source-recorded 1 \ + fd) BUG("reopen_tempfile called for an open object"); - tempfile->fd = open(tempfile->filename.buf, O_WRONLY|O_TRUNC); + tempfile->fd = open(tempfile->filename.buf, flags | O_TRUNC); return tempfile->fd; } +int reopen_tempfile(struct tempfile *tempfile) +{ + return reopen_tempfile_with_flags(tempfile, O_WRONLY); +} + +int reopen_tempfile_for_readwrite(struct tempfile *tempfile) +{ + return reopen_tempfile_with_flags(tempfile, O_RDWR); +} + int rename_tempfile(struct tempfile **tempfile_p, const char *path) { struct tempfile *tempfile = *tempfile_p; diff --git a/tempfile.h b/tempfile.h index f571f3c609c04a..0f79228fc47f81 100644 --- a/tempfile.h +++ b/tempfile.h @@ -267,6 +267,13 @@ int close_tempfile_gently(struct tempfile *tempfile); */ int reopen_tempfile(struct tempfile *tempfile); +/* + * Like `reopen_tempfile()`, but open the temporary file for both reading and + * writing. This requires read permission in addition to the write permission + * required by the ordinary reopen operation. + */ +int reopen_tempfile_for_readwrite(struct tempfile *tempfile); + /* * Close the file descriptor and/or file pointer and remove the * temporary file associated with `tempfile`. It is a NOOP to call diff --git a/wt-status.c b/wt-status.c index 262607468472ac..a5983ad5916413 100644 --- a/wt-status.c +++ b/wt-status.c @@ -2743,8 +2743,16 @@ static int repair_fsmonitor_proof_after_update( proof_index_path = get_lock_file_path(lock); repaired = repair_fsmonitor_proof( repo, proof_index_path, 1, repaired_status); + /* Receipt preparation needs read access, but the write must not. */ +#ifdef __APPLE__ + /* Preserve the read-write reopen historically used on Apple platforms. */ + if (reopen_lock_file_for_readwrite(lock) < 0 && + reopen_lock_file(lock) < 0) + return -1; +#else if (reopen_lock_file(lock) < 0) return -1; +#endif return repaired; } @@ -2769,7 +2777,7 @@ int wt_status_repair_fsmonitor_proof_after_update_with_sidecar( if (repaired <= 0) return repaired; - if (write_locked_index_with_receipt( + if (clean_status_write_index_after_provisional( repo->index, lock, COMMIT_LOCK | SKIP_IF_UNCHANGED, &written_index)) { clean_status_index_write_receipt_release(&written_index); @@ -2813,6 +2821,46 @@ int wt_status_clean_sidecar_present(struct repository *repo) return present; } +void wt_status_reissue_clean_sidecar_after_worktree_update( + struct repository *repo, int had_full_proof, + const struct clean_status_config_digest *config) +{ + struct lock_file lock = LOCK_INIT; + int repaired; + + /* A missing replacement sidecar safely falls back to normal status. */ + if (repo_hold_locked_index(repo, &lock, 0) < 0) { + trace2_data_string("status", repo, + "clean-proof/writer-sidecar-skip", + "index-lock"); + return; + } + /* A child command may have replaced the canonical index inode. */ + discard_index(repo->index); + if (repo_read_index(repo) < 0) { + rollback_lock_file(&lock); + trace2_data_string("status", repo, + "clean-proof/writer-sidecar-skip", + "index-read"); + return; + } + if (!repo->index->fsmonitor_token_valid) { + repo->index->fsmonitor_has_run_once = 0; + refresh_fsmonitor(repo->index); + } + repaired = wt_status_repair_fsmonitor_proof_after_update_with_sidecar( + repo, &lock, had_full_proof, config); + if (repaired < 0) { + rollback_lock_file(&lock); + trace2_data_string("status", repo, + "clean-proof/writer-sidecar-skip", + "proof-repair"); + return; + } + if (!repaired) + rollback_lock_file(&lock); +} + int wt_status_repair_fsmonitor_proof_after_index_update( struct repository *repo, struct lock_file *lock, int had_full_proof) { diff --git a/wt-status.h b/wt-status.h index d158c8bc743862..5f3b906531cdcf 100644 --- a/wt-status.h +++ b/wt-status.h @@ -201,6 +201,9 @@ int wt_status_repair_fsmonitor_proof_after_worktree_update( int wt_status_repair_fsmonitor_proof_after_update_with_sidecar( struct repository *repo, struct lock_file *lock, int had_full_proof, const struct clean_status_config_digest *config); +void wt_status_reissue_clean_sidecar_after_worktree_update( + struct repository *repo, int had_full_proof, + const struct clean_status_config_digest *config); int wt_status_repair_fsmonitor_proof_after_index_update( struct repository *repo, struct lock_file *lock, int had_full_proof); int wt_status_clean_sidecar_present(struct repository *repo); From 1f60e885777af6dd3a7f78ade5447226b51030d6 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 31 Aug 2026 18:37:29 -0700 Subject: [PATCH 431/432] status: recognize disabled read-side filters A command can disable worktree-to-Git filters with empty clean and process commands and required=false. It does not need to disable smudge, which converts in the other direction. The status fingerprint only normalizes the four-setting form, so an otherwise equivalent three-part override discards scoped FSMonitor history and forces a tracked-file scan. Recognize the complete read-side override as well. Continue to fingerprint partial or mixed-driver overrides, and retain the normalized-filter bit so that temporarily disabling filters cannot publish a clean sidecar. The scoped proof must still establish that no tracked path uses a filter. Exercise all subsets with both hash algorithms, the three-setting diff invocation, and active-filter write and priming attempts with either form. --- clean-status-config.c | 20 ++++++++--- t/t7527-builtin-fsmonitor.sh | 52 +++++++++++++++++++++------- t/unit-tests/u-clean-status-config.c | 11 +++--- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/clean-status-config.c b/clean-status-config.c index 5c52bbca1c240c..67c5d780ada36b 100644 --- a/clean-status-config.c +++ b/clean-status-config.c @@ -19,9 +19,11 @@ #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_READ_DISABLED \ + (CLEAN_STATUS_FILTER_CLEAN | CLEAN_STATUS_FILTER_PROCESS | \ + CLEAN_STATUS_FILTER_REQUIRED) #define CLEAN_STATUS_FILTER_COMPLETE \ - (CLEAN_STATUS_FILTER_CLEAN | CLEAN_STATUS_FILTER_SMUDGE | \ - CLEAN_STATUS_FILTER_PROCESS | CLEAN_STATUS_FILTER_REQUIRED) + (CLEAN_STATUS_FILTER_READ_DISABLED | CLEAN_STATUS_FILTER_SMUDGE) struct clean_status_pending_filter_entry { char *key; @@ -255,17 +257,25 @@ static void hash_retained_config_entry( static void flush_pending_filter(struct clean_status_config_digest *digest) { struct clean_status_pending_filter *pending = digest->pending_filter; + int read_disabled; if (!pending) return; - /* Remember command overrides omitted from the authenticated digest. */ - if (pending->mask == CLEAN_STATUS_FILTER_COMPLETE) + /* + * Read-side conversion does not use the smudge command. Disabling clean + * and process, with required=false, is sufficient for an authenticated + * filter-free scope. Keep recording the override so that it cannot + * publish a sidecar without rechecking that scope. + */ + read_disabled = (pending->mask & CLEAN_STATUS_FILTER_READ_DISABLED) == + CLEAN_STATUS_FILTER_READ_DISABLED; + if (read_disabled) digest->normalized_filter_disable = 1; 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) { + if (!read_disabled) { struct key_value_info kvi = KVI_INIT; struct config_context ctx = { .kvi = &kvi }; diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index b51469d0aedbd2..680b5535c6ee6e 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -8388,6 +8388,24 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ! test_trace2_data fsmonitor semantic/manifest-scan-count \ "[1-9][0-9]*" <.git/disabled-filter.trace && + GIT_CONFIG_COUNT=3 \ + GIT_CONFIG_KEY_0=filter.demo.clean GIT_CONFIG_VALUE_0= \ + GIT_CONFIG_KEY_1=filter.demo.process GIT_CONFIG_VALUE_1= \ + GIT_CONFIG_KEY_2=filter.demo.required GIT_CONFIG_VALUE_2=false \ + GIT_TEST_PRELOAD_INDEX=1 \ + GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ + GIT_TRACE2_EVENT="$PWD/.git/disabled-clean-filter.trace" \ + git -c core.fsmonitor=true -c core.hooksPath=/dev/null \ + diff --no-textconv --no-ext-diff --submodule=short \ + --ignore-submodules=dirty --color \ + >.git/disabled-clean-filter.out && + test_must_be_empty .git/disabled-clean-filter.out && + test_cmp_bin .git/disabled-filter.index .git/index && + test_trace2_data fsmonitor config/coherent 1 \ + <.git/disabled-clean-filter.trace && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" <.git/disabled-clean-filter.trace && + test_write_lines "tracked filter=demo" >.git/info/attributes && GIT_TEST_PRELOAD_INDEX=1 \ GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCC \ @@ -8406,8 +8424,10 @@ test_expect_success SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +for disable_smudge in true false +do test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'disabled filters cannot publish a proof for active filtered paths' ' + "disabled filters cannot publish a proof for active filtered paths (smudge: $disable_smudge)" ' test_when_finished "rm -rf disabled-filter-active-path" && test_create_repo disabled-filter-active-path && ( @@ -8431,14 +8451,16 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_trace2_data fsmonitor filter-scope/valid 1 \ <.git/prime.trace && + set -- -c filter.demo.clean= -c filter.demo.process= \ + -c filter.demo.required=false && + if test "$disable_smudge" = true + then + set -- "$@" -c filter.demo.smudge= + fi && 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 "$@" add active.filtered && GIT_OPTIONAL_LOCKS=0 \ git -c core.fsmonitor=false -c core.untrackedCache=false \ status --porcelain=v2 --untracked-files=no \ @@ -8452,6 +8474,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_cmp .git/expected .git/actual ) ' +done test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'partial required-filter overrides cannot hide a missing clean helper' ' @@ -8489,8 +8512,10 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ ) ' +for disable_smudge in true false +do test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ - 'disabled filters cannot prime a reusable proof for active clean filters' ' + "disabled filters cannot prime a reusable proof for active clean filters (smudge: $disable_smudge)" ' test_when_finished "rm -rf disabled-filter-prime" && test_create_repo disabled-filter-prime && ( @@ -8508,14 +8533,16 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ git config core.fsmonitor true && GIT_TEST_FSMONITOR_QUERY_SEQUENCE=C \ git update-index --fsmonitor && + set -- -c filter.demo.clean= -c filter.demo.process= \ + -c filter.demo.required=false && + if test "$disable_smudge" = true + then + set -- "$@" -c filter.demo.smudge= + fi && 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 "$@" status --porcelain=v2 --untracked-files=no \ >.git/disabled && test_must_be_empty .git/disabled && GIT_OPTIONAL_LOCKS=0 \ @@ -8531,6 +8558,7 @@ test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ test_cmp .git/expected .git/actual ) ' +done test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'unused configured filters preserve staged and dry-run history' ' diff --git a/t/unit-tests/u-clean-status-config.c b/t/unit-tests/u-clean-status-config.c index ae3984cf084419..169ef693d274cb 100644 --- a/t/unit-tests/u-clean-status-config.c +++ b/t/unit-tests/u-clean-status-config.c @@ -395,7 +395,7 @@ void test_clean_status_config__presentation_does_not_join_filter_parts(void) } } -void test_clean_status_config__only_complete_disabled_filters_are_normalized(void) +void test_clean_status_config__only_complete_disabled_clean_filters_are_normalized(void) { static const char *const keys[] = { "filter.demo.clean", "filter.demo.smudge", @@ -403,6 +403,7 @@ void test_clean_status_config__only_complete_disabled_filters_are_normalized(voi }; static const char *const values[] = { "", "", "", "false" }; static const int algorithms[] = { GIT_HASH_SHA1, GIT_HASH_SHA256 }; + const unsigned clean_parts = (1U << 0) | (1U << 2) | (1U << 3); struct key_value_info kvi = KVI_INIT; struct config_context ctx = { .kvi = &kvi }; @@ -418,6 +419,8 @@ void test_clean_status_config__only_complete_disabled_filters_are_normalized(voi cl_assert(!baseline.normalized_filter_disable); for (unsigned mask = 0; mask < (1U << ARRAY_SIZE(keys)); mask++) { + int disabled_clean = (mask & clean_parts) == clean_parts; + clean_status_config_init(&digest, algo); kvi.scope = CONFIG_SCOPE_LOCAL; clean_status_config_add(&digest, keys[0], "configured", &ctx); @@ -429,10 +432,10 @@ 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); + !mask || disabled_clean); cl_assert_equal_i(digest.normalized_filter_disable, - mask == 15); - if (mask == 15) { + disabled_clean); + if (disabled_clean) { cl_assert(hasheq(digest.semantic_hash, baseline.semantic_hash, algo)); cl_assert(hasheq(digest.tracked_policy_hash, From 5f1339f46d157228e848421aa6d2078ed55d7b22 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 31 Aug 2026 18:37:40 -0700 Subject: [PATCH 432/432] worktree: certify new indexes without optional locks The reset used by worktree add has no index proof to repair, and its explicit GIT_WORK_TREE prevents proof authentication. A later optional status can establish history, but GIT_OPTIONAL_LOCKS=0 suppresses that priming step. Subsequent read-only status calls cannot persist the missing proof and keep repeating the tracked-file scan. When FSMonitor and writable untracked caching are enabled, let the checkout discover the already registered worktree. Preserve the explicit repository environment for unsupported contexts, including relative configuration-file overrides whose meaning would change with cwd. For a hard reset that creates the index, certify the checkout under the mandatory index lock before committing it. Attach the current config to the new index state, query the provider after checkout, and reuse the writer repair machinery to bind the tracked and untracked proofs. Allow certification under this owned lock even when optional locks are disabled; ordinary read-only status still cannot write or repair its index. Require a complete untracked scan and a closing provider query. Active filters, provider errors, and provider resets must leave the checkout usable without certifying it. Cover those failures, post-checkout edits, and repeated read-only status calls that reuse the proof without writing the index or scanning tracked files. --- builtin/reset.c | 9 ++- builtin/worktree.c | 41 ++++++++--- t/t7519-status-fsmonitor.sh | 137 ++++++++++++++++++++++++++++++++++-- wt-status.c | 32 ++++++++- wt-status.h | 3 + 5 files changed, 204 insertions(+), 18 deletions(-) diff --git a/builtin/reset.c b/builtin/reset.c index 0f28886d02fa6a..5e7ffa8ffd0dcc 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -538,6 +538,9 @@ int cmd_reset(int argc, int repair_after_reset = reset_type == HARD && clean_status_has_current_full_fsmonitor_proof( the_repository->index); + /* A missing index has not acquired an on-disk version yet. */ + int prime_after_reset = reset_type == HARD && + !the_repository->index->version; repo_hold_locked_index(the_repository, &lock, LOCK_DIE_ON_ERROR); @@ -589,7 +592,11 @@ int cmd_reset(int argc, !the_repository->index->cache_changed && !hook_exists(the_repository, "post-index-change")) write_flags |= SKIP_IF_UNCHANGED; - if (reset_type == HARD && + if (prime_after_reset) { + if (wt_status_prime_fsmonitor_proof_after_worktree_update( + the_repository, &lock) < 0) + die(_("Could not prepare new index file.")); + } else if (reset_type == HARD && wt_status_repair_fsmonitor_proof_after_worktree_update( the_repository, &lock, repair_after_reset) < 0) die(_("Could not repair new index file.")); diff --git a/builtin/worktree.c b/builtin/worktree.c index 54600ac27da05e..c45e5ada889a7c 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -401,14 +401,21 @@ static void copy_filtered_worktree_config(const char *worktree_git_dir) } static int checkout_worktree(const struct add_opts *opts, - struct strvec *child_env) + struct strvec *child_env, const char *path) { struct child_process cp = CHILD_PROCESS_INIT; cp.git_cmd = 1; strvec_pushl(&cp.args, "reset", "--hard", "--no-recurse-submodules", NULL); if (opts->quiet) strvec_push(&cp.args, "--quiet"); - strvec_pushv(&cp.env, child_env->v); + if (path) { + /* Let the checkout authenticate the registered worktree's index. */ + cp.dir = path; + strvec_pushl(&cp.env, GIT_DIR_ENVIRONMENT, + GIT_WORK_TREE_ENVIRONMENT, NULL); + } else { + strvec_pushv(&cp.env, child_env->v); + } return run_command(&cp); } @@ -418,10 +425,9 @@ static void prime_worktree_clean_status_proof(const char *path) int ret; /* - * The checkout creates the linked index before it has a provider epoch - * from which to certify the worktree. Establish that epoch while this - * writer can still update the index; a later read-only status cannot - * persist the missing proof. + * The checkout may have certified its new index, but post-checkout can + * change files or replace the index. Recheck while optional writes are + * allowed so that later read-only status can reuse a durable proof. */ cp.git_cmd = 1; cp.dir = path; @@ -452,6 +458,19 @@ static int worktree_clean_status_proof_is_enabled(const char *git_dir, return enabled; } +static int worktree_clean_status_context_is_supported(void) +{ + const char *global = getenv("GIT_CONFIG_GLOBAL"); + const char *system = getenv("GIT_CONFIG_SYSTEM"); + + return !getenv(INDEX_ENVIRONMENT) && + !getenv(GIT_COMMON_DIR_ENVIRONMENT) && + !getenv(DB_ENVIRONMENT) && + !getenv(ALTERNATE_DB_ENVIRONMENT) && + (!global || !*global || is_absolute_path(global)) && + (!system || !*system || is_absolute_path(system)); +} + static int make_worktree_orphan(const char * ref, const struct add_opts *opts, struct strvec *child_env) { @@ -634,7 +653,10 @@ static int add_worktree(const char *path, const char *refname, goto done; if (opts->checkout && - (ret = checkout_worktree(opts, &child_env))) + (ret = checkout_worktree(opts, &child_env, + worktree_clean_status_context_is_supported() && + worktree_clean_status_proof_is_enabled(sb_repo.buf, path) ? + path : NULL))) goto done; is_junk = 0; FREE_AND_NULL(junk_work_tree); @@ -665,10 +687,7 @@ static int add_worktree(const char *path, const char *refname, ret = run_hooks_opt(the_repository, "post-checkout", &opt); } if (!ret && opts->checkout && use_optional_locks() && - !getenv(INDEX_ENVIRONMENT) && - !getenv(GIT_COMMON_DIR_ENVIRONMENT) && - !getenv(DB_ENVIRONMENT) && - !getenv(ALTERNATE_DB_ENVIRONMENT) && + worktree_clean_status_context_is_supported() && worktree_clean_status_proof_is_enabled(sb_repo.buf, path)) prime_worktree_clean_status_proof(path); diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 8980f85d0f3906..e83babdc2d20a5 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -8280,26 +8280,155 @@ test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHO ) ' -test_expect_success MACOS,FSMONITOR_DAEMON,UNTRACKED_CACHE \ - 'worktree add honors disabled optional locks' ' +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'worktree add bootstraps a proof without optional locks' ' test_when_finished "rm -rf worktree-add-no-locks worktree-add-no-locks-linked" && + test_when_finished \ + "git -C worktree-add-no-locks-linked fsmonitor--daemon stop 2>/dev/null || :" && test_create_repo worktree-add-no-locks && ( cd worktree-add-no-locks && + sane_unset GIT_TEST_SPLIT_INDEX && test_write_lines tracked >tracked && git add tracked && git commit -qm base && git config core.fsmonitor true && git config core.untrackedCache true && + set -- -c attr.tree= -c core.attributesFile= \ + -c safe.bareRepository=explicit -c core.hooksPath=/dev/null \ + -c core.fsmonitor=true && GIT_OPTIONAL_LOCKS=0 \ GIT_TRACE2_EVENT="$PWD/.git/worktree-add.trace" \ - git worktree add --detach \ + git "$@" worktree add --detach \ ../worktree-add-no-locks-linked HEAD && ! test_trace2_data worktree add/clean-status-primed 1 \ - <.git/worktree-add.trace + <.git/worktree-add.trace && + worktree="$PWD/../worktree-add-no-locks-linked" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + test_fsmonitor_full_proof "$gitdir/index" paired && + cp "$gitdir/index" "$gitdir/before.index" && + for attempt in first second + do + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$gitdir/$attempt.trace" \ + git "$@" -C "$worktree" status --porcelain \ + -z --untracked-files=no --no-renames \ + >"$gitdir/$attempt.actual" && + test_must_be_empty "$gitdir/$attempt.actual" && + test_cmp_bin "$gitdir/before.index" "$gitdir/index" && + test_trace2_data fsmonitor config/coherent 1 \ + <"$gitdir/$attempt.trace" && + ! test_trace2_data index refresh/sum_lstat \ + "[1-9][0-9]*" <"$gitdir/$attempt.trace" && + ! test_trace2_data index preload/sum_lstat \ + "[1-9][0-9]*" <"$gitdir/$attempt.trace" && + ! test_trace2_data fsmonitor semantic/manifest-scan-count \ + "[1-9][0-9]*" <"$gitdir/$attempt.trace" && + test_region ! index do_write_index \ + "$gitdir/$attempt.trace" || return 1 + done ) ' +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + 'worktree add proof detects post-checkout changes with optional locks disabled' ' + test_when_finished "rm -rf worktree-add-dirty-hook worktree-add-dirty-hook-linked" && + test_when_finished \ + "git -C worktree-add-dirty-hook-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo worktree-add-dirty-hook && + ( + cd worktree-add-dirty-hook && + sane_unset GIT_TEST_SPLIT_INDEX && + test_commit base tracked && + git config core.fsmonitor true && + git config core.untrackedCache true && + write_script .git/hooks/post-checkout <<-\EOF && + echo modified >tracked && + echo untracked >visible + EOF + GIT_OPTIONAL_LOCKS=0 \ + GIT_TRACE2_EVENT="$PWD/.git/worktree-add.trace" \ + git worktree add --detach \ + ../worktree-add-dirty-hook-linked HEAD && + test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <.git/worktree-add.trace && + worktree="$PWD/../worktree-add-dirty-hook-linked" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + cp "$gitdir/index" "$gitdir/before.index" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >"$gitdir/expected" && + test_grep "^1 \\.M .* tracked$" "$gitdir/expected" && + test_grep "^? visible$" "$gitdir/expected" && + for attempt in first second + do + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/$attempt.actual" && + test_cmp "$gitdir/expected" "$gitdir/$attempt.actual" && + test_cmp_bin "$gitdir/before.index" "$gitdir/index" || + return 1 + done + ) +' + +for proof_blocker in filter error reset +do +test_expect_success FSMONITOR_DAEMON,UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN,PERL_TEST_HELPERS \ + "worktree add rejects an uncertified initial proof ($proof_blocker)" ' + test_when_finished "rm -rf worktree-add-no-proof worktree-add-no-proof-linked" && + test_when_finished \ + "git -C worktree-add-no-proof-linked fsmonitor--daemon stop 2>/dev/null || :" && + test_create_repo worktree-add-no-proof && + ( + cd worktree-add-no-proof && + sane_unset GIT_TEST_SPLIT_INDEX && + test_write_lines "tracked filter=demo" >.gitattributes && + test_write_lines raw >tracked && + if test "$proof_blocker" = filter + then + git config filter.demo.clean "sed s/raw/converted/" && + git config filter.demo.smudge "sed s/converted/raw/" && + git config filter.demo.required true + fi && + git add .gitattributes tracked && + git commit -qm base && + git config core.fsmonitor true && + git config core.untrackedCache true && + case "$proof_blocker" in + error) GIT_TEST_FSMONITOR_QUERY_SEQUENCE=EEEEEEEE ;; + reset) GIT_TEST_FSMONITOR_QUERY_SEQUENCE=TTTTTTTT ;; + filter) GIT_TEST_FSMONITOR_QUERY_SEQUENCE=CCCCCCCC ;; + esac && + export GIT_TEST_FSMONITOR_QUERY_SEQUENCE && + GIT_OPTIONAL_LOCKS=0 \ + GIT_TEST_FSMONITOR_ALLOW_PROOF_REPAIR_SEQUENCE=1 \ + GIT_TRACE2_EVENT="$PWD/.git/worktree-add.trace" \ + git worktree add --detach \ + ../worktree-add-no-proof-linked HEAD && + worktree="$PWD/../worktree-add-no-proof-linked" && + gitdir=$(git -C "$worktree" rev-parse --absolute-git-dir) && + ! test_trace2_data fsmonitor history/writer-proof-repaired 1 \ + <.git/worktree-add.trace && + ! test_fsmonitor_full_proof "$gitdir/index" paired \ + >"$gitdir/proof.out" 2>&1 && + test_write_lines modified >"$worktree/tracked" && + test_write_lines untracked >"$worktree/visible" && + cp "$gitdir/index" "$gitdir/before.index" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" -c core.fsmonitor=false \ + -c core.untrackedCache=false status --porcelain=v2 \ + >"$gitdir/expected" && + GIT_OPTIONAL_LOCKS=0 \ + git -C "$worktree" status --porcelain=v2 \ + >"$gitdir/actual" && + test_cmp "$gitdir/expected" "$gitdir/actual" && + test_cmp_bin "$gitdir/before.index" "$gitdir/index" + ) +' +done + test_expect_success UNTRACKED_CACHE,SEMANTIC_VERIFY_ANCHORED_OPEN \ 'repeated provider resets fall back before an unclosable rescan' ' test_when_finished "rm -rf builtin-closure-terminal-reset" && diff --git a/wt-status.c b/wt-status.c index a5983ad5916413..2b26228b518ee4 100644 --- a/wt-status.c +++ b/wt-status.c @@ -1157,7 +1157,8 @@ 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()) + /* An index writer can certify under the lock it already owns. */ + if (!use_optional_locks() && !s->proof_index_path) s->certify_clean_status = 0; wt_status_begin_attr_snapshot(s); /* Record the provider token before either filesystem traversal. */ @@ -2647,7 +2648,8 @@ static int repair_fsmonitor_proof( istate->fsmonitor_untracked_token && !strcmp(istate->fsmonitor_last_update, istate->fsmonitor_untracked_token); - valid_root = istate->untracked->root->valid_recursive; + valid_root = istate->untracked && istate->untracked->root && + istate->untracked->root->valid_recursive; certifiable_index = clean_status_index_entries_are_certifiable(istate) || (index_path && locked_index_entries_are_certifiable(istate)); full_proof = clean_status_has_current_full_fsmonitor_proof(istate); @@ -2763,6 +2765,32 @@ int wt_status_repair_fsmonitor_proof_after_worktree_update( repo, lock, had_full_proof, 0, NULL); } +int wt_status_prime_fsmonitor_proof_after_worktree_update( + struct repository *repo, struct lock_file *lock) +{ + struct wt_status status = { 0 }; + int repaired; + + prepare_repo_settings(repo); + if (repo->settings.core_untracked_cache != UNTRACKED_CACHE_WRITE || + fsm_settings__get_mode(repo) != FSMONITOR_MODE_IPC) + return 0; + /* + * A newly created index has no history to repair. Certify its checkout + * under the writer's existing lock, including a complete untracked scan + * and a closing provider query, before the first index is committed. + */ + clean_status_attach_config(repo->index); + if (!repo->index->fsmonitor_token_valid) + repo->index->fsmonitor_has_run_once = 0; + refresh_fsmonitor(repo->index); + repaired = repair_fsmonitor_proof_after_update( + repo, lock, 1, 1, &status); + if (repaired > 0) + release_repair_status(&status); + return repaired; +} + int wt_status_repair_fsmonitor_proof_after_update_with_sidecar( struct repository *repo, struct lock_file *lock, int had_full_proof, const struct clean_status_config_digest *config) diff --git a/wt-status.h b/wt-status.h index 5f3b906531cdcf..98686afcb5b56c 100644 --- a/wt-status.h +++ b/wt-status.h @@ -198,6 +198,9 @@ int wt_status_prepare_fsmonitor_proof_for_worktree_update( int wt_status_fsmonitor_proof_needs_repair(struct repository *repo); int wt_status_repair_fsmonitor_proof_after_worktree_update( struct repository *repo, struct lock_file *lock, int had_full_proof); +/* The caller owns the mandatory index lock for the initial checkout. */ +int wt_status_prime_fsmonitor_proof_after_worktree_update( + struct repository *repo, struct lock_file *lock); int wt_status_repair_fsmonitor_proof_after_update_with_sidecar( struct repository *repo, struct lock_file *lock, int had_full_proof, const struct clean_status_config_digest *config);