Skip to content

Commit 1be9760

Browse files
committed
config(feat): Add save_config_yaml utility function
why: Need consistent YAML writing functionality for new CLI commands what: - Add save_config_yaml function to centralize config file writing - Use ConfigReader._dump for consistent formatting refs: Foundation for add/add-from-fs commands
1 parent 617f2c8 commit 1be9760

File tree

13 files changed

+3032
-31
lines changed

13 files changed

+3032
-31
lines changed

src/vcspull/_internal/config_reader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]:
104104
{'session_name': 'my session'}
105105
"""
106106
assert isinstance(path, pathlib.Path)
107-
content = path.open().read()
107+
content = path.open(encoding="utf-8").read()
108108

109109
if path.suffix in {".yaml", ".yml"}:
110110
fmt: FormatLiteral = "yaml"

src/vcspull/cli/__init__.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import argparse
66
import logging
7+
import pathlib
78
import textwrap
89
import typing as t
910
from typing import overload
@@ -13,6 +14,9 @@
1314
from vcspull.__about__ import __version__
1415
from vcspull.log import setup_logger
1516

17+
from .add import add_repo, create_add_subparser
18+
from .add_from_fs import add_from_filesystem, create_add_from_fs_subparser
19+
from .fmt import create_fmt_subparser, format_config_file
1620
from .sync import create_sync_subparser, sync
1721

1822
log = logging.getLogger(__name__)
@@ -73,14 +77,43 @@ def create_parser(
7377
)
7478
create_sync_subparser(sync_parser)
7579

80+
add_parser = subparsers.add_parser(
81+
"add",
82+
help="add a repository to the configuration",
83+
formatter_class=argparse.RawDescriptionHelpFormatter,
84+
description="Add a repository to the vcspull configuration file.",
85+
)
86+
create_add_subparser(add_parser)
87+
88+
add_from_fs_parser = subparsers.add_parser(
89+
"add-from-fs",
90+
help="scan filesystem for git repositories and add them to the configuration",
91+
formatter_class=argparse.RawDescriptionHelpFormatter,
92+
description="Scan a directory for git repositories and add them to the "
93+
"vcspull configuration file.",
94+
)
95+
create_add_from_fs_subparser(add_from_fs_parser)
96+
97+
fmt_parser = subparsers.add_parser(
98+
"fmt",
99+
help="format vcspull configuration files",
100+
formatter_class=argparse.RawDescriptionHelpFormatter,
101+
description="Format vcspull configuration files for consistency. "
102+
"Normalizes compact format to verbose format, standardizes on 'repo' key, "
103+
"and sorts directories and repositories alphabetically.",
104+
)
105+
create_fmt_subparser(fmt_parser)
106+
76107
if return_subparsers:
77-
return parser, sync_parser
108+
# Return all parsers needed by cli() function
109+
return parser, (sync_parser, add_parser, add_from_fs_parser, fmt_parser)
78110
return parser
79111

80112

81113
def cli(_args: list[str] | None = None) -> None:
82114
"""CLI entry point for vcspull."""
83-
parser, sync_parser = create_parser(return_subparsers=True)
115+
parser, subparsers = create_parser(return_subparsers=True)
116+
sync_parser, _add_parser, _add_from_fs_parser, _fmt_parser = subparsers
84117
args = parser.parse_args(_args)
85118

86119
setup_logger(log=log, level=args.log_level.upper())
@@ -91,7 +124,25 @@ def cli(_args: list[str] | None = None) -> None:
91124
if args.subparser_name == "sync":
92125
sync(
93126
repo_patterns=args.repo_patterns,
94-
config=args.config,
127+
config=pathlib.Path(args.config) if args.config else None,
95128
exit_on_error=args.exit_on_error,
96129
parser=sync_parser,
97130
)
131+
elif args.subparser_name == "add":
132+
add_repo(
133+
name=args.name,
134+
url=args.url,
135+
config_file_path_str=args.config,
136+
path=args.path,
137+
base_dir=args.base_dir,
138+
)
139+
elif args.subparser_name == "add-from-fs":
140+
add_from_filesystem(
141+
scan_dir_str=args.scan_dir,
142+
config_file_path_str=args.config,
143+
recursive=args.recursive,
144+
base_dir_key_arg=args.base_dir_key,
145+
yes=args.yes,
146+
)
147+
elif args.subparser_name == "fmt":
148+
format_config_file(args.config, args.write, args.all)

src/vcspull/cli/add.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Add repository functionality for vcspull."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import pathlib
7+
import traceback
8+
import typing as t
9+
10+
from colorama import Fore, Style
11+
12+
from vcspull._internal.config_reader import ConfigReader
13+
from vcspull.config import find_home_config_files, save_config_yaml
14+
15+
if t.TYPE_CHECKING:
16+
import argparse
17+
18+
log = logging.getLogger(__name__)
19+
20+
21+
def create_add_subparser(parser: argparse.ArgumentParser) -> None:
22+
"""Create ``vcspull add`` argument subparser."""
23+
parser.add_argument(
24+
"-c",
25+
"--config",
26+
dest="config",
27+
metavar="file",
28+
help="path to custom config file (default: .vcspull.yaml or ~/.vcspull.yaml)",
29+
)
30+
parser.add_argument(
31+
"name",
32+
help="Name for the repository in the config",
33+
)
34+
parser.add_argument(
35+
"url",
36+
help="Repository URL (e.g., https://github.com/user/repo.git)",
37+
)
38+
parser.add_argument(
39+
"--path",
40+
dest="path",
41+
help="Local directory path where repo will be cloned "
42+
"(determines base directory key if not specified with --dir)",
43+
)
44+
parser.add_argument(
45+
"--dir",
46+
dest="base_dir",
47+
help="Base directory key in config (e.g., '~/projects/'). "
48+
"If not specified, will be inferred from --path or use current directory.",
49+
)
50+
51+
52+
def add_repo(
53+
name: str,
54+
url: str,
55+
config_file_path_str: str | None,
56+
path: str | None,
57+
base_dir: str | None,
58+
) -> None:
59+
"""Add a repository to the vcspull configuration.
60+
61+
Parameters
62+
----------
63+
name : str
64+
Repository name for the config
65+
url : str
66+
Repository URL
67+
config_file_path_str : str | None
68+
Path to config file, or None to use default
69+
path : str | None
70+
Local path where repo will be cloned
71+
base_dir : str | None
72+
Base directory key to use in config
73+
"""
74+
# Determine config file
75+
config_file_path: pathlib.Path
76+
if config_file_path_str:
77+
config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve()
78+
else:
79+
home_configs = find_home_config_files(filetype=["yaml"])
80+
if not home_configs:
81+
config_file_path = pathlib.Path.cwd() / ".vcspull.yaml"
82+
log.info(
83+
"No config specified and no default found, will create at %s",
84+
config_file_path,
85+
)
86+
elif len(home_configs) > 1:
87+
log.error(
88+
"Multiple home config files found, please specify one with -c/--config",
89+
)
90+
return
91+
else:
92+
config_file_path = home_configs[0]
93+
94+
# Load existing config
95+
raw_config: dict[str, t.Any] = {}
96+
if config_file_path.exists() and config_file_path.is_file():
97+
try:
98+
loaded_config = ConfigReader._from_file(config_file_path)
99+
except Exception:
100+
log.exception("Error loading YAML from %s. Aborting.", config_file_path)
101+
if log.isEnabledFor(logging.DEBUG):
102+
traceback.print_exc()
103+
return
104+
105+
if loaded_config is None:
106+
raw_config = {}
107+
elif isinstance(loaded_config, dict):
108+
raw_config = loaded_config
109+
else:
110+
log.error(
111+
"Config file %s is not a valid YAML dictionary.",
112+
config_file_path,
113+
)
114+
return
115+
else:
116+
log.info(
117+
"Config file %s not found. A new one will be created.",
118+
config_file_path,
119+
)
120+
121+
# Determine base directory key
122+
if base_dir:
123+
# Use explicit base directory
124+
base_dir_key = base_dir if base_dir.endswith("/") else base_dir + "/"
125+
elif path:
126+
# Infer from provided path
127+
repo_path = pathlib.Path(path).expanduser().resolve()
128+
try:
129+
# Try to make it relative to home
130+
base_dir_key = "~/" + str(repo_path.relative_to(pathlib.Path.home())) + "/"
131+
except ValueError:
132+
# Use absolute path
133+
base_dir_key = str(repo_path) + "/"
134+
else:
135+
# Default to current directory
136+
base_dir_key = "./"
137+
138+
# Ensure base directory key exists in config
139+
if base_dir_key not in raw_config:
140+
raw_config[base_dir_key] = {}
141+
elif not isinstance(raw_config[base_dir_key], dict):
142+
log.error(
143+
"Configuration section '%s' is not a dictionary. Aborting.",
144+
base_dir_key,
145+
)
146+
return
147+
148+
# Check if repo already exists
149+
if name in raw_config[base_dir_key]:
150+
existing_config = raw_config[base_dir_key][name]
151+
# Handle both string and dict formats
152+
current_url: str
153+
if isinstance(existing_config, str):
154+
current_url = existing_config
155+
elif isinstance(existing_config, dict):
156+
repo_value = existing_config.get("repo")
157+
url_value = existing_config.get("url")
158+
current_url = repo_value or url_value or "unknown"
159+
else:
160+
current_url = str(existing_config)
161+
162+
log.warning(
163+
"Repository '%s' already exists under '%s'. Current URL: %s. "
164+
"To update, remove and re-add, or edit the YAML file manually.",
165+
name,
166+
base_dir_key,
167+
current_url,
168+
)
169+
return
170+
171+
# Add the repository in verbose format
172+
raw_config[base_dir_key][name] = {"repo": url}
173+
174+
# Save config
175+
try:
176+
save_config_yaml(config_file_path, raw_config)
177+
log.info(
178+
"%s✓%s Successfully added %s'%s'%s (%s%s%s) to %s%s%s under '%s%s%s'.",
179+
Fore.GREEN,
180+
Style.RESET_ALL,
181+
Fore.CYAN,
182+
name,
183+
Style.RESET_ALL,
184+
Fore.YELLOW,
185+
url,
186+
Style.RESET_ALL,
187+
Fore.BLUE,
188+
config_file_path,
189+
Style.RESET_ALL,
190+
Fore.MAGENTA,
191+
base_dir_key,
192+
Style.RESET_ALL,
193+
)
194+
except Exception:
195+
log.exception("Error saving config to %s", config_file_path)
196+
if log.isEnabledFor(logging.DEBUG):
197+
traceback.print_exc()
198+
return

0 commit comments

Comments
 (0)