Repository: iterative/dvc
Pull request: Fail on sync when there is no match for glob.
Closes #7492 

* [x] ❗ I have followed the [Contributing to DVC](https://dvc.org/doc/user-guide/contributing/core) checklist.

* [x] 📖 If this PR requires [documentation](https://dvc.org/doc) updates, I have created a separate PR (or issue, at least) in [dvc.org](https://github.com/iterative/dvc.org) and linked it here.


Problem statement:
push/pull: globs that match no targets should fail
When using `--glob` with `push` or `pull`, a glob that matches no targets will instead act on all targets. In other words, `dvc push/pull --glob 'nomatches*'` will be the same as `dvc push/pull/repro` without any target argument.

```
$ mkdir repo
$ mkdir dvcremote
$ cd repo
$ git init
Initialized empty Git repository in /private/tmp/glob/repo/.git/
$ dvc init -q
$ dvc remote add -d default ../dvcremote
Setting 'default' as a default remote.
$ echo foo > foo
$ dvc add foo
100% Adding...|████████████████████████████████████████████████|1/1 [00:00, 52.98file/s]

To track the changes with git, run:

    git add foo.dvc .gitignore

To enable auto staging, run:

        dvc config core.autostage true
$ dvc push --glob 'z*'
1 file pushed
$ rm foo
$ dvc pull --glob 'z*'
A       foo
1 file added
```

I would expect the commands to fail or push/pull nothing if there are no `--glob` matches.

Hints from issue discussion:
See https://github.com/iterative/dvc/issues/7462#issuecomment-1070954669
I have created https://github.com/iterative/dvc/pull/7687 to raise an exception when there is no match for glob in push/pull.

Commit message: Update tests/func/test_data_cloud.py

Co-authored-by: David de la Iglesia Castro <daviddelaiglesiacastro@gmail.com>

Patch to review:
diff --git a/dvc/exceptions.py b/dvc/exceptions.py
index 127d46362d..26afde5858 100644
--- a/dvc/exceptions.py
+++ b/dvc/exceptions.py
@@ -349,3 +349,9 @@ def __init__(self, dep, a, b):
 class PrettyDvcException(DvcException):
     def __pretty_exc__(self, **kwargs):
         """Print prettier exception message."""
+
+
+class GlobDoesNotMatchError(DvcException):
+    def __init__(self, glob):
+        msg = f"Glob {glob} has no matches."
+        super().__init__(msg)
diff --git a/dvc/utils/__init__.py b/dvc/utils/__init__.py
index 81157ed997..923a57e534 100644
--- a/dvc/utils/__init__.py
+++ b/dvc/utils/__init__.py
@@ -476,17 +476,24 @@ def is_exec(mode):
 
 
 def glob_targets(targets, glob=True, recursive=True):
+    from dvc.exceptions import GlobDoesNotMatchError
+
     if not glob:
         return targets
 
     from glob import iglob
 
-    return [
+    results = [
         exp_target
         for target in targets
         for exp_target in iglob(target, recursive=recursive)
     ]
 
+    if not results:
+        raise GlobDoesNotMatchError(targets)
+
+    return results
+
 
 def error_handler(func):
     def wrapper(*args, **kwargs):
diff --git a/tests/func/test_add.py b/tests/func/test_add.py
index 610710b482..684991517b 100644
--- a/tests/func/test_add.py
+++ b/tests/func/test_add.py
@@ -16,6 +16,7 @@
 from dvc.dvcfile import DVC_FILE_SUFFIX
 from dvc.exceptions import (
     DvcException,
+    GlobDoesNotMatchError,
     InvalidArgumentError,
     OutputDuplicationError,
     OverlappingOutputPathsError,
@@ -292,6 +293,11 @@ def test_add_filtered_files_in_dir(
         assert stage.outs[0].def_path in expected_def_paths
 
 
+def test_add_glob_no_result_exception(dvc):
+    with pytest.raises(GlobDoesNotMatchError):
+        dvc.add(["invalid*"], glob=True)
+
+
 class TestAddExternal(TestAdd):
     @pytest.fixture
     def hash_name(self):
diff --git a/tests/func/test_data_cloud.py b/tests/func/test_data_cloud.py
index eea0e549b4..dd1a4f905a 100644
--- a/tests/func/test_data_cloud.py
+++ b/tests/func/test_data_cloud.py
@@ -8,6 +8,7 @@
 import dvc as dvc_module
 from dvc.cli import main
 from dvc.data.db.local import LocalObjectDB
+from dvc.exceptions import GlobDoesNotMatchError
 from dvc.external_repo import clean_repos
 from dvc.objects.db import ObjectDB
 from dvc.stage.exceptions import StageNotFound
@@ -570,3 +571,13 @@ def test_target_remote(tmp_dir, dvc, make_remote):
         "6b18131dc289fd37006705affe961ef8.dir",
         "b8a9f715dbb64fd5c56e7783c6820a61",
     }
+
+
+def test_push_pull_glob_no_match(
+    tmp_dir, dvc,
+):
+    with pytest.raises(GlobDoesNotMatchError):
+        dvc.push(targets="b*", glob=True)
+
+    with pytest.raises(GlobDoesNotMatchError):
+        dvc.pull(targets="b*", glob=True)


Code review comments:
diff_hunk: @@ -349,3 +349,9 @@ def __init__(self, dep, a, b):
 class PrettyDvcException(DvcException):
     def __pretty_exc__(self, **kwargs):
         """Print prettier exception message."""
+
+
+class GlobDoesNotMatchError(DvcException):
+    def __init__(self, glob):
+        msg = f"Glob {glob} has no matches."
+        super().__init__(msg)
path: dvc/exceptions.py
text: @user1:
Let's keep the exception in `dvc/utils/__init__.py`, closer to where it is used.

@author:
Importing `DvcException` at top for custom exception raises circular import error. I have used `DvcException` itself.