Repository: vcs-python/vcspull

Issue:
`$ vcspull sync` should not run all repos, should print help
Related: #388, #99

No args should print help.

Syncing all should be:

```console
$ vcspull sync *
```

```console
$ vcspull sync --all
```

Comments:

Patch:
diff --git a/CHANGES b/CHANGES
index 76b3566..c6dbb23 100644
--- a/CHANGES
+++ b/CHANGES
@@ -24,6 +24,29 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force
 - Refreshed logo
 - `vcspull sync`:
 
+  - Empty command will now show help output
+
+    ```console
+    $ vcspull sync
+    Usage: vcspull sync [OPTIONS] [REPO_TERMS]...
+
+    Options:
+    -c, --config PATH Specify config
+    -x, --exit-on-error Exit immediately when encountering an error syncing
+    multiple repos
+    -h, --help Show this message and exit.
+    ```
+
+    To achieve the equivalent behavior of syncing all repos, pass `'*'`:
+
+    ```console
+    $ vcspull sync '*'
+    ```
+
+    Depending on how shell escaping works in your shell setup with [wild card / asterisk], you may not need to quote `*`.
+
+    [wild card / asterisk]: https://tldp.org/LDP/abs/html/special-chars.html#:~:text=wild%20card%20%5Basterisk%5D.
+
   - Terms with no match in config will show a notice (#394)
 
     > No repo found in config(s) for "non_existent_repo"
diff --git a/docs/cli/sync.md b/docs/cli/sync.md
index 1cc636f..81716b9 100644
--- a/docs/cli/sync.md
+++ b/docs/cli/sync.md
@@ -4,6 +4,41 @@
 
 # vcspull sync
 
+## Filtering repos
+
+As of 1.13.x, `$ vcspull sync` with no args passed will show a help dialog:
+
+```console
+$ vcspull sync
+Usage: vcspull sync [OPTIONS] [REPO_TERMS]...
+```
+
+### Sync all repos
+
+Depending on how your terminal works with shell escapes for expands such as the [wild card / asterisk], you may not need to quote `*`.
+
+```console
+$ vcspull sync '*'
+```
+
+[wild card / asterisk]: https://tldp.org/LDP/abs/html/special-chars.html#:~:text=wild%20card%20%5Basterisk%5D.
+
+### Filtering
+
+Filter all repos start with "django-":
+
+```console
+$ vcspull sync 'django-*'
+```
+
+### Multiple terms
+
+Filter all repos start with "django-":
+
+```console
+$ vcspull sync 'django-anymail' 'django-guardian'
+```
+
 ## Error handling
 
 ### Repos not found in config
diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py
index f91328e..47b9c46 100644
--- a/src/vcspull/cli/sync.py
+++ b/src/vcspull/cli/sync.py
@@ -64,6 +64,7 @@ NO_REPOS_FOR_TERM_MSG = 'No repo found in config(s) for "{name}"'
 
 
 @click.command(name="sync")
+@click.pass_context
 @click.argument(
     "repo_terms", type=click.STRING, nargs=-1, shell_complete=get_repo_completions
 )
@@ -83,7 +84,7 @@ NO_REPOS_FOR_TERM_MSG = 'No repo found in config(s) for "{name}"'
     default=False,
     help="Exit immediately when encountering an error syncing multiple repos",
 )
-def sync(repo_terms, config, exit_on_error: bool) -> None:
+def sync(ctx, repo_terms, config, exit_on_error: bool) -> None:
     if config:
         configs = load_configs([config])
     else:
@@ -108,7 +109,8 @@ def sync(repo_terms, config, exit_on_error: bool) -> None:
                 filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name)
             )
     else:
-        found_repos = configs
+        click.echo(ctx.get_help(), color=ctx.color)
+        ctx.exit()
 
     for repo in found_repos:
         try:


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 00fd9f5..4085f77 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -8,6 +8,7 @@ import yaml
 from click.testing import CliRunner
 
 from libvcs.sync.git import GitSync
+from vcspull.__about__ import __version__
 from vcspull.cli import cli
 from vcspull.cli.sync import EXIT_ON_ERROR_MSG, NO_REPOS_FOR_TERM_MSG
 
@@ -99,11 +100,96 @@ def test_sync_cli_repo_term_non_existent(
                 assert needle not in output
 
 
+class SyncFixture(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_REPO_FIXTURES = [
+    # Empty (root command)
+    SyncFixture(
+        test_id="empty",
+        sync_args=[],
+        expected_exit_code=0,
+        expected_in_output=["Options:", "Commands:"],
+    ),
+    # Version
+    SyncFixture(
+        test_id="--version",
+        sync_args=["--version"],
+        expected_exit_code=0,
+        expected_in_output=[__version__, ", libvcs"],
+    ),
+    SyncFixture(
+        test_id="-V",
+        sync_args=["-V"],
+        expected_exit_code=0,
+        expected_in_output=[__version__, ", libvcs"],
+    ),
+    # Help
+    SyncFixture(
+        test_id="--help",
+        sync_args=["--help"],
+        expected_exit_code=0,
+        expected_in_output=["Options:", "Commands:"],
+    ),
+    SyncFixture(
+        test_id="-h",
+        sync_args=["-h"],
+        expected_exit_code=0,
+        expected_in_output=["Options:", "Commands:"],
+    ),
+    # Sync
+    SyncFixture(
+        test_id="sync--empty",
+        sync_args=["sync"],
+        expected_exit_code=0,
+        expected_in_output="Options:",
+        expected_not_in_output="Commands:",
+    ),
+    # Sync: Help
+    SyncFixture(
+        test_id="sync---help",
+        sync_args=["sync", "--help"],
+        expected_exit_code=0,
+        expected_in_output="Options:",
+        expected_not_in_output="Commands:",
+    ),
+    SyncFixture(
+        test_id="sync--h",
+        sync_args=["sync", "-h"],
+        expected_exit_code=0,
+        expected_in_output="Options:",
+        expected_not_in_output="Commands:",
+    ),
+    # Sync: Repo terms
+    SyncFixture(
+        test_id="sync--one-repo-term",
+        sync_args=["sync", "my_git_repo"],
+        expected_exit_code=0,
+        expected_in_output="my_git_repo",
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    list(SyncFixture._fields),
+    SYNC_REPO_FIXTURES,
+    ids=[test.test_id for test in SYNC_REPO_FIXTURES],
+)
 def test_sync(
     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:
     runner = CliRunner()
     with runner.isolated_filesystem(temp_dir=tmp_path):
@@ -124,10 +210,21 @@ def test_sync(
         yaml_config.write_text(yaml_config_data, encoding="utf-8")
 
         # CLI can sync
-        result = runner.invoke(cli, ["sync", "my_git_repo"])
-        assert result.exit_code == 0
+        result = runner.invoke(cli, sync_args)
+        assert result.exit_code == expected_exit_code
         output = "".join(list(result.output))
-        assert "my_git_repo" in 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
 
 
 class SyncBrokenFixture(t.NamedTuple):