diff --git a/dvc/repo/index.py b/dvc/repo/index.py index f28db66b6d..a3a81f2f73 100644 --- a/dvc/repo/index.py +++ b/dvc/repo/index.py @@ -966,12 +966,17 @@ 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, 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) 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):