Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 89ffe93

Browse filesBrowse 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 19a53d1 commit 89ffe93
Copy full SHA for 89ffe93

File tree

Expand file treeCollapse file tree

6 files changed

+1547
-5
lines changed
Filter options
Expand file treeCollapse file tree

6 files changed

+1547
-5
lines changed

‎src/vcspull/cli/__init__.py

Copy file name to clipboardExpand all lines: src/vcspull/cli/__init__.py
+89-5Lines changed: 89 additions & 5 deletions
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

Copy file name to clipboard
+224Lines changed: 224 additions & 0 deletions
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: git@github.com: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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.