Skip to content

Commit 6cf08d3

Browse files
committed
Add 'add' and 'add-from-fs' CLI commands
This commit adds two new CLI commands to vcspull: 1. 'add' - Adds a single repository to the configuration - Handles different input formats (name=url, url only) - Supports custom base_dir_key and target_path options - Automatically extracts repo names from URLs when needed 2. 'add-from-fs' - Scans a directory for git repositories and adds them to the configuration - Supports recursive scanning with --recursive flag - Can use a custom base directory key with --base-dir-key - Provides interactive confirmation (can be bypassed with --yes) - Organizes nested repositories under appropriate base keys Both commands include thorough test coverage and maintain project code style.
1 parent 3568063 commit 6cf08d3

File tree

6 files changed

+1547
-5
lines changed

6 files changed

+1547
-5
lines changed

src/vcspull/cli/__init__.py

+89-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from vcspull.__about__ import __version__
1414
from vcspull.log import setup_logger
1515

16+
from .add import add_repo, create_add_subparser
17+
from .add_from_fs import add_from_filesystem, create_add_from_fs_subparser
1618
from .sync import create_sync_subparser, sync
1719

1820
log = logging.getLogger(__name__)
@@ -41,9 +43,28 @@ def create_parser(
4143
def create_parser(return_subparsers: t.Literal[False]) -> argparse.ArgumentParser: ...
4244

4345

46+
@overload
47+
def create_parser(
48+
return_subparsers: t.Literal[True], get_all_subparsers: t.Literal[True]
49+
) -> tuple[
50+
argparse.ArgumentParser,
51+
argparse._SubParsersAction[argparse.ArgumentParser],
52+
dict[str, argparse.ArgumentParser],
53+
]: ...
54+
55+
4456
def create_parser(
4557
return_subparsers: bool = False,
46-
) -> argparse.ArgumentParser | tuple[argparse.ArgumentParser, t.Any]:
58+
get_all_subparsers: bool = False,
59+
) -> (
60+
argparse.ArgumentParser
61+
| tuple[argparse.ArgumentParser, t.Any]
62+
| tuple[
63+
argparse.ArgumentParser,
64+
argparse._SubParsersAction[argparse.ArgumentParser],
65+
dict[str, argparse.ArgumentParser],
66+
]
67+
):
4768
"""Create CLI argument parser for vcspull."""
4869
parser = argparse.ArgumentParser(
4970
prog="vcspull",
@@ -73,14 +94,44 @@ def create_parser(
7394
)
7495
create_sync_subparser(sync_parser)
7596

97+
add_parser = subparsers.add_parser(
98+
"add",
99+
help="add a new repository to the configuration",
100+
formatter_class=argparse.RawDescriptionHelpFormatter,
101+
description="Adds a new repository to the vcspull configuration file.",
102+
)
103+
create_add_subparser(add_parser)
104+
105+
add_from_fs_parser = subparsers.add_parser(
106+
"add-from-fs",
107+
help="scan a directory for git repositories and add them to the configuration",
108+
formatter_class=argparse.RawDescriptionHelpFormatter,
109+
description=(
110+
"Scans a directory for git repositories and adds them "
111+
"to the vcspull configuration file."
112+
),
113+
)
114+
create_add_from_fs_subparser(add_from_fs_parser)
115+
116+
all_subparsers_dict = {
117+
"sync": sync_parser,
118+
"add": add_parser,
119+
"add-from-fs": add_from_fs_parser,
120+
}
121+
122+
if get_all_subparsers:
123+
return parser, subparsers, all_subparsers_dict
124+
76125
if return_subparsers:
77126
return parser, sync_parser
78127
return parser
79128

80129

81130
def cli(_args: list[str] | None = None) -> None:
82131
"""CLI entry point for vcspull."""
83-
parser, sync_parser = create_parser(return_subparsers=True)
132+
parser, subparsers_action, all_parsers = create_parser(
133+
return_subparsers=True, get_all_subparsers=True
134+
)
84135
args = parser.parse_args(_args)
85136

86137
setup_logger(log=log, level=args.log_level.upper())
@@ -89,9 +140,42 @@ def cli(_args: list[str] | None = None) -> None:
89140
parser.print_help()
90141
return
91142
if args.subparser_name == "sync":
143+
sync_parser = all_parsers["sync"]
144+
# Extract parameters from args, providing defaults for required params
145+
repo_patterns = args.repo_patterns if hasattr(args, "repo_patterns") else []
146+
config_path = None
147+
if hasattr(args, "config") and args.config is not None:
148+
from pathlib import Path
149+
150+
config_path = Path(args.config)
151+
exit_on_error = args.exit_on_error if hasattr(args, "exit_on_error") else False
152+
# Call sync with correct parameter types
92153
sync(
93-
repo_patterns=args.repo_patterns,
94-
config=args.config,
95-
exit_on_error=args.exit_on_error,
154+
repo_patterns=repo_patterns,
155+
config=config_path,
156+
exit_on_error=exit_on_error,
96157
parser=sync_parser,
97158
)
159+
elif args.subparser_name == "add":
160+
add_repo_kwargs = {
161+
"repo_name_or_url": args.repo_name_or_url,
162+
"config_file_path_str": args.config if hasattr(args, "config") else None,
163+
"target_path_str": args.target_path
164+
if hasattr(args, "target_path")
165+
else None,
166+
"base_dir_key": args.base_dir_key
167+
if hasattr(args, "base_dir_key")
168+
else None,
169+
}
170+
add_repo(**add_repo_kwargs)
171+
elif args.subparser_name == "add-from-fs":
172+
add_from_fs_kwargs = {
173+
"scan_dir_str": args.scan_dir,
174+
"config_file_path_str": args.config if hasattr(args, "config") else None,
175+
"recursive": args.recursive if hasattr(args, "recursive") else False,
176+
"base_dir_key_arg": args.base_dir_key
177+
if hasattr(args, "base_dir_key")
178+
else None,
179+
"yes": args.yes if hasattr(args, "yes") else False,
180+
}
181+
add_from_filesystem(**add_from_fs_kwargs)

src/vcspull/cli/add.py

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""CLI functionality for vcspull add."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import logging
7+
import pathlib
8+
import typing as t
9+
10+
import yaml
11+
12+
from vcspull.config import expand_dir, find_home_config_files
13+
14+
if t.TYPE_CHECKING:
15+
pass
16+
17+
log = logging.getLogger(__name__)
18+
19+
20+
def create_add_subparser(parser: argparse.ArgumentParser) -> None:
21+
"""Configure :py:class:`argparse.ArgumentParser` for ``vcspull add``."""
22+
parser.add_argument(
23+
"-c",
24+
"--config",
25+
dest="config",
26+
metavar="file",
27+
help="path to custom config file (default: .vcspull.yaml or ~/.vcspull.yaml)",
28+
)
29+
parser.add_argument(
30+
"--target-path",
31+
dest="target_path",
32+
help="target checkout path (e.g., ~/code/mylib)",
33+
)
34+
parser.add_argument(
35+
"--base-dir-key",
36+
dest="base_dir_key",
37+
help="base directory key in the config (e.g., ~/code/)",
38+
)
39+
parser.add_argument(
40+
"repo_name_or_url",
41+
help=(
42+
"repo_name=repo_url format, or just repo_url to extract name. "
43+
"Examples: flask=git+https://github.com/pallets/flask.git, "
44+
"or just git+https://github.com/pallets/flask.git"
45+
),
46+
)
47+
48+
49+
def extract_repo_name_and_url(repo_name_or_url: str) -> tuple[str, str]:
50+
"""Extract repository name and URL from various input formats.
51+
52+
Parameters
53+
----------
54+
repo_name_or_url : str
55+
Repository name and URL in one of these formats:
56+
- name=url
57+
- url
58+
59+
Returns
60+
-------
61+
tuple[str, str]
62+
A tuple of (repo_name, repo_url)
63+
"""
64+
if "=" in repo_name_or_url:
65+
# Format: name=url
66+
repo_name, repo_url = repo_name_or_url.split("=", 1)
67+
else:
68+
# Format: url only, extract name from URL
69+
repo_url = repo_name_or_url
70+
# Extract repo name from URL
71+
if repo_url.endswith(".git"):
72+
repo_url_path = repo_url.rstrip("/").split("/")[-1]
73+
repo_name = repo_url_path.rsplit(".git", 1)[0]
74+
elif ":" in repo_url and "@" in repo_url: # SSH URL format
75+
# SSH format: [email protected]:user/repo.git
76+
repo_url_path = repo_url.split(":")[-1].rstrip("/")
77+
if repo_url_path.endswith(".git"):
78+
repo_name = repo_url_path.split("/")[-1].rsplit(".git", 1)[0]
79+
else:
80+
repo_name = repo_url_path.split("/")[-1]
81+
else:
82+
# Just use the last part of the URL as the name
83+
repo_name = repo_url.rstrip("/").split("/")[-1]
84+
85+
return repo_name, repo_url
86+
87+
88+
def save_config_yaml(config_file_path: pathlib.Path, data: dict[t.Any, t.Any]) -> None:
89+
"""Save configuration data to YAML file.
90+
91+
Parameters
92+
----------
93+
config_file_path : pathlib.Path
94+
Path to config file to save
95+
data : dict[t.Any, t.Any]
96+
Configuration data to save
97+
"""
98+
# Ensure directory exists
99+
config_file_path.parent.mkdir(parents=True, exist_ok=True)
100+
101+
# Write to file
102+
with config_file_path.open("w", encoding="utf-8") as f:
103+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
104+
105+
106+
def add_repo(
107+
repo_name_or_url: str,
108+
config_file_path_str: str | None = None,
109+
target_path_str: str | None = None,
110+
base_dir_key: str | None = None,
111+
) -> None:
112+
"""Add a repository to the vcspull configuration.
113+
114+
Parameters
115+
----------
116+
repo_name_or_url : str
117+
Repository name and URL in format name=url or just url
118+
config_file_path_str : str, optional
119+
Path to config file, by default None
120+
target_path_str : str, optional
121+
Target checkout path, by default None
122+
base_dir_key : str, optional
123+
Base directory key in the config, by default None
124+
"""
125+
repo_name, repo_url = extract_repo_name_and_url(repo_name_or_url)
126+
127+
# Determine config file path
128+
config_file_path: pathlib.Path
129+
if config_file_path_str:
130+
config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve()
131+
else:
132+
# Default to ~/.vcspull.yaml if no global --config is passed
133+
home_configs = find_home_config_files(filetype=["yaml"])
134+
if not home_configs:
135+
# If no configs found, create .vcspull.yaml in current directory
136+
config_file_path = pathlib.Path.cwd() / ".vcspull.yaml"
137+
log.info(f"No config found, will create {config_file_path}")
138+
elif len(home_configs) > 1:
139+
log.error(
140+
"Multiple home_config files found, please specify one with -c/--config"
141+
)
142+
return
143+
else:
144+
config_file_path = home_configs[0]
145+
146+
# Load existing config
147+
raw_config: dict[str, t.Any] = {}
148+
if config_file_path.exists() and config_file_path.is_file():
149+
try:
150+
with config_file_path.open(encoding="utf-8") as f:
151+
raw_config = yaml.safe_load(f) or {}
152+
if not isinstance(raw_config, dict):
153+
log.error(f"Config file {config_file_path} not a valid YAML dict")
154+
return
155+
except Exception:
156+
log.exception(f"Error loading YAML from {config_file_path}")
157+
return
158+
else:
159+
log.info(
160+
f"Config file {config_file_path} not found. A new one will be created."
161+
)
162+
163+
# Determine the base directory key to use in the config
164+
actual_base_dir_key: str
165+
if base_dir_key:
166+
actual_base_dir_key = base_dir_key
167+
elif target_path_str:
168+
# If target path is provided, use its parent directory as the base key
169+
target_dir = expand_dir(pathlib.Path(target_path_str))
170+
resolved_target_dir_parent = target_dir.parent
171+
try:
172+
# Try to make the path relative to home
173+
actual_base_dir_key = (
174+
"~/"
175+
+ str(resolved_target_dir_parent.relative_to(pathlib.Path.home()))
176+
+ "/"
177+
)
178+
except ValueError:
179+
# Use absolute path if not relative to home
180+
actual_base_dir_key = str(resolved_target_dir_parent) + "/"
181+
else:
182+
# Default to use an existing key or create a new one
183+
if raw_config and raw_config.keys():
184+
# Use the first key if there's an existing configuration
185+
actual_base_dir_key = next(iter(raw_config))
186+
else:
187+
# Default to ~/code/ for a new configuration
188+
actual_base_dir_key = "~/code/"
189+
190+
if not actual_base_dir_key.endswith("/"): # Ensure trailing slash for consistency
191+
actual_base_dir_key += "/"
192+
193+
log.debug(f"Using base directory key: {actual_base_dir_key}")
194+
195+
# Ensure the base directory key exists in the config
196+
if actual_base_dir_key not in raw_config:
197+
raw_config[actual_base_dir_key] = {}
198+
elif not isinstance(raw_config[actual_base_dir_key], dict):
199+
log.error(
200+
f"Section '{actual_base_dir_key}' is not a valid dictionary. Aborting."
201+
)
202+
return
203+
204+
# Check if repo already exists under this base key
205+
if repo_name in raw_config[actual_base_dir_key]:
206+
log.warning(
207+
f"Repository '{repo_name}' already exists under '{actual_base_dir_key}'."
208+
f" Current URL: {raw_config[actual_base_dir_key][repo_name]}."
209+
f" To update, remove and re-add, or edit the YAML file manually."
210+
)
211+
return
212+
213+
# Add the repository to the configuration
214+
raw_config[actual_base_dir_key][repo_name] = repo_url
215+
216+
try:
217+
# Save config back to file
218+
save_config_yaml(config_file_path, raw_config)
219+
log.info(
220+
f"Added '{repo_name}' ({repo_url}) to {config_file_path}"
221+
f" in '{actual_base_dir_key}'."
222+
)
223+
except Exception:
224+
log.exception(f"Error saving config to {config_file_path}")

0 commit comments

Comments
 (0)