Skip to content

Commit b49c9af

Browse files
committed
cli(feat) Show help instead of errors when commands lack required args
why: Improve developer experience by providing helpful guidance instead of cryptic argparse errors or confusing behavior when CLI commands are called without required arguments. what: - Add --all/-a flag to sync command (BREAKING: sync now requires --all to sync all repos, previously synced none silently) - Show help for sync when no patterns and --all not specified - Show help for search when no query terms provided (changed nargs="+" to "*") - Show help for add when no repo_path provided (added nargs="?") - Show help for discover when no scan_dir provided (added nargs="?") - Update tests to verify new help-on-empty behavior - Update CLI examples to show vcspull sync --all
1 parent 5330ff4 commit b49c9af

File tree

6 files changed

+100
-29
lines changed

6 files changed

+100
-29
lines changed

src/vcspull/cli/__init__.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def build_description(
5555
(
5656
"sync",
5757
[
58-
'vcspull sync "*"',
58+
"vcspull sync --all",
5959
'vcspull sync "django-*"',
6060
'vcspull sync --dry-run "*"',
6161
'vcspull sync -f ./myrepos.yaml "*"',
@@ -116,7 +116,7 @@ def build_description(
116116
(
117117
None,
118118
[
119-
'vcspull sync "*"',
119+
"vcspull sync --all",
120120
'vcspull sync "django-*"',
121121
'vcspull sync --dry-run "*"',
122122
'vcspull sync -f ./myrepos.yaml "*"',
@@ -354,9 +354,9 @@ def cli(_args: list[str] | None = None) -> None:
354354
sync_parser,
355355
_list_parser,
356356
_status_parser,
357-
_search_parser,
358-
_add_parser,
359-
_discover_parser,
357+
search_parser,
358+
add_parser,
359+
discover_parser,
360360
_fmt_parser,
361361
) = subparsers
362362
args = parser.parse_args(_args)
@@ -384,6 +384,7 @@ def cli(_args: list[str] | None = None) -> None:
384384
fetch=getattr(args, "fetch", False),
385385
offline=getattr(args, "offline", False),
386386
verbosity=getattr(args, "verbosity", 0),
387+
sync_all=getattr(args, "sync_all", False),
387388
parser=sync_parser,
388389
)
389390
elif args.subparser_name == "list":
@@ -409,6 +410,9 @@ def cli(_args: list[str] | None = None) -> None:
409410
max_concurrent=getattr(args, "max_concurrent", None),
410411
)
411412
elif args.subparser_name == "search":
413+
if not args.query_terms:
414+
search_parser.print_help()
415+
return
412416
search_repos(
413417
query_terms=args.query_terms,
414418
config_path=pathlib.Path(args.config) if args.config else None,
@@ -425,8 +429,14 @@ def cli(_args: list[str] | None = None) -> None:
425429
match_any=getattr(args, "match_any", False),
426430
)
427431
elif args.subparser_name == "add":
432+
if not args.repo_path:
433+
add_parser.print_help()
434+
return
428435
handle_add_command(args)
429436
elif args.subparser_name == "discover":
437+
if not args.scan_dir:
438+
discover_parser.print_help()
439+
return
430440
discover_repos(
431441
scan_dir_str=args.scan_dir,
432442
config_file_path_str=args.config,

src/vcspull/cli/add.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None:
3737
"""
3838
parser.add_argument(
3939
"repo_path",
40+
nargs="?",
41+
default=None,
4042
help=(
4143
"Filesystem path to an existing project. The parent directory "
4244
"becomes the workspace unless overridden with --workspace."

src/vcspull/cli/discover.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ def create_discover_subparser(parser: argparse.ArgumentParser) -> None:
124124
parser.add_argument(
125125
"scan_dir",
126126
metavar="PATH",
127+
nargs="?",
128+
default=None,
127129
help="Directory to scan for git repositories",
128130
)
129131
parser.add_argument(

src/vcspull/cli/search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ def create_search_subparser(parser: argparse.ArgumentParser) -> None:
479479
parser.add_argument(
480480
"query_terms",
481481
metavar="query",
482-
nargs="+",
482+
nargs="*",
483483
help=(
484484
"search query terms (regex by default). Use field prefixes like "
485485
"name:, path:, url:, workspace:."

src/vcspull/cli/sync.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,13 @@ def create_sync_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP
596596
default=0,
597597
help="increase plan verbosity (-vv for maximum detail)",
598598
)
599+
parser.add_argument(
600+
"--all",
601+
"-a",
602+
action="store_true",
603+
dest="sync_all",
604+
help="sync all configured repositories",
605+
)
599606

600607
try:
601608
import shtab
@@ -622,10 +629,17 @@ def sync(
622629
fetch: bool,
623630
offline: bool,
624631
verbosity: int,
632+
sync_all: bool = False,
625633
parser: argparse.ArgumentParser
626634
| None = None, # optional so sync can be unit tested
627635
) -> None:
628636
"""Entry point for ``vcspull sync``."""
637+
# Show help if no patterns and --all not specified
638+
if not repo_patterns and not sync_all:
639+
if parser is not None:
640+
parser.print_help()
641+
return
642+
629643
output_mode = get_output_mode(output_json, output_ndjson)
630644
formatter = OutputFormatter(output_mode)
631645
colors = Colors(get_color_mode(color))
@@ -647,26 +661,30 @@ def sync(
647661
found_repos: list[ConfigDict] = []
648662
unmatched_count = 0
649663

650-
for repo_pattern in repo_patterns:
651-
path, vcs_url, name = None, None, None
652-
if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]):
653-
path = repo_pattern
654-
elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]):
655-
vcs_url = repo_pattern
656-
else:
657-
name = repo_pattern
658-
659-
found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name)
660-
if not found:
661-
search_term = name or path or vcs_url or repo_pattern
662-
log.debug(NO_REPOS_FOR_TERM_MSG.format(name=search_term))
663-
if not summary_only:
664-
formatter.emit_text(
665-
f"{colors.error('✗')} "
666-
f"{NO_REPOS_FOR_TERM_MSG.format(name=search_term)}",
667-
)
668-
unmatched_count += 1
669-
found_repos.extend(found)
664+
if sync_all:
665+
# Load all repos when --all is specified
666+
found_repos = list(configs)
667+
else:
668+
for repo_pattern in repo_patterns:
669+
path, vcs_url, name = None, None, None
670+
if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]):
671+
path = repo_pattern
672+
elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]):
673+
vcs_url = repo_pattern
674+
else:
675+
name = repo_pattern
676+
677+
found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name)
678+
if not found:
679+
search_term = name or path or vcs_url or repo_pattern
680+
log.debug(NO_REPOS_FOR_TERM_MSG.format(name=search_term))
681+
if not summary_only:
682+
formatter.emit_text(
683+
f"{colors.error('✗')} "
684+
f"{NO_REPOS_FOR_TERM_MSG.format(name=search_term)}",
685+
)
686+
unmatched_count += 1
687+
found_repos.extend(found)
670688

671689
if workspace_root:
672690
found_repos = filter_by_workspace(found_repos, workspace_root)

tests/test_cli.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,19 @@ class SyncFixture(t.NamedTuple):
232232
expected_exit_code=0,
233233
expected_in_out=["{sync", "positional arguments:"],
234234
),
235-
# Sync
235+
# Sync: No args shows help
236236
SyncFixture(
237237
test_id="sync--empty",
238238
sync_args=["sync"],
239239
expected_exit_code=0,
240-
expected_in_out=["No repositories matched the criteria."],
240+
expected_in_out=["--all", "--dry-run", "Synchronize VCS repositories"],
241+
),
242+
# Sync: --all syncs all repos
243+
SyncFixture(
244+
test_id="sync--all",
245+
sync_args=["sync", "--all"],
246+
expected_exit_code=0,
247+
expected_in_out="my_git_repo",
241248
),
242249
# Sync: Help
243250
SyncFixture(
@@ -261,6 +268,27 @@ class SyncFixture(t.NamedTuple):
261268
expected_exit_code=0,
262269
expected_in_out="my_git_repo",
263270
),
271+
# Search: No args shows help
272+
SyncFixture(
273+
test_id="search--empty",
274+
sync_args=["search"],
275+
expected_exit_code=0,
276+
expected_in_out=["search query terms", "--ignore-case"],
277+
),
278+
# Add: No args shows help
279+
SyncFixture(
280+
test_id="add--empty",
281+
sync_args=["add"],
282+
expected_exit_code=0,
283+
expected_in_out=["Filesystem path", "--workspace"],
284+
),
285+
# Discover: No args shows help
286+
SyncFixture(
287+
test_id="discover--empty",
288+
sync_args=["discover"],
289+
expected_exit_code=0,
290+
expected_in_out=["Directory to scan", "--recursive"],
291+
),
264292
]
265293

266294

@@ -328,10 +356,21 @@ def test_sync(
328356
yaml_config = config_path / ".vcspull.yaml"
329357
write_config(yaml_config, yaml.dump(config, default_flow_style=False))
330358

359+
# Build CLI args, injecting -f for commands that need explicit config
360+
cli_args = list(sync_args)
361+
if (
362+
cli_args
363+
and cli_args[0] == "sync"
364+
and "--help" not in cli_args
365+
and "-h" not in cli_args
366+
):
367+
# Inject config file path for sync commands to ensure test isolation
368+
cli_args.extend(["-f", str(yaml_config)])
369+
331370
# CLI can sync
332371
exit_code = 0
333372
try:
334-
cli(sync_args)
373+
cli(cli_args)
335374
except SystemExit as exc:
336375
exit_code = exc.code if isinstance(exc.code, int) else 0
337376

0 commit comments

Comments
 (0)