diff --git a/src/vcspull/cli/__init__.py b/src/vcspull/cli/__init__.py index de66ef79..7319aaab 100644 --- a/src/vcspull/cli/__init__.py +++ b/src/vcspull/cli/__init__.py @@ -55,7 +55,7 @@ def build_description( ( "sync", [ - 'vcspull sync "*"', + "vcspull sync --all", 'vcspull sync "django-*"', 'vcspull sync --dry-run "*"', 'vcspull sync -f ./myrepos.yaml "*"', @@ -116,7 +116,7 @@ def build_description( ( None, [ - 'vcspull sync "*"', + "vcspull sync --all", 'vcspull sync "django-*"', 'vcspull sync --dry-run "*"', 'vcspull sync -f ./myrepos.yaml "*"', @@ -354,9 +354,9 @@ def cli(_args: list[str] | None = None) -> None: sync_parser, _list_parser, _status_parser, - _search_parser, - _add_parser, - _discover_parser, + search_parser, + add_parser, + discover_parser, _fmt_parser, ) = subparsers args = parser.parse_args(_args) @@ -384,6 +384,7 @@ def cli(_args: list[str] | None = None) -> None: fetch=getattr(args, "fetch", False), offline=getattr(args, "offline", False), verbosity=getattr(args, "verbosity", 0), + sync_all=getattr(args, "sync_all", False), parser=sync_parser, ) elif args.subparser_name == "list": @@ -409,6 +410,9 @@ def cli(_args: list[str] | None = None) -> None: max_concurrent=getattr(args, "max_concurrent", None), ) elif args.subparser_name == "search": + if not args.query_terms: + search_parser.print_help() + return search_repos( query_terms=args.query_terms, config_path=pathlib.Path(args.config) if args.config else None, @@ -425,8 +429,14 @@ def cli(_args: list[str] | None = None) -> None: match_any=getattr(args, "match_any", False), ) elif args.subparser_name == "add": + if not args.repo_path: + add_parser.print_help() + return handle_add_command(args) elif args.subparser_name == "discover": + if not args.scan_dir: + discover_parser.print_help() + return discover_repos( scan_dir_str=args.scan_dir, config_file_path_str=args.config, diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index d979bcfc..40dfc454 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -37,6 +37,8 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None: """ parser.add_argument( "repo_path", + nargs="?", + default=None, help=( "Filesystem path to an existing project. The parent directory " "becomes the workspace unless overridden with --workspace." diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index 39f8053f..6f2a010c 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -124,6 +124,8 @@ def create_discover_subparser(parser: argparse.ArgumentParser) -> None: parser.add_argument( "scan_dir", metavar="PATH", + nargs="?", + default=None, help="Directory to scan for git repositories", ) parser.add_argument( diff --git a/src/vcspull/cli/search.py b/src/vcspull/cli/search.py index 899ce5bd..ad66e1b7 100644 --- a/src/vcspull/cli/search.py +++ b/src/vcspull/cli/search.py @@ -479,7 +479,7 @@ def create_search_subparser(parser: argparse.ArgumentParser) -> None: parser.add_argument( "query_terms", metavar="query", - nargs="+", + nargs="*", help=( "search query terms (regex by default). Use field prefixes like " "name:, path:, url:, workspace:." diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index 7855929a..fbc9c4fa 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -596,6 +596,13 @@ def create_sync_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP default=0, help="increase plan verbosity (-vv for maximum detail)", ) + parser.add_argument( + "--all", + "-a", + action="store_true", + dest="sync_all", + help="sync all configured repositories", + ) try: import shtab @@ -622,10 +629,17 @@ def sync( fetch: bool, offline: bool, verbosity: int, + sync_all: bool = False, parser: argparse.ArgumentParser | None = None, # optional so sync can be unit tested ) -> None: """Entry point for ``vcspull sync``.""" + # Show help if no patterns and --all not specified + if not repo_patterns and not sync_all: + if parser is not None: + parser.print_help() + return + output_mode = get_output_mode(output_json, output_ndjson) formatter = OutputFormatter(output_mode) colors = Colors(get_color_mode(color)) @@ -646,19 +660,23 @@ def sync( configs = load_configs(find_config_files(include_home=True)) found_repos: list[ConfigDict] = [] - for repo_pattern in repo_patterns: - path, vcs_url, name = None, None, None - if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]): - path = repo_pattern - elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]): - vcs_url = repo_pattern - else: - name = repo_pattern - - found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name) - if not found and formatter.mode == OutputMode.HUMAN: - log.info(NO_REPOS_FOR_TERM_MSG.format(name=name)) - found_repos.extend(found) + if sync_all: + # Load all repos when --all is specified + found_repos = list(configs) + else: + for repo_pattern in repo_patterns: + path, vcs_url, name = None, None, None + if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]): + path = repo_pattern + elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]): + vcs_url = repo_pattern + else: + name = repo_pattern + + found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name) + if not found and formatter.mode == OutputMode.HUMAN: + log.info(NO_REPOS_FOR_TERM_MSG.format(name=name)) + found_repos.extend(found) if workspace_root: found_repos = filter_by_workspace(found_repos, workspace_root) diff --git a/tests/test_cli.py b/tests/test_cli.py index 48a9056b..4a939855 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -175,12 +175,19 @@ class SyncFixture(t.NamedTuple): expected_exit_code=0, expected_in_out=["{sync", "positional arguments:"], ), - # Sync + # Sync: No args shows help SyncFixture( test_id="sync--empty", sync_args=["sync"], expected_exit_code=0, - expected_in_out=["No repositories matched the criteria."], + expected_in_out=["--all", "--dry-run", "Synchronize VCS repositories"], + ), + # Sync: --all syncs all repos + SyncFixture( + test_id="sync--all", + sync_args=["sync", "--all"], + expected_exit_code=0, + expected_in_out="my_git_repo", ), # Sync: Help SyncFixture( @@ -204,6 +211,27 @@ class SyncFixture(t.NamedTuple): expected_exit_code=0, expected_in_out="my_git_repo", ), + # Search: No args shows help + SyncFixture( + test_id="search--empty", + sync_args=["search"], + expected_exit_code=0, + expected_in_out=["search query terms", "--ignore-case"], + ), + # Add: No args shows help + SyncFixture( + test_id="add--empty", + sync_args=["add"], + expected_exit_code=0, + expected_in_out=["Filesystem path", "--workspace"], + ), + # Discover: No args shows help + SyncFixture( + test_id="discover--empty", + sync_args=["discover"], + expected_exit_code=0, + expected_in_out=["Directory to scan", "--recursive"], + ), ] @@ -272,9 +300,20 @@ def test_sync( yaml_config_data = yaml.dump(config, default_flow_style=False) yaml_config.write_text(yaml_config_data, encoding="utf-8") + # Build CLI args, injecting -f for commands that need explicit config + cli_args = list(sync_args) + if ( + cli_args + and cli_args[0] == "sync" + and "--help" not in cli_args + and "-h" not in cli_args + ): + # Inject config file path for sync commands to ensure test isolation + cli_args.extend(["-f", str(yaml_config)]) + # CLI can sync with contextlib.suppress(SystemExit): - cli(sync_args) + cli(cli_args) result = capsys.readouterr() output = "".join(list(result.out if expected_exit_code == 0 else result.err))