Volume XXII, number 279Tuesday, October 6, 2026Latest message 1 hour ago

The Git List

News and archive of git@vger.kernel.org, since April 2005

patch, 2 partsls-files: reuse the untracked cache

8 messages between Sep 23, 2026 and Sep 23, 2026, from Tamir Duberstein.

Plain Markdown or JSON for tools and agents. Diffs are folded; open one to read it.

Tamir DubersteinSep 23, 2026, 17:10 UTC on lore
Repeated queries such as
  git ls-files --cached --others --exclude-standard -z -- "**/pyproject.toml"

walk the working tree even when status has already populated an untracked cache. This series lets ls-files reuse those directory listings. It expands partial scans and collapsed untracked directories as needed, without writing the converted cache back to the index.

The first patch fixes inconsistent ignore-file hashes that invalidate an unchanged cache. The second enables reuse for ls-files and filters complete listings after the walk. Fixed-prefix queries retain directory pruning; attribute and exclude pathspecs retain their existing traversal because matching a directory can differ from matching its children.

On macOS, a synthetic tree with 100,000 tracked files in 5,000 directories and a few untracked files averaged 361 ms before and 50 ms after in five hyperfine runs after one warmup. Directory opens fell from 5,058 to seven. With half the files untracked, five alternating runs averaged 659 ms before and 339 ms after; that case still expands 2,533 partially cached directories. A cache populated with status.showUntrackedFiles=all avoids directory opens in both cases. These measurements disable fsmonitor and use a populated cache with a warm filesystem.

Uncached controls were noisy; the apparent slowdown in the first batch did not recur. Literal-query results varied in both directions, with a separate five-run hyperfine check measuring 17 ms before and 20 ms after.

Prepared with Codex, including code review and benchmark work by separate Codex agents.

Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
Tamir Duberstein (2):
      dir: hash ignore files before adding parser LF
      ls-files: reuse cached untracked listings
 builtin/ls-files.c                | 11 +++++--
 dir.c                             | 64 +++++++++++++++++++++++++++++++++---
 dir.h                             |  2 ++
 t/perf/p3010-ls-files.sh          | 15 +++++++++
 t/t7063-status-untracked-cache.sh | 68 ++++++++++++++++++++++++++++++++++++---
 5 files changed, 149 insertions(+), 11 deletions(-)

--- base-commit: 3bc0341126508f78f5869cbfc0005e987efdf0c7 change-id: 20260923-ls-files-untracked-cache-3559bed01a3e

Tamir DubersteinSep 23, 2026, 17:10 UTC in reply to Tamir Duberstein on lore

[PATCH 1/2] dir: hash ignore files before adding parser LF

add_patterns() appends a newline for the pattern parser before computing an ignore file's object ID. Its fallback hash therefore includes a byte that is absent from the file. The fast path instead copies the original blob ID from an up-to-date index entry.

Switching between those paths changes the recorded ignore identity even when the file has not changed, invalidating the untracked cache below it. Compute the hash before appending the parser newline so both paths agree. Update the expected identities of the untracked ignore files accordingly.

Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
 dir.c                             | 2 +-
 t/t7063-status-untracked-cache.sh | 8 ++++----
 2 files changed, 5 insertions(+), 5 deletions(-)
Show changes to 2 files +5 −5

dir.c, t/t7063-status-untracked-cache.sh

diff --git a/dir.c b/dir.c
index d896e7be4b..037780e556 100644
--- a/dir.c
+++ b/dir.c
@@ -1195,7 +1195,6 @@ static int add_patterns(const char *fname, const char *base, int baselen,
 			close(fd);
 			return -1;
 		}
-		buf[size++] = '\n';
 		close(fd);
 		if (oid_stat) {
 			int pos;
@@ -1215,6 +1214,7 @@ static int add_patterns(const char *fname, const char *base, int baselen,
 			fill_stat_data(&oid_stat->stat, &st);
 			oid_stat->valid = 1;
 		}
+		buf[size++] = '\n';
 	}
 
 	if (size > PATTERN_MAX_FILE_SIZE) {
diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh
index 8929ef481f..10d4850077 100755
--- a/t/t7063-status-untracked-cache.sh
+++ b/t/t7063-status-untracked-cache.sh
@@ -98,11 +98,11 @@ test_expect_success 'setup' '
 	: >.git/info/exclude &&
 	git update-index --untracked-cache &&
 	test_oid_cache <<-EOF
-	root sha1:e6fcc8f2ee31bae321d66afd183fcb7237afae6e
-	root sha256:b90c672088c015b9c83876e919da311bad4cd39639fb139f988af6a11493b974
+	root sha1:8510665149157c2bc901848c3e0b746954e9cbd9
+	root sha256:09ef24b38105f396a61ad78d73ba6a18ee3cbd89ce4524b4e13b6c1af191e2d8
 
-	exclude sha1:13263c0978fb9fad16b2d580fb800b6d811c3ff0
-	exclude sha256:fe4aaa1bbbbce4cb8f73426748a14c5ad6026b26f90505a0bf2494b165a5b76c
+	exclude sha1:2bdf67abb163a4ffb2d7f3f0880c9fe5068ce782
+	exclude sha256:b83643f4390b339c1b3ff2f5132c99bd4a77687dd321d3f386c25953aa6f1ce4
 
 	done sha1:1946f0437f90c5005533cbe1736a6451ca301714
 	done sha256:7f079501d79f665b3acc50f5e0e9e94509084d5032ac20113a37dd5029b757cc
-- 
2.56.0.rc0.807.ga0c0929ce1.frankengit
Tamir DubersteinSep 23, 2026, 17:10 UTC in reply to Tamir Duberstein on lore

[PATCH 2/2] ls-files: reuse cached untracked listings

ls-files --cached --others --exclude-standard with a wildcard pathspec walks the working tree on every invocation, even when status has already cached its untracked directories.

Attach the existing untracked cache before loading standard excludes. For whole-tree wildcard queries without a fixed prefix, read complete cached listings and filter their results afterward. Preserve traversal filtering for attribute and exclude pathspecs, which can reject a parent directory independently of its children.

A normal status cache collapses untracked directories and may stop after finding their first file. Rescan those entries while retaining complete listings. Opt in from ls-files, which does not write the index, so the saved status cache and its configured mode remain intact.

On a synthetic tree with 100,000 tracked files in 5,000 directories and a few untracked files, five hyperfine runs after one warmup of the pyproject.toml query average 361 ms before and 50 ms after with a normal status cache. Directory opens fall from 5,058 to seven. A tree with 50,000 untracked files averages 659 ms before and 339 ms after over five alternating runs; that case still expands the collapsed untracked directories. Without an existing cache, traversal is unchanged.

Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
 builtin/ls-files.c                | 11 +++++--
 dir.c                             | 62 +++++++++++++++++++++++++++++++++++++--
 dir.h                             |  2 ++
 t/perf/p3010-ls-files.sh          | 15 ++++++++++
 t/t7063-status-untracked-cache.sh | 60 +++++++++++++++++++++++++++++++++++++
 5 files changed, 144 insertions(+), 6 deletions(-)
Show changes to 5 files +144 −6

builtin/ls-files.c, dir.c, dir.h, t/perf/p3010-ls-files.sh, t/t7063-status-untracked-cache.sh

diff --git a/builtin/ls-files.c b/builtin/ls-files.c
index b044520f9e..da1804acf3 100644
--- a/builtin/ls-files.c
+++ b/builtin/ls-files.c
@@ -681,11 +681,16 @@ int cmd_ls_files(int argc,
 	if (repo_read_index(repo) < 0)
 		die("index file corrupt");
 
+	dir.untracked = repo->index->untracked;
+	dir.untracked_cache_expand = 1;
 	argc = parse_options(argc, argv, prefix, builtin_ls_files_options,
 			ls_files_usage, 0);
-	pl = add_pattern_list(&dir, EXC_CMDL, "--exclude option");
-	for (i = 0; i < exclude_list.nr; i++) {
-		add_pattern(exclude_list.items[i].string, "", 0, pl, --exclude_args);
+	if (!show_others || dir.flags || !dir.exclude_per_dir || with_tree)
+		dir.untracked = NULL;
+	if (exclude_list.nr) {
+		pl = add_pattern_list(&dir, EXC_CMDL, "--exclude option");
+		for (i = 0; i < exclude_list.nr; i++)
+			add_pattern(exclude_list.items[i].string, "", 0, pl, --exclude_args);
 	}
 
 	if (format && (show_stage || show_others || show_killed ||
diff --git a/dir.c b/dir.c
index 037780e556..eccd24e318 100644
--- a/dir.c
+++ b/dir.c
@@ -2974,6 +2974,27 @@ void remove_untracked_cache(struct index_state *istate)
 	}
 }
 
+static void expand_untracked_cache(struct untracked_cache *uc,
+				   struct untracked_cache_dir *dir)
+{
+	if (!dir)
+		return;
+	/* Re-read partial scans and listings containing collapsed directories. */
+	if (dir->check_only) {
+		invalidate_directory(uc, dir);
+		dir->check_only = 0;
+	} else {
+		for (size_t i = 0; i < dir->untracked_nr; i++) {
+			if (ends_with(dir->untracked[i], "/")) {
+				invalidate_directory(uc, dir);
+				break;
+			}
+		}
+	}
+	for (size_t i = 0; i < dir->dirs_nr; i++)
+		expand_untracked_cache(uc, dir->dirs[i]);
+}
+
 static struct untracked_cache_dir *validate_untracked_cache(struct dir_struct *dir,
 						      int base_len,
 						      const struct pathspec *pathspec,
@@ -3033,6 +3054,12 @@ static struct untracked_cache_dir *validate_untracked_cache(struct dir_struct *d
 		return NULL;
 	}
 
+	/* Keep complete directory listings when expanding a normal-mode cache. */
+	if (dir->untracked_cache_expand && !dir->flags && dir->untracked->dir_flags == (DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES)) {
+		expand_untracked_cache(dir->untracked, dir->untracked->root);
+		dir->untracked->dir_flags = 0;
+	}
+
 	/*
 	 * If the untracked structure we received does not have the same flags
 	 * as requested in this run, we're going to need to either discard the
@@ -3141,6 +3168,16 @@ int read_directory(struct dir_struct *dir, struct index_state *istate,
 		   const char *path, int len, const struct pathspec *pathspec)
 {
 	struct untracked_cache_dir *untracked;
+	const struct pathspec *walk_pathspec = pathspec;
+	/* Attribute and exclude pathspecs can prune a directory by its own name. */
+	int filter = dir->untracked_cache_expand && dir->untracked &&
+		     !len && !dir->flags && pathspec && pathspec->nr &&
+		     !(pathspec->magic & (PATHSPEC_ATTR | PATHSPEC_EXCLUDE));
+
+	/* Keep the usual pruning for queries with a fixed prefix. */
+	for (int i = 0; filter && i < pathspec->nr; i++)
+		if (pathspec->items[i].nowildcard_len)
+			filter = 0;
 
 	trace2_region_enter("dir", "read_directory", istate->repo);
 	dir->internal.visited_paths = 0;
@@ -3151,15 +3188,34 @@ int read_directory(struct dir_struct *dir, struct index_state *istate,
 		return dir->nr;
 	}
 
-	untracked = validate_untracked_cache(dir, len, pathspec, istate);
+	untracked = validate_untracked_cache(dir, len,
+					     filter ? NULL : pathspec, istate);
 	if (!untracked)
 		/*
 		 * make sure untracked cache code path is disabled,
 		 * e.g. prep_exclude()
 		 */
 		dir->untracked = NULL;
-	if (!len || treat_leading_path(dir, istate, path, len, pathspec))
-		read_directory_recursive(dir, istate, path, len, untracked, 0, 0, pathspec);
+	else if (filter)
+		walk_pathspec = NULL;
+	if (!len || treat_leading_path(dir, istate, path, len, walk_pathspec))
+		read_directory_recursive(dir, istate, path, len, untracked, 0, 0,
+					 walk_pathspec);
+	if (filter && untracked) {
+		int dst = 0;
+
+		/* Cache complete listings; apply the query only to the results. */
+		for (int i = 0; i < dir->nr; i++) {
+			struct dir_entry *ent = dir->entries[i];
+
+			if (match_pathspec(istate, pathspec, ent->name, ent->len,
+					   0, NULL, 0))
+				dir->entries[dst++] = dir->entries[i];
+			else
+				free(dir->entries[i]);
+		}
+		dir->nr = dst;
+	}
 	QSORT(dir->entries, dir->nr, cmp_dir_entry);
 	QSORT(dir->ignored, dir->ignored_nr, cmp_dir_entry);
 
diff --git a/dir.h b/dir.h
index 83e0f648a8..3403816271 100644
--- a/dir.h
+++ b/dir.h
@@ -296,6 +296,8 @@ struct dir_struct {
 
 	/* Enable/update untracked file cache if set */
 	struct untracked_cache *untracked;
+	/* Allow expanding a normal-mode cache without saving it to the index. */
+	unsigned int untracked_cache_expand:1;
 
 	/**
 	 * Deprecated: ls-files is the only allowed caller; all other callers
diff --git a/t/perf/p3010-ls-files.sh b/t/perf/p3010-ls-files.sh
index ae14449432..59eaea000b 100755
--- a/t/perf/p3010-ls-files.sh
+++ b/t/perf/p3010-ls-files.sh
@@ -28,4 +28,19 @@ test_perf 'ls-files --modified with pathspec' '
 		-- "$pathspec" >/dev/null
 '
 
+test_perf 'ls-files --others with pathspec and no untracked cache' '
+	git -c core.fsmonitor=false -c core.untrackedCache=false \
+		ls-files --cached --others --exclude-standard -- "$pathspec" >/dev/null
+'
+
+test_expect_success 'populate the untracked cache' '
+	git config core.untrackedCache true &&
+	git -c core.fsmonitor=false status --porcelain >/dev/null
+'
+
+test_perf 'ls-files --others with pathspec and untracked cache' '
+	git -c core.fsmonitor=false ls-files --cached --others \
+		--exclude-standard -- "$pathspec" >/dev/null
+'
+
 test_done
diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh
index 10d4850077..ccfc354dad 100755
--- a/t/t7063-status-untracked-cache.sh
+++ b/t/t7063-status-untracked-cache.sh
@@ -991,4 +991,64 @@ test_expect_success 'empty repo (no index) and core.untrackedCache' '
 	git -C emptyrepo -c core.untrackedCache=true write-tree
 '
 
+test_expect_success 'ls-files expands cached directories and filters wildcard results' '
+	test_create_repo ls-files-cache &&
+	(
+		cd ls-files-cache &&
+		git config core.untrackedCache true &&
+		mkdir tracked untracked empty ignored-only &&
+		touch tracked/pyproject.toml untracked/pyproject.toml &&
+		touch untracked/other ignored-only/file &&
+		echo ignored-only/file >.gitignore &&
+		echo "*.toml selected" >.gitattributes &&
+		git add .gitignore .gitattributes tracked &&
+		git init nested.git &&
+		test-tool chmtime =-300 . tracked untracked empty ignored-only &&
+		for mode in normal all
+		do
+			# Normal mode rescans the root and three partial directories.
+			case "$mode" in
+			normal) opened=4 ;;
+			all) opened=0 ;;
+			esac &&
+			git config status.showUntrackedFiles "$mode" &&
+			git status --porcelain >/dev/null &&
+			cp .git/index ../saved-index &&
+			: >"$TRASH_DIRECTORY/ls-files.trace" &&
+			GIT_TRACE2_PERF="$TRASH_DIRECTORY/ls-files.trace" \
+				git ls-files --cached --others --exclude-standard -z \
+				-- "**/pyproject.toml" >../actual &&
+			printf "%s\0" untracked/pyproject.toml tracked/pyproject.toml \
+				>../expect &&
+			test_cmp ../expect ../actual &&
+			test_cmp_bin ../saved-index .git/index &&
+			test_grep "read_directo.*opendir:$opened\$" \
+				"$TRASH_DIRECTORY/ls-files.trace" &&
+			test_grep "read_directo.*gitignore-invalidation:0\$" \
+				"$TRASH_DIRECTORY/ls-files.trace" || return 1
+			for pathspec in "*.git/" ":(glob)**/*.toml" \
+				":(exclude)untracked/" ":(attr:selected)**/*.toml" \
+				"tracked/pyproject.toml"
+			do
+				GIT_DISABLE_UNTRACKED_CACHE=1 git ls-files --others \
+					--exclude-standard -- "$pathspec" >../expect &&
+				git ls-files --others --exclude-standard \
+					-- "$pathspec" >../actual &&
+				test_cmp ../expect ../actual || return 1
+			done || return 1
+		done
+	)
+'
+
+test_expect_success 'ls-files revalidates cached files and ignore rules' '
+	(
+		cd ls-files-cache &&
+		echo untracked/ >>.gitignore &&
+		touch tracked/new.toml empty/new.toml &&
+		git ls-files --others --exclude-standard -- "**/*.toml" >../actual &&
+		printf "%s\n" empty/new.toml tracked/new.toml >../expect &&
+		test_cmp ../expect ../actual
+	)
+'
+
 test_done
-- 
2.56.0.rc0.807.ga0c0929ce1.frankengit
Tamir DubersteinSep 23, 2026, 18:38 UTC in reply to Tamir Duberstein on lore

Re: [PATCH 0/2] ls-files: reuse the untracked cache

On Wed, Sep 23, 2026 at 1:10 PM Tamir Duberstein <tamird@gmail.com> wrote:
Show 7 quoted lines
>
> Uncached controls were noisy; the apparent slowdown in the first batch
> did not recur. Literal-query results varied in both directions, with a
> separate five-run hyperfine check measuring 17 ms before and 20 ms after.
>
> Prepared with Codex, including code review and benchmark work by separate
> Codex agents.

Jeez, sorry about this slop. This was operator error on my part; I did not intend for these to go out in this form.

The first commit is in reasonable shape but I'm still iterating on the second.
Tamir DubersteinSep 23, 2026, 22:26 UTC in reply to Tamir Duberstein on lore

[PATCH v2 0/3] ls-files: reuse and update the untracked cache

Repeated queries such as
  git ls-files --cached --others --exclude-standard -z -- "**/pyproject.toml"

walk the working tree even when status has populated an untracked cache. This series lets ls-files reuse those listings and save the work for later commands through Git's existing optional index writes.

The first patch fixes inconsistent ignore-file hashes that invalidate an unchanged cache. The second lets 'git status -unormal' and 'git status -uall' reuse the same cache. 'git status -unormal' still stops scanning an untracked directory after finding an untracked file; a later command requesting all untracked files completes the listing as needed. The third lets ls-files use the cache and write pending untracked cache and fsmonitor updates back to the index. --no-optional-locks suppresses those writes.

For wildcard pathspecs without a fixed prefix, apply the pathspec after reading complete directory listings. A first query can therefore scan directories that its pathspec would otherwise skip. Fixed-prefix, attribute and exclude pathspecs retain their existing traversal.

A new dir_flags value makes older versions of Git rebuild the cache before using it to list untracked files. Changes invalidate cached entries in parent directories as well, so updates can reopen more directories than a cache populated with --untracked-files=all before this series.

On macOS, with 100,000 files in 5,000 leaf directories, half tracked. Each group of 100 leaf directories has 50 tracked and 50 untracked. Each binary populated its own cache using the indicated status mode.

hyperfine --warmup 3 --runs 7 (mean ± standard deviation, milliseconds):
  cache   fsmonitor  command          upstream        v1             v2
  normal  off        query            241.3 ± 14.8    132.7 ± 20.0   33.3 ± 2.4
  normal  off        query + status   292.7 ± 11.2    187.0 ± 10.8   92.6 ± 3.3
  all     off        query            231.3 ± 14.6    29.3 ± 1.3     34.7 ± 2.0
  normal  on         query            223.1 ± 17.8    111.0 ± 13.4   33.8 ± 1.5
  all     on         query            216.6 ± 12.0    28.0 ± 5.1     34.6 ± 0.8

"query" is the ls-files command above; "status" is "git status -unormal --porcelain". These are repeated queries with warm filesystem and untracked caches.

Assisted-by: LLM
Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
Changes in v2:
- Share the cache between --untracked-files=normal and --untracked-files=all.
- Write ls-files untracked cache and fsmonitor updates back to the index.
- Test alternating modes, cache invalidation and optional index writes.
- Replace the v1 measurements with results for the revised implementation.
- Link to v1: https://patch.msgid.link/20260923-ls-files-untracked-cache-v1-0-08db4cc1efdb@gmail.com
---
Tamir Duberstein (3):
      dir: hash ignore files before appending newline
      dir: share untracked caches across output modes
      ls-files: use and update the untracked cache
 Documentation/git-ls-files.adoc    |   4 +
 Documentation/gitformat-index.adoc |  13 +-
 builtin/ls-files.c                 |  40 +++++-
 dir.c                              | 251 ++++++++++++++++++----------------
 dir.h                              |  16 +--
 t/perf/p3010-ls-files.sh           |  15 ++
 t/t3001-ls-files-others-exclude.sh |  20 +++
 t/t7063-status-untracked-cache.sh  | 272 +++++++++++++++++++++++++++++--------
 t/t7519-status-fsmonitor.sh        |  44 ++++++
 9 files changed, 481 insertions(+), 194 deletions(-)
Range-diff versus v1:
1:  896f1aea52 ! 1:  bf5e5eea24 dir: hash ignore files before adding parser LF
    @@ Metadata
     Author: Tamir Duberstein <tamird@gmail.com>
     
      ## Commit message ##
    -    dir: hash ignore files before adding parser LF
    +    dir: hash ignore files before appending newline
     
         add_patterns() appends a newline for the pattern parser before computing
    -    an ignore file's object ID. Its fallback hash therefore includes a byte
    -    that is absent from the file. The fast path instead copies the original
    -    blob ID from an up-to-date index entry.
    +    an ignore file's object ID. Hashing the buffer therefore includes a byte
    +    that is absent from the file. When the file has an up-to-date index entry
    +    and needs no content conversion, the function instead uses that entry's
    +    object ID.
     
    -    Switching between those paths changes the recorded ignore identity even
    -    when the file has not changed, invalidating the untracked cache below it.
    -    Compute the hash before appending the parser newline so both paths agree.
    -    Update the expected identities of the untracked ignore files accordingly.
    +    Switching between these paths changes the cached object ID even when the
    +    file has not changed, invalidating the untracked cache below it. Compute
    +    the hash before appending the newline so both paths agree. Update the
    +    expected object IDs of the untracked ignore files accordingly.
     
    +    Assisted-by: LLM
         Signed-off-by: Tamir Duberstein <tamird@gmail.com>
     
      ## dir.c ##
    @@ dir.c: static int add_patterns(const char *fname, const char *base, int baselen,
      			fill_stat_data(&oid_stat->stat, &st);
      			oid_stat->valid = 1;
      		}
    ++		/*
    ++		 * The extra newline is only for parsing. Like do_read_blob(),
    ++		 * keep it out of the file's object ID.
    ++		 */
     +		buf[size++] = '\n';
      	}
      
2:  fc65e91309 < -:  ---------- ls-files: reuse cached untracked listings
-:  ---------- > 2:  ae86ca623b dir: share untracked caches across output modes
-:  ---------- > 3:  126241f862 ls-files: use and update the untracked cache

--- base-commit: 3bc0341126508f78f5869cbfc0005e987efdf0c7 change-id: 20260923-ls-files-untracked-cache-3559bed01a3e

Tamir DubersteinSep 23, 2026, 22:26 UTC in reply to Tamir Duberstein on lore

[PATCH v2 1/3] dir: hash ignore files before appending newline

add_patterns() appends a newline for the pattern parser before computing an ignore file's object ID. Hashing the buffer therefore includes a byte that is absent from the file. When the file has an up-to-date index entry and needs no content conversion, the function instead uses that entry's object ID.

Switching between these paths changes the cached object ID even when the file has not changed, invalidating the untracked cache below it. Compute the hash before appending the newline so both paths agree. Update the expected object IDs of the untracked ignore files accordingly.

Assisted-by: LLM
Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
 dir.c                             | 6 +++++-
 t/t7063-status-untracked-cache.sh | 8 ++++----
 2 files changed, 9 insertions(+), 5 deletions(-)
Show changes to 2 files +9 −5

dir.c, t/t7063-status-untracked-cache.sh

diff --git a/dir.c b/dir.c
index d896e7be4b..5484eb1562 100644
--- a/dir.c
+++ b/dir.c
@@ -1195,7 +1195,6 @@ static int add_patterns(const char *fname, const char *base, int baselen,
 			close(fd);
 			return -1;
 		}
-		buf[size++] = '\n';
 		close(fd);
 		if (oid_stat) {
 			int pos;
@@ -1215,6 +1214,11 @@ static int add_patterns(const char *fname, const char *base, int baselen,
 			fill_stat_data(&oid_stat->stat, &st);
 			oid_stat->valid = 1;
 		}
+		/*
+		 * The extra newline is only for parsing. Like do_read_blob(),
+		 * keep it out of the file's object ID.
+		 */
+		buf[size++] = '\n';
 	}
 
 	if (size > PATTERN_MAX_FILE_SIZE) {
diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh
index 8929ef481f..10d4850077 100755
--- a/t/t7063-status-untracked-cache.sh
+++ b/t/t7063-status-untracked-cache.sh
@@ -98,11 +98,11 @@ test_expect_success 'setup' '
 	: >.git/info/exclude &&
 	git update-index --untracked-cache &&
 	test_oid_cache <<-EOF
-	root sha1:e6fcc8f2ee31bae321d66afd183fcb7237afae6e
-	root sha256:b90c672088c015b9c83876e919da311bad4cd39639fb139f988af6a11493b974
+	root sha1:8510665149157c2bc901848c3e0b746954e9cbd9
+	root sha256:09ef24b38105f396a61ad78d73ba6a18ee3cbd89ce4524b4e13b6c1af191e2d8
 
-	exclude sha1:13263c0978fb9fad16b2d580fb800b6d811c3ff0
-	exclude sha256:fe4aaa1bbbbce4cb8f73426748a14c5ad6026b26f90505a0bf2494b165a5b76c
+	exclude sha1:2bdf67abb163a4ffb2d7f3f0880c9fe5068ce782
+	exclude sha256:b83643f4390b339c1b3ff2f5132c99bd4a77687dd321d3f386c25953aa6f1ce4
 
 	done sha1:1946f0437f90c5005533cbe1736a6451ca301714
 	done sha256:7f079501d79f665b3acc50f5e0e9e94509084d5032ac20113a37dd5029b757cc
-- 
2.56.0.rc0.807.ga0c0929ce1.frankengit
Tamir DubersteinSep 23, 2026, 22:26 UTC in reply to Tamir Duberstein on lore

[PATCH v2 2/3] dir: share untracked caches across output modes

The untracked cache stores different directory listings for --untracked-files=normal and --untracked-files=all. Switching modes bypasses the cache or rebuilds it, even when complete listings could be used for either mode.

Use each directory's check_only bit to record an incomplete scan. Rescan it when all untracked files are requested, and retain complete listings when a later 'git status -unormal' stops early. If a partial listing loses its cached untracked entry, rescan the directory to find any remaining untracked files.

Call treat_directory() for cached directories as well. For wildcard pathspecs without a fixed prefix, cache complete listings and apply the pathspec afterward; retain traversal pruning for fixed-prefix, attribute and exclude pathspecs. This follows the cache reuse proposed in [1] while preserving the early exit for large untracked trees [2].

Use a new value for dir_flags so older versions of Git rebuild the cache before using it to list untracked files. Retain DIR_SHOW_OTHER_DIRECTORIES so commands such as 'git add' in those versions still invalidate parent directories when updating the index. Reuse caches written for either 'normal' or 'all' mode.

[1]: https://lore.kernel.org/git/xmqqtucmag00.fsf@gitster.g/ [2]: https://lore.kernel.org/git/CAPMMpoixi3x1PHrSHJPV1GRBzMpuOQ4meMr-fipXuDvz-96MEA@mail.gmail.com/

Assisted-by: LLM
Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
 Documentation/gitformat-index.adoc |  13 ++-
 dir.c                              | 217 ++++++++++++++++++++-----------------
 dir.h                              |  16 +--
 t/t7063-status-untracked-cache.sh  | 167 ++++++++++++++++++----------
 4 files changed, 245 insertions(+), 168 deletions(-)
Show changes to 4 files +245 −168

Documentation/gitformat-index.adoc, dir.c, dir.h, t/t7063-status-untracked-cache.sh

diff --git a/Documentation/gitformat-index.adoc b/Documentation/gitformat-index.adoc
index f6a427cb49..d7596eb430 100644
--- a/Documentation/gitformat-index.adoc
+++ b/Documentation/gitformat-index.adoc
@@ -296,7 +296,13 @@ Git index format
 
   - Stat data of core.excludesFile
 
-  - 32-bit dir_flags (see struct dir_struct)
+  - 32-bit dir_flags. The values 0 and 6 store the flags from
+    `struct dir_struct` for `--untracked-files=all` and
+    `--untracked-files=normal`, respectively. The value 0x80000006 allows
+    the same cache to be used for both modes. Older versions of Git
+    rebuild this cache before using it to list untracked files. Its
+    `DIR_SHOW_OTHER_DIRECTORIES` bit ensures that they still invalidate
+    parent directories when updating the index.
 
   - Hash of $GIT_COMMON_DIR/info/exclude. A null hash means the file
     does not exist.
@@ -327,8 +333,9 @@ The remaining data of each directory block is grouped by type:
   - An ewah bitmap, the n-th bit marks whether the n-th directory has
     valid untracked cache entries.
 
-  - An ewah bitmap, the n-th bit records "check-only" bit of
-    read_directory_recursive() for the n-th directory.
+  - An ewah bitmap, the n-th bit records the "check-only" bit for the
+    n-th directory. With dir_flags 0x80000006, a set bit marks a directory
+    listing that may be incomplete.
 
   - An ewah bitmap, the n-th bit indicates whether hash and stat data
     is valid for the n-th directory and exists in the next data.
diff --git a/dir.c b/dir.c
index 5484eb1562..425fe2af67 100644
--- a/dir.c
+++ b/dir.c
@@ -43,6 +43,15 @@
   */
 #define PATTERN_MAX_FILE_SIZE (100 * 1024 * 1024)
 
+/*
+ * Use the same cache for --untracked-files=normal and --untracked-files=all.
+ * The high bit makes older versions of Git rebuild the cache before using
+ * it to list untracked files. Keep DIR_SHOW_OTHER_DIRECTORIES so their
+ * index updates still invalidate parent directories.
+ */
+#define UNTRACKED_CACHE_LAZY ((1U << 31) | DIR_SHOW_OTHER_DIRECTORIES | \
+			      DIR_HIDE_EMPTY_DIRECTORIES)
+
 /*
  * Tells read_directory_recursive how a file or directory should be treated.
  * Values are ordered by significance, e.g. if a directory contains both
@@ -1057,7 +1066,7 @@ static void trim_trailing_spaces(char *buf)
 /*
  * Given a subdirectory name and "dir" of the current directory,
  * search the subdir in "dir" and return it, or create a new one if it
- * does not exist in "dir".
+ * does not exist in "dir". If "uc" is NULL, do not create a new entry.
  *
  * If "name" has the trailing slash, it'll be excluded in the search.
  */
@@ -1088,6 +1097,8 @@ static struct untracked_cache_dir *lookup_untracked(struct untracked_cache *uc,
 		first = next+1;
 	}
 
+	if (!uc)
+		return NULL;
 	uc->dir_created++;
 	FLEX_ALLOC_MEM(d, name, name, len);
 
@@ -2410,26 +2421,19 @@ static enum path_treatment treat_path_fast(struct dir_struct *dir,
 	strbuf_setlen(path, baselen);
 	if (!cdir->ucd) {
 		strbuf_addstr(path, cdir->file);
-		return path_untracked;
+		if (!ends_with(cdir->file, "/"))
+			return path_untracked;
+	} else {
+		strbuf_addstr(path, cdir->ucd->name);
+		/* treat_directory() expects a trailing slash. */
+		strbuf_complete(path, '/');
 	}
-	strbuf_addstr(path, cdir->ucd->name);
-	/* treat_one_path() does this before it calls treat_directory() */
-	strbuf_complete(path, '/');
-	if (cdir->ucd->check_only)
-		/*
-		 * check_only is set as a result of treat_directory() getting
-		 * to its bottom. Verify again the same set of directories
-		 * with check_only set.
-		 */
-		return read_directory_recursive(dir, istate, path->buf, path->len,
-						cdir->ucd, 1, 0, pathspec);
 	/*
-	 * We get path_recurse in the first run when
-	 * directory_exists_in_index() returns index_nonexistent. We
-	 * are sure that new changes in the index does not impact the
-	 * outcome. Return now.
+	 * The output mode may have changed since this directory was cached,
+	 * and a nested repository may have been created or removed.
 	 */
-	return path_recurse;
+	return treat_directory(dir, istate, cdir->untracked, path->buf,
+			       path->len, baselen, 0, pathspec);
 }
 
 static enum path_treatment treat_path(struct dir_struct *dir,
@@ -2556,7 +2560,8 @@ static int valid_cached_dir(struct dir_struct *dir,
 		}
 	}
 
-	if (untracked->check_only != !!check_only)
+	/* A complete listing can also answer a check_only request. */
+	if (untracked->check_only && !check_only)
 		return 0;
 
 	/*
@@ -2618,7 +2623,12 @@ static int read_cached_dir(struct cached_dir *cdir)
 		cdir->d_type = DTYPE(de);
 		return 0;
 	}
-	while (cdir->nr_dirs < cdir->untracked->dirs_nr) {
+	/*
+	 * If a cached entry is no longer a nested repository, recursing into
+	 * it can add it to dirs while we iterate over untracked. Do not visit
+	 * it twice.
+	 */
+	while (!cdir->nr_files && cdir->nr_dirs < cdir->untracked->dirs_nr) {
 		struct untracked_cache_dir *d = cdir->untracked->dirs[cdir->nr_dirs];
 		if (!d->recurse) {
 			cdir->nr_dirs++;
@@ -2629,9 +2639,17 @@ static int read_cached_dir(struct cached_dir *cdir)
 		return 0;
 	}
 	cdir->ucd = NULL;
-	if (cdir->nr_files < cdir->untracked->untracked_nr) {
+	while (cdir->nr_files < cdir->untracked->untracked_nr) {
 		struct untracked_cache_dir *d = cdir->untracked;
 		cdir->file = d->untracked[cdir->nr_files++];
+		/* A directory may occur in both dirs and untracked. Return it once. */
+		if (ends_with(cdir->file, "/")) {
+			struct untracked_cache_dir *child =
+				lookup_untracked(NULL, d, cdir->file,
+						 strlen(cdir->file));
+			if (child && child->recurse)
+				continue;
+		}
 		return 0;
 	}
 	return -1;
@@ -2641,10 +2659,7 @@ static void close_cached_dir(struct cached_dir *cdir)
 {
 	if (cdir->fdir)
 		closedir(cdir->fdir);
-	/*
-	 * We have gone through this directory and found no untracked
-	 * entries. Mark it valid.
-	 */
+	/* The listing is valid even if check_only marks it as incomplete. */
 	if (cdir->untracked) {
 		cdir->untracked->valid = 1;
 		cdir->untracked->recurse = 1;
@@ -2717,6 +2732,7 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir,
 	struct cached_dir cdir;
 	enum path_treatment state, subdir_state, dir_state = path_none;
 	struct strbuf path = STRBUF_INIT;
+	int incomplete = 0;
 
 	strbuf_add(&path, base, baselen);
 
@@ -2724,9 +2740,6 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir,
 		goto out;
 	dir->internal.visited_directories++;
 
-	if (untracked)
-		untracked->check_only = !!check_only;
-
 	while (!read_cached_dir(&cdir)) {
 		/* check how the file or directory should be treated */
 		state = treat_path(dir, untracked, &cdir, istate, &path,
@@ -2776,6 +2789,7 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir,
 				 */
 				if (dir_state >= path_excluded) {
 					dir_state = path_excluded;
+					incomplete = 1;
 					break;
 				}
 			}
@@ -2784,6 +2798,7 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir,
 			if (dir_state == path_untracked) {
 				if (cdir.fdir)
 					add_untracked(untracked, path.buf + baselen);
+				incomplete = 1;
 				break;
 			}
 			/* skip the add_path_to_appropriate_result_list() */
@@ -2794,7 +2809,27 @@ static enum path_treatment read_directory_recursive(struct dir_struct *dir,
 						    istate, &path, baselen,
 						    pathspec, state);
 	}
-	close_cached_dir(&cdir);
+	/*
+	 * Only a filesystem scan replaces the saved completeness. Stopping
+	 * early while reading a complete cache must not make it partial.
+	 */
+	if (cdir.fdir && untracked)
+		untracked->check_only = incomplete;
+	if (!cdir.fdir && untracked->check_only &&
+	    dir_state != path_untracked) {
+		/*
+		 * Removing the last untracked file in a cached child need not
+		 * change this directory's mtime. Other children may still have
+		 * untracked files, so rescan the directory before returning.
+		 */
+		close_cached_dir(&cdir);
+		invalidate_directory(dir->untracked, untracked);
+		dir_state = read_directory_recursive(dir, istate, base, baselen,
+						     untracked, check_only,
+						     stop_at_first_file, pathspec);
+	} else {
+		close_cached_dir(&cdir);
+	}
  out:
 	strbuf_release(&path);
 
@@ -2925,33 +2960,12 @@ static void set_untracked_ident(struct untracked_cache *uc)
 	strbuf_addch(&uc->ident, 0);
 }
 
-static unsigned new_untracked_cache_flags(struct index_state *istate)
-{
-	struct repository *repo = istate->repo;
-	const char *val;
-
-	/*
-	 * This logic is coordinated with the setting of these flags in
-	 * wt-status.c#wt_status_collect_untracked(), and the evaluation
-	 * of the config setting in commit.c#git_status_config()
-	 */
-	if (!repo_config_get_string_tmp(repo, "status.showuntrackedfiles", &val) &&
-	    !strcmp(val, "all"))
-		return 0;
-
-	/*
-	 * The default, if "all" is not set, is "normal" - leading us here.
-	 * If the value is "none" then it really doesn't matter.
-	 */
-	return DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES;
-}
-
-static void new_untracked_cache(struct index_state *istate, int flags)
+static void new_untracked_cache(struct index_state *istate)
 {
 	struct untracked_cache *uc = xcalloc(1, sizeof(*uc));
 	strbuf_init(&uc->ident, 100);
 	uc->exclude_per_dir = ".gitignore";
-	uc->dir_flags = flags >= 0 ? flags : new_untracked_cache_flags(istate);
+	uc->dir_flags = UNTRACKED_CACHE_LAZY;
 	set_untracked_ident(uc);
 	istate->untracked = uc;
 	istate->cache_changed |= UNTRACKED_CHANGED;
@@ -2960,11 +2974,11 @@ static void new_untracked_cache(struct index_state *istate, int flags)
 void add_untracked_cache(struct index_state *istate)
 {
 	if (!istate->untracked) {
-		new_untracked_cache(istate, -1);
+		new_untracked_cache(istate);
 	} else {
 		if (!ident_in_untracked(istate->untracked)) {
 			free_untracked_cache(istate->untracked);
-			new_untracked_cache(istate, -1);
+			new_untracked_cache(istate);
 		}
 	}
 }
@@ -3004,10 +3018,9 @@ static struct untracked_cache_dir *validate_untracked_cache(struct dir_struct *d
 		return NULL;
 
 	/*
-	 * Optimize for the main use case only: whole-tree git
-	 * status. More work involved in treat_leading_path() if we
-	 * use cache on just a subset of the worktree. pathspec
-	 * support could make the matter even worse.
+	 * The cache needs a whole-tree scan without pathspec pruning.
+	 * read_directory() handles eligible pathspecs by filtering the results
+	 * after the scan and passing NULL here.
 	 */
 	if (base_len || (pathspec && pathspec->nr))
 		return NULL;
@@ -3037,47 +3050,22 @@ static struct untracked_cache_dir *validate_untracked_cache(struct dir_struct *d
 		return NULL;
 	}
 
-	/*
-	 * If the untracked structure we received does not have the same flags
-	 * as requested in this run, we're going to need to either discard the
-	 * existing structure (and potentially later recreate), or bypass the
-	 * untracked cache mechanism for this run.
-	 */
-	if (dir->flags != dir->untracked->dir_flags) {
-		/*
-		 * If the untracked structure we received does not have the same flags
-		 * as configured, then we need to reset / create a new "untracked"
-		 * structure to match the new config.
-		 *
-		 * Keeping the saved and used untracked cache consistent with the
-		 * configuration provides an opportunity for frequent users of
-		 * "git status -uall" to leverage the untracked cache by aligning their
-		 * configuration - setting "status.showuntrackedfiles" to "all" or
-		 * "normal" as appropriate.
-		 *
-		 * Previously using -uall (or setting "status.showuntrackedfiles" to
-		 * "all") was incompatible with untracked cache and *consistently*
-		 * caused surprisingly bad performance (with fscache and fsmonitor
-		 * enabled) on Windows.
-		 *
-		 * IMPROVEMENT OPPORTUNITY: If we reworked the untracked cache storage
-		 * to not be as bound up with the desired output in a given run,
-		 * and instead iterated through and stored enough information to
-		 * correctly serve both "modes", then users could get peak performance
-		 * with or without '-uall' regardless of their
-		 * "status.showuntrackedfiles" config.
-		 */
-		if (dir->untracked->dir_flags != new_untracked_cache_flags(istate)) {
+	/* Only --untracked-files=normal and --untracked-files=all are supported. */
+	if (dir->flags &&
+	    dir->flags != (DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES))
+		return NULL;
+
+	if (dir->untracked->dir_flags != UNTRACKED_CACHE_LAZY) {
+		/* Reuse caches written for either mode by older versions of Git. */
+		if (dir->untracked->dir_flags &&
+		    dir->untracked->dir_flags !=
+			    (DIR_SHOW_OTHER_DIRECTORIES | DIR_HIDE_EMPTY_DIRECTORIES)) {
 			free_untracked_cache(istate->untracked);
-			new_untracked_cache(istate, dir->flags);
+			new_untracked_cache(istate);
 			dir->untracked = istate->untracked;
-		}
-		else {
-			/*
-			 * Current untracked cache data is consistent with config, but not
-			 * usable in this request/run; just bypass untracked cache.
-			 */
-			return NULL;
+		} else {
+			dir->untracked->dir_flags = UNTRACKED_CACHE_LAZY;
+			istate->cache_changed |= UNTRACKED_CHANGED;
 		}
 	}
 
@@ -3145,6 +3133,16 @@ int read_directory(struct dir_struct *dir, struct index_state *istate,
 		   const char *path, int len, const struct pathspec *pathspec)
 {
 	struct untracked_cache_dir *untracked;
+	const struct pathspec *walk_pathspec = pathspec;
+	/* Attribute and exclude pathspecs can prune a directory by its own name. */
+	int filter = dir->untracked &&
+		     !len && !dir->flags && pathspec && pathspec->nr &&
+		     !(pathspec->magic & (PATHSPEC_ATTR | PATHSPEC_EXCLUDE));
+
+	/* Keep the usual pruning for pathspecs with a fixed prefix. */
+	for (int i = 0; filter && i < pathspec->nr; i++)
+		if (pathspec->items[i].nowildcard_len)
+			filter = 0;
 
 	trace2_region_enter("dir", "read_directory", istate->repo);
 	dir->internal.visited_paths = 0;
@@ -3155,15 +3153,34 @@ int read_directory(struct dir_struct *dir, struct index_state *istate,
 		return dir->nr;
 	}
 
-	untracked = validate_untracked_cache(dir, len, pathspec, istate);
+	untracked = validate_untracked_cache(dir, len,
+					     filter ? NULL : pathspec, istate);
 	if (!untracked)
 		/*
 		 * make sure untracked cache code path is disabled,
 		 * e.g. prep_exclude()
 		 */
 		dir->untracked = NULL;
-	if (!len || treat_leading_path(dir, istate, path, len, pathspec))
-		read_directory_recursive(dir, istate, path, len, untracked, 0, 0, pathspec);
+	else if (filter)
+		walk_pathspec = NULL;
+	if (!len || treat_leading_path(dir, istate, path, len, walk_pathspec))
+		read_directory_recursive(dir, istate, path, len, untracked, 0, 0,
+					 walk_pathspec);
+	if (filter && untracked) {
+		int dst = 0;
+
+		/* Keep complete listings in the cache for later pathspecs. */
+		for (int i = 0; i < dir->nr; i++) {
+			struct dir_entry *ent = dir->entries[i];
+
+			if (match_pathspec(istate, pathspec, ent->name, ent->len,
+					   0, NULL, 0))
+				dir->entries[dst++] = dir->entries[i];
+			else
+				free(dir->entries[i]);
+		}
+		dir->nr = dst;
+	}
 	QSORT(dir->entries, dir->nr, cmp_dir_entry);
 	QSORT(dir->ignored, dir->ignored_nr, cmp_dir_entry);
 
diff --git a/dir.h b/dir.h
index 83e0f648a8..5cdad2bc45 100644
--- a/dir.h
+++ b/dir.h
@@ -152,13 +152,10 @@ struct oid_stat {
  *
  *   - The list of files and directories of the directory in question
  *   - The $GIT_DIR/index
- *   - dir_struct flags
  *   - The content of $GIT_COMMON_DIR/info/exclude
  *   - The content of core.excludesfile
  *   - The content (or the lack) of .gitignore of all parent directories
  *     from $GIT_WORK_TREE
- *   - The check_only flag in read_directory_recursive (for
- *     DIR_HIDE_EMPTY_DIRECTORIES)
  *
  *  The first input can be checked using directory mtime. In many
  *  filesystems, directory mtime (stat_data field) is updated when its
@@ -168,9 +165,10 @@ struct oid_stat {
  *  Whenever a file (or a submodule) is added or removed from a
  *  directory, we invalidate that directory.
  *
- *  The remaining inputs are easy, their SHA-1 could be used to verify
- *  their contents (exclude_sha1[], info_exclude_sha1[] and
- *  excludes_file_sha1[])
+ *  Normal and all mode share these listings; other directory flags bypass
+ *  the cache. Partial scans are marked check_only and completed when needed.
+ *
+ *  Ignore files are validated by their object IDs.
  */
 struct untracked_cache_dir {
 	struct untracked_cache_dir **dirs;
@@ -178,6 +176,7 @@ struct untracked_cache_dir {
 	struct stat_data stat_data;
 	unsigned int untracked_alloc, dirs_nr, dirs_alloc;
 	unsigned int untracked_nr;
+	/* The directory scan stopped early; the listing may be incomplete. */
 	unsigned int check_only : 1;
 	/* all data except 'dirs' in this struct are good */
 	unsigned int valid : 1;
@@ -193,10 +192,7 @@ struct untracked_cache {
 	const char *exclude_per_dir;
 	char *exclude_per_dir_to_free;
 	struct strbuf ident;
-	/*
-	 * dir_struct#flags must match dir_flags or the untracked
-	 * cache is ignored.
-	 */
+	/* UNTRACKED_CACHE_LAZY, or dir_struct.flags from older versions of Git. */
 	unsigned dir_flags;
 	struct untracked_cache_dir *root;
 	/* Statistics */
diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh
index 10d4850077..2efbb93b71 100755
--- a/t/t7063-status-untracked-cache.sh
+++ b/t/t7063-status-untracked-cache.sh
@@ -115,7 +115,7 @@ test_expect_success 'untracked cache is empty' '
 info/exclude $ZERO_OID
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 EOF
 	test_cmp ../expect-empty ../actual
 '
@@ -133,7 +133,7 @@ cat >../dump.expect <<EOF &&
 info/exclude $EMPTY_BLOB
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $ZERO_OID recurse valid
 dthree/
 dtwo/
@@ -200,11 +200,7 @@ A  two
 ?? three
 EOF
 
-# Bypassing the untracked cache here is not desirable from an
-# end-user perspective, but is expected in the current design.
-# The untracked cache data stored for a -unormal run cannot be
-# correctly used in a -uall run - it would yield incorrect output.
-test_expect_success 'untracked cache is bypassed with -uall' '
+test_expect_success 'untracked cache completes partial directory listings with -uall' '
 	: >../trace.output &&
 	GIT_TRACE2_PERF="$TRASH_DIRECTORY/trace.output" \
 	git status -uall --porcelain >../actual &&
@@ -214,16 +210,21 @@ test_expect_success 'untracked cache is bypassed with -uall' '
 	get_relevant_traces ../trace.output ../trace.relevant &&
 	cat >../trace.expect <<EOF &&
  ....path:
+ ....node-creation:0
+ ....gitignore-invalidation:0
+ ....directory-invalidation:2
+ ....opendir:2
 EOF
 	test_cmp ../trace.expect ../trace.relevant
 '
 
-test_expect_success 'untracked cache remains after bypass' '
+test_expect_success 'untracked cache retains completed listings' '
+	sed "s/check_only //" ../dump.expect >../dump_uall.expect &&
 	test-tool dump-untracked-cache >../actual &&
-	test_cmp ../dump.expect ../actual
+	test_cmp ../dump_uall.expect ../actual
 '
 
-test_expect_success 'if -uall is configured, untracked cache gets populated by default' '
+test_expect_success 'if -uall is configured, untracked cache is reused by default' '
 	test_config status.showuntrackedfiles all &&
 	: >../trace.output &&
 	GIT_TRACE2_PERF="$TRASH_DIRECTORY/trace.output" \
@@ -234,28 +235,14 @@ test_expect_success 'if -uall is configured, untracked cache gets populated by d
 	get_relevant_traces ../trace.output ../trace.relevant &&
 	cat >../trace.expect <<EOF &&
  ....path:
- ....node-creation:3
- ....gitignore-invalidation:1
+ ....node-creation:0
+ ....gitignore-invalidation:0
  ....directory-invalidation:0
- ....opendir:4
+ ....opendir:0
 EOF
 	test_cmp ../trace.expect ../trace.relevant
 '
 
-cat >../dump_uall.expect <<EOF &&
-info/exclude $EMPTY_BLOB
-core.excludesfile $ZERO_OID
-exclude_per_dir .gitignore
-flags 00000000
-/ $ZERO_OID recurse valid
-three
-/done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse valid
-three
-/dtwo/ $ZERO_OID recurse valid
-two
-EOF
-
 test_expect_success 'if -uall was configured, untracked cache is populated' '
 	test-tool dump-untracked-cache >../actual &&
 	test_cmp ../dump_uall.expect ../actual
@@ -280,12 +267,7 @@ EOF
 	test_cmp ../trace.expect ../trace.relevant
 '
 
-# Bypassing the untracked cache here is not desirable from an
-# end-user perspective, but is expected in the current design.
-# The untracked cache data stored for a -all run cannot be
-# correctly used in a -unormal run - it would yield incorrect
-# output.
-test_expect_success 'if -uall is configured, untracked cache is bypassed with -unormal' '
+test_expect_success 'if -uall is configured, untracked cache is reused with -unormal' '
 	test_config status.showuntrackedfiles all &&
 	: >../trace.output &&
 	GIT_TRACE2_PERF="$TRASH_DIRECTORY/trace.output" \
@@ -296,12 +278,18 @@ test_expect_success 'if -uall is configured, untracked cache is bypassed with -u
 	get_relevant_traces ../trace.output ../trace.relevant &&
 	cat >../trace.expect <<EOF &&
  ....path:
+ ....node-creation:0
+ ....gitignore-invalidation:0
+ ....directory-invalidation:0
+ ....opendir:0
 EOF
 	test_cmp ../trace.expect ../trace.relevant
 '
 
-test_expect_success 'repopulate untracked cache for -unormal' '
-	git status --porcelain
+test_expect_success 'normal status preserves completed untracked cache' '
+	git status --porcelain &&
+	test-tool dump-untracked-cache >../actual &&
+	test_cmp ../dump_uall.expect ../actual
 '
 
 test_expect_success 'modify in root directory, one dir invalidation' '
@@ -340,16 +328,16 @@ test_expect_success 'verify untracked cache dump' '
 info/exclude $EMPTY_BLOB
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $ZERO_OID recurse valid
 dthree/
 dtwo/
 four
 three
 /done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 three
-/dtwo/ $ZERO_OID recurse check_only valid
+/dtwo/ $ZERO_OID recurse valid
 two
 EOF
 	test_cmp ../expect ../actual
@@ -390,7 +378,7 @@ test_expect_success 'verify untracked cache dump' '
 info/exclude $EMPTY_BLOB
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse valid
 .gitignore
 dthree/
@@ -437,12 +425,12 @@ test_expect_success 'verify untracked cache dump' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse valid
 .gitignore
 dtwo/
 /done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -456,10 +444,10 @@ test_expect_success 'move two from tracked to untracked' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse
 /done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -497,13 +485,13 @@ test_expect_success 'verify untracked cache dump' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse valid
 .gitignore
 dtwo/
 two
 /done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -517,10 +505,10 @@ test_expect_success 'move two from untracked to tracked' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse
 /done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -558,12 +546,12 @@ test_expect_success 'verify untracked cache dump' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse valid
 .gitignore
 dtwo/
 /done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -606,12 +594,12 @@ test_expect_success 'untracked cache correct after commit' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse valid
 .gitignore
 dtwo/
 /done/ $ZERO_OID recurse valid
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -669,13 +657,13 @@ test_expect_success 'untracked cache correct after status' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse valid
 .gitignore
 dtwo/
 /done/ $(test_oid done) recurse valid
 five
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -744,7 +732,7 @@ test_expect_success 'verify untracked cache dump (sparse/subdirs)' '
 info/exclude $(test_oid exclude)
 core.excludesfile $ZERO_OID
 exclude_per_dir .gitignore
-flags 00000006
+flags 80000006
 / $(test_oid root) recurse valid
 .gitignore
 dtwo/
@@ -755,7 +743,7 @@ sub/
 sub/
 /done/sub/sub/ $ZERO_OID recurse check_only valid
 file
-/dthree/ $ZERO_OID recurse check_only valid
+/dthree/ $ZERO_OID recurse valid
 /dtwo/ $ZERO_OID recurse check_only valid
 two
 EOF
@@ -991,4 +979,73 @@ test_expect_success 'empty repo (no index) and core.untrackedCache' '
 	git -C emptyrepo -c core.untrackedCache=true write-tree
 '
 
+test_expect_success 'rescan a partial listing after removing the cached untracked file' '
+	test_create_repo partial-cache &&
+	(
+		cd partial-cache &&
+		touch tracked &&
+		git add tracked &&
+		git commit -m initial &&
+		git config core.untrackedCache true &&
+		mkdir -p d/a d/z &&
+		touch d/a/file d/z/file &&
+		test-tool chmtime =-300 . d d/a d/z &&
+		git status --porcelain >../actual &&
+		test-tool dump-untracked-cache >../dump &&
+		cached_dir=$(sed -n "s|^/d/\([^/]*\)/ .*check_only valid$|\1|p" ../dump) &&
+		test -n "$cached_dir" &&
+		rm "d/$cached_dir/file" &&
+		git status --porcelain >../actual &&
+		echo "?? d/" >../expect &&
+		test_cmp ../expect ../actual
+	)
+'
+
+test_expect_success 'untracked cache handles nested repository changes' '
+	test_create_repo nested-cache &&
+	(
+		cd nested-cache &&
+		touch tracked &&
+		git add tracked &&
+		git commit -m initial &&
+		git config core.untrackedCache true &&
+		git init nested &&
+		touch nested/file &&
+		test-tool chmtime =-300 . nested &&
+		git status -uall --porcelain >../actual &&
+		echo "?? nested/" >../expect &&
+		test_cmp ../expect ../actual &&
+		rm -rf nested/.git &&
+		git status -uall --porcelain >../actual &&
+		echo "?? nested/file" >../expect &&
+		test_cmp ../expect ../actual &&
+		git init nested &&
+		git status -uall --porcelain >../actual &&
+		echo "?? nested/" >../expect &&
+		test_cmp ../expect ../actual
+	)
+'
+
+test_expect_success 'filtered status retains complete untracked listings' '
+	test_create_repo filtered-cache &&
+	(
+		cd filtered-cache &&
+		touch tracked &&
+		git add tracked &&
+		git commit -m initial &&
+		git config core.untrackedCache true &&
+		mkdir d &&
+		touch d/match.toml d/other &&
+		test-tool chmtime =-300 . d &&
+		git status -uall --porcelain -- "**/*.toml" >../actual &&
+		echo "?? d/match.toml" >../expect &&
+		test_cmp ../expect ../actual &&
+		GIT_TRACE2_PERF="$TRASH_DIRECTORY/filtered.trace" \
+			git status -uall --porcelain >../actual &&
+		printf "%s\n" "?? d/match.toml" "?? d/other" >../expect &&
+		test_cmp ../expect ../actual &&
+		test_grep "read_directo.*opendir:0\$" "$TRASH_DIRECTORY/filtered.trace"
+	)
+'
+
 test_done
-- 
2.56.0.rc0.807.ga0c0929ce1.frankengit
Tamir DubersteinSep 23, 2026, 22:27 UTC in reply to Tamir Duberstein on lore

[PATCH v2 3/3] ls-files: use and update the untracked cache

Repeated 'git ls-files --cached --others --exclude-standard' calls scan the working tree even when 'git status' has populated an untracked cache. Reuse those listings and write pending untracked cache and fsmonitor updates back to the index when optional locking is enabled, so later commands can reuse the work.

Parse options before reading the index so eligible calls can take the optional lock first. With index.skipHash, the index checksum cannot be used to detect concurrent index changes. Record the object IDs of the standard exclude files before assigning dir->untracked, so the exclude options can be processed in the same order before reading the index.

A pathspec prefix can prune the in-memory index, and --with-tree can add entries to it. Do not write the index in those cases. Respect --no-optional-locks and continue without writing when the index lock is busy.

Assisted-by: LLM
Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
 Documentation/git-ls-files.adoc    |  4 ++
 builtin/ls-files.c                 | 40 +++++++++++++---
 dir.c                              | 28 +++++------
 t/perf/p3010-ls-files.sh           | 15 ++++++
 t/t3001-ls-files-others-exclude.sh | 20 ++++++++
 t/t7063-status-untracked-cache.sh  | 97 ++++++++++++++++++++++++++++++++++++++
 t/t7519-status-fsmonitor.sh        | 44 +++++++++++++++++
 7 files changed, 227 insertions(+), 21 deletions(-)
Show changes to 7 files +227 −21

Documentation/git-ls-files.adoc, builtin/ls-files.c, dir.c, t/perf/p3010-ls-files.sh, t/t3001-ls-files-others-exclude.sh, t/t7063-status-untracked-cache.sh, t/t7519-status-fsmonitor.sh

diff --git a/Documentation/git-ls-files.adoc b/Documentation/git-ls-files.adoc
index 2b175388e1..11323b4ba9 100644
--- a/Documentation/git-ls-files.adoc
+++ b/Documentation/git-ls-files.adoc
@@ -127,6 +127,10 @@ OPTIONS
 --exclude-standard::
 	Add the standard Git exclusions: .git/info/exclude, .gitignore
 	in each directory, and the user's global exclusion file.
++
+With `--others`, an enabled untracked cache can be updated in the index
+after a complete traversal. Use `--no-optional-locks` (see linkgit:git[1])
+to disable this optional index update.
 
 --error-unmatch::
 	If any <file> does not appear in the index, treat this as an
diff --git a/builtin/ls-files.c b/builtin/ls-files.c
index b044520f9e..e74ffa344d 100644
--- a/builtin/ls-files.c
+++ b/builtin/ls-files.c
@@ -15,6 +15,7 @@
 #include "quote.h"
 #include "dir.h"
 #include "gettext.h"
+#include "lockfile.h"
 #include "object-name.h"
 #include "strbuf.h"
 #include "parse-options.h"
@@ -597,6 +598,8 @@ int cmd_ls_files(int argc,
 	struct dir_struct dir = DIR_INIT;
 	struct pattern_list *pl;
 	struct string_list exclude_list = STRING_LIST_INIT_NODUP;
+	struct lock_file index_lock = LOCK_INIT;
+	int index_fd = -1;
 	struct option builtin_ls_files_options[] = {
 		/* Think twice before adding "--nul" synonym to this */
 		OPT_SET_INT('z', NULL, &line_terminator,
@@ -678,14 +681,13 @@ int cmd_ls_files(int argc,
 		prefix_len = strlen(prefix);
 	repo_config(repo, git_default_config, NULL);
 
-	if (repo_read_index(repo) < 0)
-		die("index file corrupt");
-
 	argc = parse_options(argc, argv, prefix, builtin_ls_files_options,
 			ls_files_usage, 0);
-	pl = add_pattern_list(&dir, EXC_CMDL, "--exclude option");
-	for (i = 0; i < exclude_list.nr; i++) {
-		add_pattern(exclude_list.items[i].string, "", 0, pl, --exclude_args);
+	/* Even an empty command-line exclude list would disable the cache. */
+	if (exclude_list.nr) {
+		pl = add_pattern_list(&dir, EXC_CMDL, "--exclude option");
+		for (i = 0; i < exclude_list.nr; i++)
+			add_pattern(exclude_list.items[i].string, "", 0, pl, --exclude_args);
 	}
 
 	if (format && (show_stage || show_others || show_killed ||
@@ -747,6 +749,25 @@ int cmd_ls_files(int argc,
 		max_prefix = common_prefix(&pathspec);
 	max_prefix_len = get_common_prefix_len(max_prefix);
 
+	/*
+	 * Do not save an index pruned by a pathspec or changed by --with-tree.
+	 * With index.skipHash, the checksum cannot detect concurrent index
+	 * changes. Lock the index before reading it.
+	 */
+	if (show_others && !dir.flags && dir.exclude_per_dir &&
+	    !max_prefix && !with_tree && use_optional_locks() &&
+	    !dir.internal.unmanaged_exclude_files && !exclude_list.nr &&
+	    repo->settings.core_untracked_cache != UNTRACKED_CACHE_REMOVE)
+		index_fd = repo_hold_locked_index(repo, &index_lock, 0);
+	if (repo_read_index(repo) < 0)
+		die("index file corrupt");
+	if (show_others && !dir.flags && dir.exclude_per_dir && !with_tree)
+		dir.untracked = repo->index->untracked;
+	if (index_fd >= 0 && !dir.untracked) {
+		rollback_lock_file(&index_lock);
+		index_fd = -1;
+	}
+
 	prune_index(repo->index, max_prefix, max_prefix_len);
 
 	/* Treat unmatching pathspec elements as errors */
@@ -784,6 +805,13 @@ int cmd_ls_files(int argc,
 		ret = 1;
 	}
 
+	if (index_fd >= 0) {
+		if (!ret && dir.untracked)
+			repo_update_index_if_able(repo, &index_lock);
+		else
+			rollback_lock_file(&index_lock);
+	}
+
 	string_list_clear(&exclude_list, 0);
 	dir_clear(&dir);
 	free(max_prefix);
diff --git a/dir.c b/dir.c
index 425fe2af67..949f06c8f7 100644
--- a/dir.c
+++ b/dir.c
@@ -1209,7 +1209,8 @@ static int add_patterns(const char *fname, const char *base, int baselen,
 		close(fd);
 		if (oid_stat) {
 			int pos;
-			if (oid_stat->valid &&
+			/* Racy stat checks need the index timestamp. */
+			if (istate && oid_stat->valid &&
 			    !match_stat_data_racy(istate, &oid_stat->stat, &st))
 				; /* no content change, oid_stat->oid still good */
 			else if (istate &&
@@ -1321,18 +1322,15 @@ struct pattern_list *add_pattern_list(struct dir_struct *dir,
 }
 
 /*
- * Used to set up core.excludesfile and .git/info/exclude lists.
+ * Only the standard exclude files have object IDs saved in the untracked
+ * cache. Other files have no oid_stat and must disable use of the cache.
  */
 static void add_patterns_from_file_1(struct dir_struct *dir, const char *fname,
 				     struct oid_stat *oid_stat)
 {
 	struct pattern_list *pl;
-	/*
-	 * catch setup_standard_excludes() that's called before
-	 * dir->untracked is assigned. That function behaves
-	 * differently when dir->untracked is non-NULL.
-	 */
-	if (!dir->untracked)
+
+	if (!oid_stat)
 		dir->internal.unmanaged_exclude_files++;
 	pl = add_pattern_list(dir, EXC_FILE, fname);
 	if (add_patterns(fname, "", 0, pl, NULL, 0, oid_stat) < 0)
@@ -1341,7 +1339,6 @@ static void add_patterns_from_file_1(struct dir_struct *dir, const char *fname,
 
 void add_patterns_from_file(struct dir_struct *dir, const char *fname)
 {
-	dir->internal.unmanaged_exclude_files++; /* see validate_untracked_cache() */
 	add_patterns_from_file_1(dir, fname, NULL);
 }
 
@@ -3009,10 +3006,7 @@ static struct untracked_cache_dir *validate_untracked_cache(struct dir_struct *d
 
 	/*
 	 * We only support $GIT_COMMON_DIR/info/exclude and core.excludesfile
-	 * as the global ignore rule files. Any other additions
-	 * (e.g. from command line) invalidate the cache. This
-	 * condition also catches running setup_standard_excludes()
-	 * before setting dir->untracked!
+	 * as the global ignore rule files. Other exclude files bypass the cache.
 	 */
 	if (dir->internal.unmanaged_exclude_files)
 		return NULL;
@@ -3511,17 +3505,21 @@ void setup_standard_excludes(struct dir_struct *dir)
 
 	dir->exclude_per_dir = ".gitignore";
 
+	/*
+	 * Option parsing may precede reading the index. Record the object IDs
+	 * even before the untracked cache is available for validation.
+	 */
 	/* core.excludesfile defaulting to $XDG_CONFIG_HOME/git/ignore */
 	if (excludes_file && !access_or_warn(excludes_file, R_OK, 0))
 		add_patterns_from_file_1(dir, excludes_file,
-					 dir->untracked ? &dir->internal.ss_excludes_file : NULL);
+					 &dir->internal.ss_excludes_file);
 
 	/* per repository user preference */
 	if (startup_info->have_repository) {
 		const char *path = git_path_info_exclude();
 		if (!access_or_warn(path, R_OK, 0))
 			add_patterns_from_file_1(dir, path,
-						 dir->untracked ? &dir->internal.ss_info_exclude : NULL);
+						 &dir->internal.ss_info_exclude);
 	}
 }
 
diff --git a/t/perf/p3010-ls-files.sh b/t/perf/p3010-ls-files.sh
index ae14449432..bdf398906f 100755
--- a/t/perf/p3010-ls-files.sh
+++ b/t/perf/p3010-ls-files.sh
@@ -28,4 +28,19 @@ test_perf 'ls-files --modified with pathspec' '
 		-- "$pathspec" >/dev/null
 '
 
+test_perf 'ls-files --others with pathspec and no untracked cache' '
+	git -c core.fsmonitor=false -c core.untrackedCache=false \
+		ls-files --cached --others --exclude-standard -- "$pathspec" >/dev/null
+'
+
+test_expect_success 'populate the untracked cache with ls-files' '
+	git config core.untrackedCache true &&
+	git -c core.fsmonitor=false ls-files --others --exclude-standard >/dev/null
+'
+
+test_perf 'ls-files --others with pathspec and untracked cache' '
+	git -c core.fsmonitor=false ls-files --cached --others \
+		--exclude-standard -- "$pathspec" >/dev/null
+'
+
 test_done
diff --git a/t/t3001-ls-files-others-exclude.sh b/t/t3001-ls-files-others-exclude.sh
index 29a0a25b30..5ca80932ac 100755
--- a/t/t3001-ls-files-others-exclude.sh
+++ b/t/t3001-ls-files-others-exclude.sh
@@ -306,4 +306,24 @@ test_expect_success 'ls-files with "**" patterns and no slashes' '
 	test_must_be_empty actual
 '
 
+test_expect_success 'ls-files preserves repeated and ordered exclude options' '
+	test_create_repo exclude-options &&
+	(
+		cd exclude-options &&
+		touch keep info-hidden global-hidden &&
+		echo info-hidden >.git/info/exclude &&
+		echo global-hidden >.git/global-ignore &&
+		git config core.excludesFile .git/global-ignore &&
+		echo "!info-hidden" >.git/custom-ignore &&
+		echo keep >../expect &&
+		git ls-files --others --exclude-from=.git/custom-ignore \
+			--exclude-standard --exclude-standard >../actual &&
+		test_cmp ../expect ../actual &&
+		git ls-files --others --exclude-standard \
+			--exclude-from=.git/custom-ignore >../actual &&
+		printf "%s\n" info-hidden keep >../expect &&
+		test_cmp ../expect ../actual
+	)
+'
+
 test_done
diff --git a/t/t7063-status-untracked-cache.sh b/t/t7063-status-untracked-cache.sh
index 2efbb93b71..70eed91dd7 100755
--- a/t/t7063-status-untracked-cache.sh
+++ b/t/t7063-status-untracked-cache.sh
@@ -1016,6 +1016,9 @@ test_expect_success 'untracked cache handles nested repository changes' '
 		echo "?? nested/" >../expect &&
 		test_cmp ../expect ../actual &&
 		rm -rf nested/.git &&
+		git ls-files --others --exclude-standard >../actual &&
+		echo nested/file >../expect &&
+		test_cmp ../expect ../actual &&
 		git status -uall --porcelain >../actual &&
 		echo "?? nested/file" >../expect &&
 		test_cmp ../expect ../actual &&
@@ -1048,4 +1051,98 @@ test_expect_success 'filtered status retains complete untracked listings' '
 	)
 '
 
+test_expect_success 'ls-files expands cached directories and filters wildcard results' '
+	test_create_repo ls-files-cache &&
+	(
+		cd ls-files-cache &&
+		git config core.untrackedCache true &&
+		mkdir tracked untracked empty ignored-only &&
+		touch tracked/pyproject.toml untracked/pyproject.toml &&
+		touch untracked/other ignored-only/file &&
+		echo ignored-only/file >.gitignore &&
+		echo "*.toml selected" >.gitattributes &&
+		git add .gitignore .gitattributes tracked &&
+		git init nested.git &&
+		test-tool chmtime =-300 . tracked untracked empty ignored-only &&
+		git status -unormal --porcelain >/dev/null &&
+		# Complete the partial listing, then reuse it without opening directories.
+		for opened in 1 0
+		do
+			: >"$TRASH_DIRECTORY/ls-files.trace" &&
+			GIT_TRACE2_PERF="$TRASH_DIRECTORY/ls-files.trace" \
+				git ls-files --cached --others --exclude-standard -z \
+				-- "**/pyproject.toml" >../actual &&
+			printf "%s\0" untracked/pyproject.toml tracked/pyproject.toml \
+				>../expect &&
+			test_cmp ../expect ../actual &&
+			test_grep "read_directo.*opendir:$opened\$" \
+				"$TRASH_DIRECTORY/ls-files.trace" &&
+			test_grep "read_directo.*gitignore-invalidation:0\$" \
+				"$TRASH_DIRECTORY/ls-files.trace" || return 1
+		done &&
+		for pathspec in "*.git/" ":(glob)**/*.toml" \
+			":(exclude)untracked/" ":(attr:selected)**/*.toml"
+		do
+			GIT_DISABLE_UNTRACKED_CACHE=1 git ls-files --others \
+				--exclude-standard -- "$pathspec" >../expect &&
+			git ls-files --others --exclude-standard \
+				-- "$pathspec" >../actual &&
+			test_cmp ../expect ../actual || return 1
+		done
+	)
+'
+
+test_expect_success 'ls-files cache is reused after status -unormal' '
+	test_create_repo persistent-cache &&
+	(
+		cd persistent-cache &&
+		mkdir tracked untracked &&
+		touch tracked/a tracked/b untracked/a untracked/b &&
+		git add tracked &&
+		git commit -m initial &&
+		git config core.untrackedCache true &&
+		test-tool chmtime =-300 . tracked untracked &&
+		git ls-files --others --exclude-standard >../actual &&
+		printf "%s\n" untracked/a untracked/b >../expect &&
+		test_cmp ../expect ../actual &&
+		git status --porcelain >../actual &&
+		echo "?? untracked/" >../status-expect &&
+		test_cmp ../status-expect ../actual &&
+		GIT_TRACE2_PERF="$TRASH_DIRECTORY/persistent.trace" \
+			git ls-files --others --exclude-standard >../actual &&
+		test_cmp ../expect ../actual &&
+		test_grep "read_directo.*opendir:0\$" "$TRASH_DIRECTORY/persistent.trace"
+	)
+'
+
+test_expect_success 'ls-files respects optional locks and a busy index lock' '
+	test_when_finished "rm -f persistent-cache/.git/index.lock" &&
+	(
+		cd persistent-cache &&
+		touch untracked/new &&
+		cp .git/index ../saved-index &&
+		git --no-optional-locks ls-files --others --exclude-standard >../actual &&
+		test_cmp_bin ../saved-index .git/index &&
+		printf "%s\n" untracked/a untracked/b untracked/new >../expect &&
+		test_cmp ../expect ../actual &&
+		touch .git/index.lock &&
+		git ls-files --others --exclude-standard >../actual &&
+		test_cmp ../expect ../actual &&
+		test_cmp_bin ../saved-index .git/index
+	)
+'
+
+test_expect_success 'ls-files does not write the index with a pathspec prefix or --with-tree' '
+	(
+		cd persistent-cache &&
+		cp .git/index ../saved-index &&
+		git ls-files --cached --others --exclude-standard -- tracked/a >../actual &&
+		test_cmp_bin ../saved-index .git/index &&
+		git rm --cached tracked/b &&
+		cp .git/index ../saved-index &&
+		git ls-files --cached --others --exclude-standard --with-tree=HEAD >../actual &&
+		test_cmp_bin ../saved-index .git/index
+	)
+'
+
 test_done
diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh
index 93973ed25a..8feca101ab 100755
--- a/t/t7519-status-fsmonitor.sh
+++ b/t/t7519-status-fsmonitor.sh
@@ -477,4 +477,48 @@ test_expect_success 'status succeeds with sparse index' '
 	)
 '
 
+test_expect_success UNTRACKED_CACHE 'ls-files saves the fsmonitor token with index.skipHash' '
+	test_create_repo ls-files-fsmonitor &&
+	(
+		# index.skipHash gives shared indexes a null OID, so they cannot
+		# be reloaded.
+		sane_unset GIT_TEST_SPLIT_INDEX &&
+		cd ls-files-fsmonitor &&
+		touch tracked &&
+		git add tracked &&
+		git commit -m initial &&
+		git config core.untrackedCache true &&
+		git config index.skipHash true &&
+		git config core.fsmonitor .git/hooks/fsmonitor-test &&
+		test_hook --setup fsmonitor-test <<-\EOF &&
+		printf "initial-token\0/\0"
+		EOF
+		mkdir untracked &&
+		touch untracked/first &&
+		git status --porcelain >/dev/null &&
+		test_hook --clobber fsmonitor-test <<-\EOF &&
+		test -f .git/index.lock || exit 1
+		printf "%s\n" "$2" >.git/query-token
+		printf "next-token\0"
+		if test "$2" != next-token
+		then
+			printf "untracked/second\0"
+		fi
+		EOF
+		touch untracked/second &&
+		git ls-files --others --exclude-standard >../actual &&
+		printf "%s\n" untracked/first untracked/second >../expect &&
+		test_cmp ../expect ../actual &&
+		echo initial-token >../token-expect &&
+		test_cmp ../token-expect .git/query-token &&
+		GIT_TRACE2_PERF="$TRASH_DIRECTORY/ls-files-fsmonitor.trace" \
+			git ls-files --others --exclude-standard >../actual &&
+		test_cmp ../expect ../actual &&
+		echo next-token >../token-expect &&
+		test_cmp ../token-expect .git/query-token &&
+		test_grep "read_directo.*opendir:0\$" \
+			"$TRASH_DIRECTORY/ls-files-fsmonitor.trace"
+	)
+'
+
 test_done
-- 
2.56.0.rc0.807.ga0c0929ce1.frankengit

Back to recent threads

[PATCH 0/2] ls-files: reuse the untracked cache | The Git List