From aa27eecc23291ffe0e593a35d6192eee182035b4 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 | 51 ++++++++++++++++++++++++++++++++++++ unpack-trees.c | 12 +++++++++ 2 files changed, 63 insertions(+) diff --git a/t/t1093-virtualfilesystem.sh b/t/t1093-virtualfilesystem.sh index cad13d680cb199..31c1aa77c6712e 100755 --- a/t/t1093-virtualfilesystem.sh +++ b/t/t1093-virtualfilesystem.sh @@ -368,6 +368,57 @@ 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 fix, the tree entry lacks CE_NEW_SKIP_WORKTREE (only + # src_index entries get that flag), so verify_absent_if_directory() + # falls through to verify_absent_1() which 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, verify_absent_if_directory() is skipped entirely + # when VFS mode is active — no lstat, no rejection, checkout completes. + # + # Set up two branches: main has dir1/ + dir2/, side has only dir1/ + clean_repo && + + test_when_finished "rm -rf dir2/file1.txt && git -c core.virtualfilesystem= checkout main" && + + 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 +' + 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..1463758686d816 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -2720,6 +2720,18 @@ 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 (core_virtualfilesystem && + old->ce_flags & CE_NEW_SKIP_WORKTREE) { + /* + * 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 apply_sparse_checkout() preserves CE_SKIP_WORKTREE + * later, and skip verify_absent_if_directory() entirely to + * avoid unnecessary lstats on virtualized paths. + */ + ((struct cache_entry *)ce)->ce_flags |= CE_NEW_SKIP_WORKTREE; } else if (verify_absent_if_directory(ce, ERROR_WOULD_LOSE_UNTRACKED_REMOVED, o)) { return -1; }