From 13137758263bfce2bc4148aafebfef814337b6ea Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 21 Aug 2026 11:50:25 -0500 Subject: [PATCH 01/12] 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 b0edd3ab19a95f952d329bd1ae389b49d0fdc27a Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 21 Aug 2026 14:48:44 -0500 Subject: [PATCH 02/12] 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 b425587a9905f02120504c1b52c2c07bfb83b261 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 11:50:10 -0500 Subject: [PATCH 03/12] 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 6703e9e02eb9027493f9cedef5442e1eb610ad96 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 11:50:41 -0500 Subject: [PATCH 04/12] 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 f67a2e78e2100489276aa1f426e916828bce0c67 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 16:26:00 -0500 Subject: [PATCH 05/12] 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 851d61d5f931ec6097cb7147edc0d0e602c765ce Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Mon, 24 Aug 2026 21:12:52 -0500 Subject: [PATCH 06/12] 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 4022f0dfbc374bacea62438a82be3a660666b1b4 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 26 Aug 2026 18:52:53 -0500 Subject: [PATCH 07/12] 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 0a572e57f8741c7df5ce12ba9c52ab94de92f6ff Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 26 Aug 2026 23:20:07 -0500 Subject: [PATCH 08/12] 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 a9f3180ef6a9df2bdbcd996de165fc669dd17f21 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 03:00:10 -0500 Subject: [PATCH 09/12] 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 f0aa62f3a15bfcaebdb7db438c68f74d89abacb3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 03:18:41 -0500 Subject: [PATCH 10/12] 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 fcae71aee7347b9ad01b92ab625b51089b12177b Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 03:27:36 -0500 Subject: [PATCH 11/12] 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 071004651abbd0b0bf8360e70d8573a8efe43bd3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Thu, 27 Aug 2026 06:32:30 -0500 Subject: [PATCH 12/12] 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,