From eabf9ac5b7dc4a960a9f0b10cd0d32e457a9354a Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:18:58 +0530 Subject: [PATCH 1/2] fix: fall back to full index when pull/checkout targets mix .dvc files and paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `index_from_targets` builds a merged per-target index when every target is a stage or `.dvc` file, otherwise it falls back to the full repo index. The fallback signal is `index is None`, but `index` is also the loop variable that holds each per-target index. When a target list mixes a `.dvc`-file target (which parses) with a granular path inside a tracked directory (which raises `StageFileDoesNotExistError`), the loop set `index` from the `.dvc` target before the granular target failed, so the `except` left a *partial* index in place. The `index is None` fallback was then skipped, and that partial index — which knows nothing about the directory — was used with the full target list. Consequences (both from #11075): - fresh-clone state: the granular target is silently skipped (never checked out); - if the tracked directory has drifted, `checkout`'s `_check_can_delete` looks up a key absent from the partial index's `storage_map` and dies with an uncaught `KeyError`/`StorageKeyError`. All-data-path target lists already worked, but only because the first target fails immediately and leaves `index is None`. Reset `index = None` in the `except` so a partial parse falls back to the full repo index with the original targets, matching that working path. Added a regression test covering both the silent-skip and the drift-crash cases. Fixes #11075 --- dvc/repo/index.py | 8 +++++++- tests/func/test_data_cloud.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/dvc/repo/index.py b/dvc/repo/index.py index f28db66b6d..b03a4cb224 100644 --- a/dvc/repo/index.py +++ b/dvc/repo/index.py @@ -972,7 +972,13 @@ def index_from_targets( index = Index(repo, stages=list(stages)) indexes.append(index) except (StageFileDoesNotExistError, StageNotFound): - pass + # A target that is not a stage/.dvc file (e.g. a granular path inside a + # tracked directory) aborts the per-target merge partway through. Reset + # to fall back to the full repo index with the original targets, rather + # than keeping the partial index built only from the targets parsed + # before the failure — using that partial index with the full targets + # list silently drops the unparsed targets and can crash checkout (#11075). + index = None else: index = Index.from_indexes(repo, indexes) targets = None diff --git a/tests/func/test_data_cloud.py b/tests/func/test_data_cloud.py index 8e4cfdd7e1..d9faf8ea54 100644 --- a/tests/func/test_data_cloud.py +++ b/tests/func/test_data_cloud.py @@ -222,6 +222,44 @@ def test_verify_hashes(tmp_dir, scm, dvc, mocker, tmp_path_factory, local_remote assert hash_spy.call_count == 10 +def test_pull_mixed_dvcfile_and_granular_targets(tmp_dir, scm, dvc, local_remote): + """Regression test for #11075. + + A ``pull`` target list that mixes a ``.dvc``-file target with a granular path + inside a tracked directory used to build a partial index from only the + ``.dvc`` target while still filtering against the full target list, so the + granular target was silently skipped (or, when the directory had drifted, + ``checkout`` crashed with an uncaught ``KeyError``). + """ + tmp_dir.dvc_gen( + {"datadir": {"f1.txt": "one", "f2.txt": "two", "f3.txt": "three"}}, + commit="add dir", + ) + tmp_dir.dvc_gen("single.csv", "single", commit="add single") + dvc.push() + + mixed_targets = ["single.csv.dvc", join("datadir", "f1.txt")] + + # Fresh-clone state: the granular target must be checked out, not skipped. + remove("datadir") + remove("single.csv") + dvc.cache.local.clear() + + dvc.pull(mixed_targets) + + assert (tmp_dir / "datadir" / "f1.txt").read_text() == "one" + assert (tmp_dir / "single.csv").read_text() == "single" + + # A drifted (untracked) file inside the tracked directory must not turn the + # mixed-target pull into an uncaught KeyError during checkout. + dvc.pull() + (tmp_dir / "datadir" / "extra-drift.txt").write_text("extra") + + dvc.pull(mixed_targets) # must not raise + + assert (tmp_dir / "datadir" / "f1.txt").read_text() == "one" + + # @pytest.mark.flaky(reruns=3) @pytest.mark.parametrize("erepo_type", ["git_dir", "erepo_dir"]) def test_pull_git_imports(request, tmp_dir, dvc, scm, erepo_type): From c9d81a68c062aed3c398c29f3c8ef82f70b2dbb9 Mon Sep 17 00:00:00 2001 From: adarshsm <24850536+adarshsm@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:48:23 +0530 Subject: [PATCH 2/2] refactor: name the per-target index separately from the fallback index served as both the loop variable and the "no per-target index" signal, so a target that failed to parse left a partial index behind and skipped the fallback. Naming the per-target index idx keeps index as the fallback signal only, which removes the need to reset it in the handler. --- dvc/repo/index.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/dvc/repo/index.py b/dvc/repo/index.py index b03a4cb224..a3a81f2f73 100644 --- a/dvc/repo/index.py +++ b/dvc/repo/index.py @@ -966,19 +966,18 @@ def index_from_targets( continue file, name = parse_target(target) if file and not name: - index = Index.from_file(repo, file) + idx = Index.from_file(repo, file) else: stages = repo.stage.collect(target) - index = Index(repo, stages=list(stages)) - indexes.append(index) + idx = Index(repo, stages=list(stages)) + indexes.append(idx) except (StageFileDoesNotExistError, StageNotFound): - # A target that is not a stage/.dvc file (e.g. a granular path inside a - # tracked directory) aborts the per-target merge partway through. Reset - # to fall back to the full repo index with the original targets, rather - # than keeping the partial index built only from the targets parsed - # before the failure — using that partial index with the full targets - # list silently drops the unparsed targets and can crash checkout (#11075). - index = None + # A target that is not a stage/.dvc file, such as a granular path + # inside a tracked directory, aborts the per-target merge partway + # through. Fall back to the full repo index with the original + # targets: a partial index built only from the targets parsed + # before the failure silently drops the rest (#11075). + pass else: index = Index.from_indexes(repo, indexes) targets = None