Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/vcspull/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "*"',
Expand Down Expand Up @@ -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 "*"',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/vcspull/cli/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
2 changes: 2 additions & 0 deletions src/vcspull/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/vcspull/cli/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:."
Expand Down
44 changes: 31 additions & 13 deletions src/vcspull/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -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)
Expand Down
45 changes: 42 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"],
),
]


Expand Down Expand Up @@ -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))
Expand Down