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>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
seen
Tamir Duberstein 2026-09-23 18:26:59 -04:00 committed by Junio C Hamano
parent 18423523c4
commit 913d1e28c7
4 changed files with 245 additions and 168 deletions

View File

@ -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.

217
dir.c
View File

@ -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);


16
dir.h
View File

@ -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 */

View File

@ -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