[PATCH] read-cache: avoid sparse-index expansion for unborn HEAD

Sahitya Chandra <[email protected]> Mon, 3 Aug 2026 02:58:26 +0530
Newsgroups org.kernel.vger.git
Message-ID <[email protected]>
repo_index_has_changes() normally checks whether the index differs from
a tree by passing that tree to the diff machinery. When no tree is
passed, it tries to use HEAD for that comparison.

If HEAD does not resolve, as on an unborn branch, the function falls
back to walking the index directly. With a sparse index, however, sparse
directory entries may stand in for many paths, so the fallback first
expands the index before reporting the changed paths.

That expansion is unnecessary. An unborn HEAD is equivalent for this
check to comparing the index against the empty tree: every index entry
is new relative to that tree.

Use the empty tree when HEAD cannot be resolved. This keeps the
unborn-branch case on the same diff code path as the normal
tree-comparison case, avoiding the sparse-index expansion while still
letting callers see paths inside sparse directories.

Teach test-tool read-cache to exercise repo_index_has_changes(), and
add a t1092 check that the unborn-branch case reports paths inside a
sparse directory without expanding the index.

Signed-off-by: Sahitya Chandra <[email protected]>
---
 read-cache.c                             | 46 ++++++++++--------------
 t/helper/test-read-cache.c               | 19 ++++++++++
 t/t1092-sparse-checkout-compatibility.sh | 16 +++++++++
 3 files changed, 53 insertions(+), 28 deletions(-)

diff --git a/read-cache.c b/read-cache.c
index 6c449f393d..88ee9ba935 100644
--- a/read-cache.c
+++ b/read-cache.c
@@ -2505,39 +2505,29 @@ int repo_index_has_changes(struct repository *repo,
 			   struct tree *tree,
 			   struct strbuf *sb)
 {
-	struct index_state *istate = repo->index;
+	struct diff_options opt;
 	struct object_id cmp;
 	int i;
 
 	if (tree)
 		cmp = tree->object.oid;
-	if (tree || !repo_get_oid_tree(repo, "HEAD", &cmp)) {
-		struct diff_options opt;
-
-		repo_diff_setup(repo, &opt);
-		opt.flags.exit_with_status = 1;
-		if (!sb)
-			opt.flags.quick = 1;
-		diff_setup_done(&opt);
-		do_diff_cache(&cmp, &opt);
-		diffcore_std(&opt);
-		for (i = 0; sb && i < diff_queued_diff.nr; i++) {
-			if (i)
-				strbuf_addch(sb, ' ');
-			strbuf_addstr(sb, diff_queued_diff.queue[i]->two->path);
-		}
-		diff_flush(&opt);
-		return opt.flags.has_changes != 0;
-	} else {
-		/* TODO: audit for interaction with sparse-index. */
-		ensure_full_index(istate);
-		for (i = 0; sb && i < istate->cache_nr; i++) {
-			if (i)
-				strbuf_addch(sb, ' ');
-			strbuf_addstr(sb, istate->cache[i]->name);
-		}
-		return !!istate->cache_nr;
-	}
+	else if (repo_get_oid_tree(repo, "HEAD", &cmp))
+		oidcpy(&cmp, repo->hash_algo->empty_tree);
+
+	repo_diff_setup(repo, &opt);
+	opt.flags.exit_with_status = 1;
+	if (!sb)
+		opt.flags.quick = 1;
+	diff_setup_done(&opt);
+	do_diff_cache(&cmp, &opt);
+	diffcore_std(&opt);
+	for (i = 0; sb && i < diff_queued_diff.nr; i++) {
+		if (i)
+			strbuf_addch(sb, ' ');
+		strbuf_addstr(sb, diff_queued_diff.queue[i]->two->path);
+	}
+	diff_flush(&opt);
+	return opt.flags.has_changes != 0;
 }
 
 static int write_index_ext_header(struct hashfile *f,
diff --git a/t/helper/test-read-cache.c b/t/helper/test-read-cache.c
index 6b08ba8f07..ee629fbc69 100644
--- a/t/helper/test-read-cache.c
+++ b/t/helper/test-read-cache.c
@@ -4,6 +4,7 @@
 #include "config.h"
 #include "environment.h"
 #include "read-cache-ll.h"
+#include "repo-settings.h"
 #include "repository.h"
 #include "setup.h"
 
@@ -12,6 +13,24 @@ int cmd__read_cache(int argc, const char **argv)
 	int i, cnt = 1;
 	const char *name = NULL;
 
+	if (argc == 2 && !strcmp(argv[1], "--index-has-changes")) {
+		struct strbuf sb = STRBUF_INIT;
+		int ret;
+
+		setup_git_directory(the_repository);
+		repo_config(the_repository, git_default_config, NULL);
+		prepare_repo_settings(the_repository);
+		the_repository->settings.command_requires_full_index = 0;
+
+		repo_read_index(the_repository);
+		ret = repo_index_has_changes(the_repository, NULL, &sb);
+		printf("has_changes=%d\n", ret);
+		if (sb.len)
+			printf("dirty=%s\n", sb.buf);
+		strbuf_release(&sb);
+		return 0;
+	}
+
 	if (argc > 1 && skip_prefix(argv[1], "--print-and-refresh=", &name)) {
 		argc--;
 		argv++;
diff --git a/t/t1092-sparse-checkout-compatibility.sh b/t/t1092-sparse-checkout-compatibility.sh
index 4140c4d8ef..90239a862d 100755
--- a/t/t1092-sparse-checkout-compatibility.sh
+++ b/t/t1092-sparse-checkout-compatibility.sh
@@ -1558,6 +1558,22 @@ test_expect_success 'sparse-index is not expanded' '
 	)
 '
 
+test_expect_success 'sparse-index is not expanded: index has changes on unborn branch' '
+	init_repos &&
+	git -C sparse-index checkout --orphan unborn &&
+	git -C sparse-index ls-files --sparse --stage >cache &&
+	test_grep "^040000 .*	folder1/$" cache &&
+
+	rm -f trace2.txt &&
+	GIT_TRACE2_EVENT="$(pwd)/trace2.txt" GIT_TRACE2_EVENT_NESTING=10 \
+		test-tool -C sparse-index read-cache --index-has-changes \
+		>sparse-index-out 2>sparse-index-error &&
+	test_region ! index ensure_full_index trace2.txt &&
+	test_must_be_empty sparse-index-error &&
+	test_grep "has_changes=1" sparse-index-out &&
+	test_grep "folder1/a" sparse-index-out
+'
+
 test_expect_success 'sparse-index is not expanded: merge conflict in cone' '
 	init_repos &&
 

base-commit: a97fcc37c2bc6340a8d7ce78dedf227aac4e9aa7
-- 
2.43.0