Repository: vcs-python/vcspull

Issue:
`$ vcspull sync`: Print message if repo not in config
```console
$ vcspull sync repo-not-in-config
```

Should notify user repo not found

Comments:

Patch:
diff --git a/CHANGES b/CHANGES
index 827bc78..76b3566 100644
--- a/CHANGES
+++ b/CHANGES
@@ -24,6 +24,10 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force
 - Refreshed logo
 - `vcspull sync`:
 
+  - Terms with no match in config will show a notice (#394)
+
+    > No repo found in config(s) for "non_existent_repo"
+
   - Syncing will now skip to the next repos if an error is encountered
 
   - Learned `--exit-on-error` / `-x`
diff --git a/docs/cli/sync.md b/docs/cli/sync.md
index 5153ef9..1cc636f 100644
--- a/docs/cli/sync.md
+++ b/docs/cli/sync.md
@@ -6,9 +6,35 @@
 
 ## Error handling
 
+### Repos not found in config
+
+As of 1.13.x, if you enter a repo term (or terms) that aren't found throughout
+your configurations, it will show a warning:
+
+```console
+$ vcspull sync non_existent_repo
+No repo found in config(s) for "non_existent_repo"
+```
+
+```console
+$ vcspull sync non_existent_repo existing_repo
+No repo found in config(s) for "non_existent_repo"
+```
+
+```console
+$ vcspull sync non_existent_repo existing_repo another_repo_not_in_config
+No repo found in config(s) for "non_existent_repo"
+No repo found in config(s) for "another_repo_not_in_config"
+```
+
+Since syncing terms are treated as a filter rather than a lookup, the message is
+considered a warning, so will not exit even if `--exit-on-error` flag is used.
+
+### Syncing
+
 As of 1.13.x, vcspull will continue to the next repo if an error is encountered when syncing multiple repos.
 
-To imitate the old behavior, use `--exit-on-error` / `-x`:
+To imitate the old behavior, the `--exit-on-error` / `-x` flag:
 
 ```console
 $ vcspull sync --exit-on-error grako django
diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py
index 99280f1..f91328e 100644
--- a/src/vcspull/cli/sync.py
+++ b/src/vcspull/cli/sync.py
@@ -60,6 +60,7 @@ def clamp(n, _min, _max):
 
 
 EXIT_ON_ERROR_MSG = "Exiting via error (--exit-on-error passed)"
+NO_REPOS_FOR_TERM_MSG = 'No repo found in config(s) for "{name}"'
 
 
 @click.command(name="sync")
@@ -100,6 +101,9 @@ def sync(repo_terms, config, exit_on_error: bool) -> None:
                 name = repo_term
 
             # collect the repos from the config files
+            found = filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name)
+            if len(found) == 0:
+                click.echo(NO_REPOS_FOR_TERM_MSG.format(name=name))
             found_repos.extend(
                 filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name)
             )


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index cd6a808..00fd9f5 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -9,15 +9,94 @@ from click.testing import CliRunner
 
 from libvcs.sync.git import GitSync
 from vcspull.cli import cli
-from vcspull.cli.sync import EXIT_ON_ERROR_MSG
+from vcspull.cli.sync import EXIT_ON_ERROR_MSG, NO_REPOS_FOR_TERM_MSG
 
+if t.TYPE_CHECKING:
+    from typing_extensions import TypeAlias
+
+    ExpectedOutput: TypeAlias = t.Optional[t.Union[str, t.List[str]]]
+
+
+class SyncCLINonExistentRepo(t.NamedTuple):
+    test_id: str
+    sync_args: list[str]
+    expected_exit_code: int
+    expected_in_output: "ExpectedOutput" = None
+    expected_not_in_output: "ExpectedOutput" = None
+
+
+SYNC_CLI_EXISTENT_REPO_FIXTURES = [
+    SyncCLINonExistentRepo(
+        test_id="exists",
+        sync_args=["my_git_project"],
+        expected_exit_code=0,
+        expected_in_output="Already on 'master'",
+        expected_not_in_output=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"),
+    ),
+    SyncCLINonExistentRepo(
+        test_id="non-existent-only",
+        sync_args=["this_isnt_in_the_config"],
+        expected_exit_code=0,
+        expected_in_output=NO_REPOS_FOR_TERM_MSG.format(name="this_isnt_in_the_config"),
+    ),
+    SyncCLINonExistentRepo(
+        test_id="non-existent-mixed",
+        sync_args=["this_isnt_in_the_config", "my_git_project", "another"],
+        expected_exit_code=0,
+        expected_in_output=[
+            NO_REPOS_FOR_TERM_MSG.format(name="this_isnt_in_the_config"),
+            NO_REPOS_FOR_TERM_MSG.format(name="another"),
+        ],
+        expected_not_in_output=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"),
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    list(SyncCLINonExistentRepo._fields),
+    SYNC_CLI_EXISTENT_REPO_FIXTURES,
+    ids=[test.test_id for test in SYNC_CLI_EXISTENT_REPO_FIXTURES],
+)
+def test_sync_cli_repo_term_non_existent(
+    user_path: pathlib.Path,
+    config_path: pathlib.Path,
+    tmp_path: pathlib.Path,
+    git_repo: GitSync,
+    test_id: str,
+    sync_args: list[str],
+    expected_exit_code: int,
+    expected_in_output: "ExpectedOutput",
+    expected_not_in_output: "ExpectedOutput",
+) -> None:
+    config = {
+        "~/github_projects/": {
+            "my_git_project": {
+                "url": f"git+file://{git_repo.dir}",
+                "remotes": {"test_remote": f"git+file://{git_repo.dir}"},
+            },
+        }
+    }
+    yaml_config = config_path / ".vcspull.yaml"
+    yaml_config_data = yaml.dump(config, default_flow_style=False)
+    yaml_config.write_text(yaml_config_data, encoding="utf-8")
 
-def test_sync_cli_non_existent(tmp_path: pathlib.Path) -> None:
     runner = CliRunner()
     with runner.isolated_filesystem(temp_dir=tmp_path):
-        result = runner.invoke(cli, ["sync", "hi"])
-        assert result.exit_code == 0
-        assert "" in result.output
+        result = runner.invoke(cli, ["sync", *sync_args])
+        assert result.exit_code == expected_exit_code
+        output = "".join(list(result.output))
+
+        if expected_in_output is not None:
+            if isinstance(expected_in_output, str):
+                expected_in_output = [expected_in_output]
+            for needle in expected_in_output:
+                assert needle in output
+
+        if expected_not_in_output is not None:
+            if isinstance(expected_not_in_output, str):
+                expected_not_in_output = [expected_not_in_output]
+            for needle in expected_not_in_output:
+                assert needle not in output
 
 
 def test_sync(
@@ -51,12 +130,6 @@ def test_sync(
         assert "my_git_repo" in output
 
 
-if t.TYPE_CHECKING:
-    from typing_extensions import TypeAlias
-
-    ExpectedOutput: TypeAlias = t.Optional[t.Union[str, t.List[str]]]
-
-
 class SyncBrokenFixture(t.NamedTuple):
     test_id: str
     sync_args: list[str]
@@ -86,25 +159,25 @@ SYNC_BROKEN_REPO_FIXTURES = [
     ),
     SyncBrokenFixture(
         test_id="normal-first-broken",
-        sync_args=["non_existent_repo", "my_git_repo"],
+        sync_args=["my_git_repo_not_found", "my_git_repo"],
         expected_exit_code=0,
         expected_not_in_output=EXIT_ON_ERROR_MSG,
     ),
     SyncBrokenFixture(
         test_id="normal-last-broken",
-        sync_args=["my_git_repo", "non_existent_repo"],
+        sync_args=["my_git_repo", "my_git_repo_not_found"],
         expected_exit_code=0,
         expected_not_in_output=EXIT_ON_ERROR_MSG,
     ),
     SyncBrokenFixture(
         test_id="exit-on-error--exit-on-error-first-broken",
-        sync_args=["non_existent_repo", "my_git_repo", "--exit-on-error"],
+        sync_args=["my_git_repo_not_found", "my_git_repo", "--exit-on-error"],
         expected_exit_code=1,
         expected_in_output=EXIT_ON_ERROR_MSG,
     ),
     SyncBrokenFixture(
         test_id="exit-on-error--x-first-broken",
-        sync_args=["non_existent_repo", "my_git_repo", "-x"],
+        sync_args=["my_git_repo_not_found", "my_git_repo", "-x"],
         expected_exit_code=1,
         expected_in_output=EXIT_ON_ERROR_MSG,
         expected_not_in_output="master",
@@ -114,13 +187,13 @@ SYNC_BROKEN_REPO_FIXTURES = [
     #
     SyncBrokenFixture(
         test_id="exit-on-error--exit-on-error-last-broken",
-        sync_args=["my_git_repo", "non_existent_repo", "-x"],
+        sync_args=["my_git_repo", "my_git_repo_not_found", "-x"],
         expected_exit_code=1,
         expected_in_output=[EXIT_ON_ERROR_MSG, "Already on 'master'"],
     ),
     SyncBrokenFixture(
         test_id="exit-on-error--x-last-item",
-        sync_args=["my_git_repo", "non_existent_repo", "--exit-on-error"],
+        sync_args=["my_git_repo", "my_git_repo_not_found", "--exit-on-error"],
         expected_exit_code=1,
         expected_in_output=[EXIT_ON_ERROR_MSG, "Already on 'master'"],
     ),
@@ -157,7 +230,7 @@ def test_sync_broken(
                     "url": f"git+file://{git_repo.dir}",
                     "remotes": {"test_remote": f"git+file://{git_repo.dir}"},
                 },
-                "non_existent_repo": {
+                "my_git_repo_not_found": {
                     "url": "git+file:///dev/null",
                 },
             }