Skip to content

Commit 15d7f0f

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 bcfb4bb commit 15d7f0f

File tree

6 files changed

+93
-22
lines changed

6 files changed

+93
-22
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: 31 additions & 13 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))
@@ -646,19 +660,23 @@ def sync(
646660
configs = load_configs(find_config_files(include_home=True))
647661
found_repos: list[ConfigDict] = []
648662

649-
for repo_pattern in repo_patterns:
650-
path, vcs_url, name = None, None, None
651-
if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]):
652-
path = repo_pattern
653-
elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]):
654-
vcs_url = repo_pattern
655-
else:
656-
name = repo_pattern
657-
658-
found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name)
659-
if not found and formatter.mode == OutputMode.HUMAN:
660-
log.info(NO_REPOS_FOR_TERM_MSG.format(name=name))
661-
found_repos.extend(found)
663+
if sync_all:
664+
# Load all repos when --all is specified
665+
found_repos = list(configs)
666+
else:
667+
for repo_pattern in repo_patterns:
668+
path, vcs_url, name = None, None, None
669+
if any(repo_pattern.startswith(n) for n in ["./", "/", "~", "$HOME"]):
670+
path = repo_pattern
671+
elif any(repo_pattern.startswith(n) for n in ["http", "git", "svn", "hg"]):
672+
vcs_url = repo_pattern
673+
else:
674+
name = repo_pattern
675+
676+
found = filter_repos(configs, path=path, vcs_url=vcs_url, name=name)
677+
if not found and formatter.mode == OutputMode.HUMAN:
678+
log.info(NO_REPOS_FOR_TERM_MSG.format(name=name))
679+
found_repos.extend(found)
662680

663681
if workspace_root:
664682
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
@@ -175,12 +175,19 @@ class SyncFixture(t.NamedTuple):
175175
expected_exit_code=0,
176176
expected_in_out=["{sync", "positional arguments:"],
177177
),
178-
# Sync
178+
# Sync: No args shows help
179179
SyncFixture(
180180
test_id="sync--empty",
181181
sync_args=["sync"],
182182
expected_exit_code=0,
183-
expected_in_out=["No repositories matched the criteria."],
183+
expected_in_out=["--all", "--dry-run", "Synchronize VCS repositories"],
184+
),
185+
# Sync: --all syncs all repos
186+
SyncFixture(
187+
test_id="sync--all",
188+
sync_args=["sync", "--all"],
189+
expected_exit_code=0,
190+
expected_in_out="my_git_repo",
184191
),
185192
# Sync: Help
186193
SyncFixture(
@@ -204,6 +211,27 @@ class SyncFixture(t.NamedTuple):
204211
expected_exit_code=0,
205212
expected_in_out="my_git_repo",
206213
),
214+
# Search: No args shows help
215+
SyncFixture(
216+
test_id="search--empty",
217+
sync_args=["search"],
218+
expected_exit_code=0,
219+
expected_in_out=["search query terms", "--ignore-case"],
220+
),
221+
# Add: No args shows help
222+
SyncFixture(
223+
test_id="add--empty",
224+
sync_args=["add"],
225+
expected_exit_code=0,
226+
expected_in_out=["Filesystem path", "--workspace"],
227+
),
228+
# Discover: No args shows help
229+
SyncFixture(
230+
test_id="discover--empty",
231+
sync_args=["discover"],
232+
expected_exit_code=0,
233+
expected_in_out=["Directory to scan", "--recursive"],
234+
),
207235
]
208236

209237

@@ -272,9 +300,20 @@ def test_sync(
272300
yaml_config_data = yaml.dump(config, default_flow_style=False)
273301
yaml_config.write_text(yaml_config_data, encoding="utf-8")
274302

303+
# Build CLI args, injecting -f for commands that need explicit config
304+
cli_args = list(sync_args)
305+
if (
306+
cli_args
307+
and cli_args[0] == "sync"
308+
and "--help" not in cli_args
309+
and "-h" not in cli_args
310+
):
311+
# Inject config file path for sync commands to ensure test isolation
312+
cli_args.extend(["-f", str(yaml_config)])
313+
275314
# CLI can sync
276315
with contextlib.suppress(SystemExit):
277-
cli(sync_args)
316+
cli(cli_args)
278317

279318
result = capsys.readouterr()
280319
output = "".join(list(result.out if expected_exit_code == 0 else result.err))

0 commit comments

Comments
 (0)