From 133350637a5c32105dd81f4ce8f61e3af6c9b41f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 6 Mar 2026 09:25:47 -0800 Subject: [PATCH] unpack-trees: skip lstats for deleted VFS entries in checkout When core_virtualfilesystem is set and a branch switch deletes entries (present in old tree, absent in new tree), deleted_entry() calls verify_absent_if_directory() with 'ce' pointing to a tree entry from traverse_trees(). This tree entry lacks CE_NEW_SKIP_WORKTREE because that flag is only set on src_index entries by mark_new_skip_worktree(). The missing flag causes verify_absent_if_directory()'s fast-path to fail, falling through to verify_absent_1() which lstats every such path. In a VFS repo each lstat may trigger callbacks, creating placeholders. On a large repo switching between LTS releases this produces tens of thousands of placeholders that the VFS must then clean up when they are deleted as part of the checkout. Fix this by propagating CE_NEW_SKIP_WORKTREE from the index entry (old) to the tree entry (ce) when core_virtualfilesystem is set. This allows the existing fast-path to work, eliminating the unnecessary lstats entirely. This is safe in VFS mode because the virtual filesystem is responsible for tracking which files are hydrated and cleaning up placeholders when entries are removed from the index. Additionally, when GVFS_NO_DELETE_OUTSIDE_SPARSECHECKOUT is set (always the case in VFS repos), deleted_entry() preserves CE_SKIP_WORKTREE on the CE_REMOVE entry and git does not unlink skip-worktree files from disk, so the lstat result would not be acted upon anyway. Measured on a 2.8M file VFS repo (0% hydrated): Before: ~135s checkout, ~23k folder placeholders created After: ~25s checkout, 0 folder placeholders created Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- t/t1093-virtualfilesystem.sh | 52 ++++++++++++++++++++++++++++++++++++ unpack-trees.c | 16 +++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/t/t1093-virtualfilesystem.sh b/t/t1093-virtualfilesystem.sh index cad13d680cb199..27e9f6423e4d9e 100755 --- a/t/t1093-virtualfilesystem.sh +++ b/t/t1093-virtualfilesystem.sh @@ -368,6 +368,58 @@ test_expect_success 'folder with same prefix as file' ' test_cmp expected actual ' +test_expect_success 'checkout skips lstat for deleted skip-worktree entries in VFS mode' ' + # When switching branches, entries present in the old tree but absent + # in the new tree go through deleted_entry() -> verify_absent_if_directory(). + # Without the CE_NEW_SKIP_WORKTREE propagation fix, the tree entry + # lacks that flag, so the fast-path fails and verify_absent_1() lstats + # the path. If a directory exists where the deleted file entry was + # (simulating a worst-case scenario), the lstat finds it and + # verify_clean_subdirectory() rejects the checkout due to untracked + # content inside. + # + # With the fix, CE_NEW_SKIP_WORKTREE is propagated and the fast-path + # succeeds — no lstat, no rejection, checkout completes. + # + # Set up two branches: main has dir1/ + dir2/, side has only dir1/ + clean_repo && + + git -c core.virtualfilesystem= checkout -b side && + git -c core.virtualfilesystem= rm -rf dir2 && + git -c core.virtualfilesystem= commit -m "remove dir2" && + git -c core.virtualfilesystem= checkout main && + + # Configure VFS hook that returns nothing (0% hydration) + write_script .git/hooks/virtualfilesystem <<-\EOF && + printf "" + EOF + + # Create a directory where the deleted file entry is, with + # untracked content inside. This would not happen with a real + # VFS because the VFS would report the file-to-directory change + # in the virtualfilesystem hook results, clearing skip-worktree. + # But it lets us verify that the lstat is not called: without + # the fix, verify_absent_1() lstats this path, finds a directory, + # and verify_clean_subdirectory() rejects the checkout because of + # the untracked file inside. + rm -f dir2/file1.txt && + mkdir -p dir2/file1.txt && + echo "untracked" >dir2/file1.txt/trap.txt && + + # Verify all entries are skip-worktree before checkout + git ls-files -v >actual && + ! grep "^H " actual && + + # Checkout to side branch. Without the fix this fails because + # verify_absent_1 finds untracked content in the directory at + # dir2/file1.txt. With the fix the lstat is skipped entirely. + git checkout side && + + # Clean up: return to main so subsequent tests have dir2/ + rm -rf dir2/file1.txt && + git -c core.virtualfilesystem= checkout main +' + test_expect_success MINGW,FSMONITOR_DAEMON 'virtualfilesystem hook disables built-in FSMonitor' ' clean_repo && test_config core.usebuiltinfsmonitor true && diff --git a/unpack-trees.c b/unpack-trees.c index 4d897829419a3d..d05c4609defd69 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -2720,8 +2720,20 @@ static int deleted_entry(const struct cache_entry *ce, if (verify_absent(ce, ERROR_WOULD_LOSE_UNTRACKED_REMOVED, o)) return -1; return 0; - } else if (verify_absent_if_directory(ce, ERROR_WOULD_LOSE_UNTRACKED_REMOVED, o)) { - return -1; + } else { + /* + * When core_virtualfilesystem is set, 'ce' may be a tree + * entry from traverse_trees() that lacks CE_NEW_SKIP_WORKTREE + * (only src_index entries get that flag from + * mark_new_skip_worktree()). Propagate it from the index + * entry so verify_absent_if_directory()'s fast-path works, + * avoiding unnecessary lstats on virtualized paths. + */ + if (core_virtualfilesystem && + old->ce_flags & CE_NEW_SKIP_WORKTREE) + ((struct cache_entry *)ce)->ce_flags |= CE_NEW_SKIP_WORKTREE; + if (verify_absent_if_directory(ce, ERROR_WOULD_LOSE_UNTRACKED_REMOVED, o)) + return -1; } if (!(old->ce_flags & CE_CONFLICTED) && verify_uptodate(old, o))