From: Muhammed Dilshad A Date: Fri, 09 Oct 2026 14:00:05 GMT Subject: [PATCH] unpack-trees: protect untracked files in sparse checkouts Message-ID: In-Reply-To: In a sparse checkout, switching to a branch that adds a file over an untracked local path can warn and continue, replacing the local contents. Ordinary checkout refuses the same switch. merged_entry() defers the check for new paths until sparse patterns are known. The deferred check reports a sparsity warning, and unpack_trees() clears its failure before updating the working tree. Use the ordinary untracked overwrite error for that check. Finish checking all new entries before applying sparsity changes, and abort through the existing failure path if any conflict is found. Keep advisory warnings for sparsity changes to existing entries. Add a regression comparing full checkout, sparse checkout, and sparse-index checkout. Check both conflicting files, complete diagnostics, HEAD, an unrelated tracked file, and an existing staged change. Adjust the warning test to use existing skipped entries and verify that their local contents survive. Reported-by: Webstrand Link: https://lore.kernel.org/git/CACm1TQd5b9tX368LrsD26Q6tm_mzdi1nwGtGd2V1jwYW+r2c1A@mail.gmail.com/ Signed-off-by: Muhammed Dilshad A --- t/t1011-read-tree-sparse-checkout.sh | 16 ++++++--- t/t1092-sparse-checkout-compatibility.sh | 41 ++++++++++++++++++++++++ unpack-trees.c | 9 +++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/t/t1011-read-tree-sparse-checkout.sh b/t/t1011-read-tree-sparse-checkout.sh index 93244ee134..54a7258b03 100755 --- a/t/t1011-read-tree-sparse-checkout.sh +++ b/t/t1011-read-tree-sparse-checkout.sh @@ -263,12 +263,15 @@ test_expect_success 'read-tree --reset removes outside worktree' ' ' test_expect_success 'print warnings when some worktree updates disabled' ' - echo sub >.git/info/sparse-checkout && - git checkout -f init && + echo init.t >.git/info/sparse-checkout && + git checkout -f top && mkdir sub && - touch sub/added sub/addedtoo && + test_write_lines local >sub/added && + test_write_lines local >sub/addedtoo && + echo sub >.git/info/sparse-checkout && + # Keep skip-worktree bits on the materialized paths to test the warning. # Use -q to suppress "Previous HEAD position" and "Head is now at" msgs - git checkout -q top 2>actual && + git -c sparse.expectFilesOutsideOfPatterns=true checkout -q top 2>actual && cat >expected <<\EOF && warning: The following paths were already present and thus not updated despite sparse patterns: sub/added @@ -276,7 +279,10 @@ warning: The following paths were already present and thus not updated despite s After fixing the above paths, you may want to run `git sparse-checkout reapply`. EOF - test_cmp expected actual + test_cmp expected actual && + test_write_lines local >expected-content && + test_cmp expected-content sub/added && + test_cmp expected-content sub/addedtoo ' test_expect_success 'checkout without --ignore-skip-worktree-bits' ' diff --git a/t/t1092-sparse-checkout-compatibility.sh b/t/t1092-sparse-checkout-compatibility.sh index 05b54062b3..0958af35c4 100755 --- a/t/t1092-sparse-checkout-compatibility.sh +++ b/t/t1092-sparse-checkout-compatibility.sh @@ -486,6 +486,47 @@ test_expect_success 'checkout with modified sparse directory' ' test_all_match git checkout base ' +test_expect_success 'checkout protects untracked files inside sparse cone' ' + init_repos && + git -C initial-repo rev-parse base >expect-head && + test_write_lines local >expect-untracked && + test_write_lines a >expect-tracked && + test_write_lines staged >expect-staged && + test_write_lines refs/heads/base >expect-branch && + for repo in full-checkout sparse-checkout sparse-index + do + ( + cd "$repo" && + git checkout -b new-file && + test_write_lines incoming >deep/new-file && + test_write_lines incoming >deep/other-file && + test_write_lines changed >deep/a && + git add deep/new-file deep/other-file deep/a && + git commit -m "add a file and change a tracked file" && + git checkout base && + test_write_lines local >deep/new-file && + test_write_lines local >deep/other-file && + test_write_lines staged >a && + git add a && + git write-tree >"../$repo-index-before" + ) || return 1 + done && + test_all_match test_must_fail git checkout new-file && + for repo in full-checkout sparse-checkout sparse-index + do + git -C "$repo" rev-parse HEAD >actual-head && + test_cmp expect-head actual-head && + git -C "$repo" symbolic-ref HEAD >actual-branch && + test_cmp expect-branch actual-branch && + test_cmp expect-untracked "$repo/deep/new-file" && + test_cmp expect-untracked "$repo/deep/other-file" && + test_cmp expect-tracked "$repo/deep/a" && + test_cmp expect-staged "$repo/a" && + git -C "$repo" write-tree >actual-index && + test_cmp "$repo-index-before" actual-index || return 1 + done +' + test_expect_success 'checkout orphan then non-orphan' ' init_repos && diff --git a/unpack-trees.c b/unpack-trees.c index 1802809ad3..f763f284a6 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -2058,8 +2058,15 @@ int unpack_trees(unsigned len, struct tree_desc *t, struct unpack_trees_options * correct CE_NEW_SKIP_WORKTREE */ if (ce->ce_flags & CE_ADDED && - verify_absent(ce, WARNING_SPARSE_ORPHANED_NOT_OVERWRITTEN, o)) + verify_absent(ce, + ERROR_WOULD_LOSE_UNTRACKED_OVERWRITTEN, o)) ret = 1; + } + if (ret) + goto return_failed; + + for (i = 0; i < o->internal.result.cache_nr; i++) { + struct cache_entry *ce = o->internal.result.cache[i]; if (apply_sparse_checkout(&o->internal.result, ce, o)) ret = 1; base-commit: 6de20f6092dcf9bdb1c8efe03db4b70c82b423dd -- 2.55.0