Skip to content

New commands: add and add-from-fs #465

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 32 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6a9b8fd
test(helpers) Add `save_config_yaml()`
tony May 10, 2025
4db214c
!squash initial: `add` and `add-from-fs`
tony May 10, 2025
a6bb579
!squash wip
tony May 10, 2025
a89f479
!suqash wip
tony May 10, 2025
aded9c5
!squash more
tony May 10, 2025
e6bd43b
!squash wip
tony May 10, 2025
880f068
!squash more
tony May 10, 2025
68c478d
!squash rm tests/cli/test_*.py
tony May 10, 2025
0542a12
`git restore --source=origin/master tests src`
tony May 10, 2025
adec09c
Fix: Update sync function to accept optional config parameter
tony May 10, 2025
c571abb
Fix: Resolve test issues and add missing imports
tony Jun 19, 2025
c9d448d
Fix: Ensure config parameter is always passed to sync()
tony Jun 19, 2025
df9027e
style: Fix code style violations (Phase 1)
tony Jun 19, 2025
15264c8
refactor: Centralize save_config_yaml function (Phase 3)
tony Jun 19, 2025
6558173
refactor: Simplify add command argument parsing (Phase 2)
tony Jun 19, 2025
b660106
cli(refactor[add/add_from_fs]): Complete refactoring to match vcspull…
tony Jun 19, 2025
315c7cf
cli/add_from_fs(fix[variable-redefinition]): Remove duplicate type an…
tony Jun 19, 2025
a0151b6
cli/add_from_fs(feat[output]): Add detailed reporting of existing rep…
tony Jun 19, 2025
3cb7668
tests/add_from_fs(feat[enhanced-output]): Add comprehensive tests for…
tony Jun 19, 2025
2d150a4
log(feat[simple-formatter]): Add clean output formatter for CLI add c…
tony Jun 19, 2025
7e17fef
tests(feat[test_log]): Add comprehensive tests for vcspull logging ut…
tony Jun 19, 2025
8299a87
cli(fix[error-handling]): Use logging.exception for better error repo…
tony Jun 19, 2025
f5f1fbd
tests/add_from_fs(style[code-quality]): Fix formatting and style issues
tony Jun 19, 2025
8cd3f1e
log(feat[cli-sync-formatter]): Add SimpleLogFormatter to CLI sync for…
tony Jun 19, 2025
7a4b02e
tests/cli(fix[output-capture]): Fix CLI test output capture to includ…
tony Jun 19, 2025
adfdd76
tests/log(feat[sync-logger-tests]): Add comprehensive tests for CLI s…
tony Jun 19, 2025
df6dda3
cli/add_from_fs(style): Fix line length violations in colorized output
tony Jun 20, 2025
a4b690a
cli/__init__(refactor[create_parser]): Simplify parser return handling
tony Jun 22, 2025
b96ac3d
cli/add_from_fs(feat[UX]): Improve output for many existing repositories
tony Jun 22, 2025
bc563ae
cli/add(feat[config-format]): Use verbose repo format for new configs
tony Jun 22, 2025
a0a1b14
cli/add(fix[duplicate-check]): Handle both config formats when checki…
tony Jun 22, 2025
32bbbd8
style: Apply ruff formatting to entire codebase
tony Jun 22, 2025
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
58 changes: 53 additions & 5 deletions src/vcspull/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import logging
import pathlib
import textwrap
import typing as t
from typing import overload
Expand All @@ -13,6 +14,8 @@
from vcspull.__about__ import __version__
from vcspull.log import setup_logger

from .add import add_repo, create_add_subparser
from .add_from_fs import add_from_filesystem, create_add_from_fs_subparser
from .sync import create_sync_subparser, sync

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -73,14 +76,33 @@ def create_parser(
)
create_sync_subparser(sync_parser)

add_parser = subparsers.add_parser(
"add",
help="add a repository to the configuration",
formatter_class=argparse.RawDescriptionHelpFormatter,
description="Add a repository to the vcspull configuration file.",
)
create_add_subparser(add_parser)

add_from_fs_parser = subparsers.add_parser(
"add-from-fs",
help="scan filesystem for git repositories and add them to the configuration",
formatter_class=argparse.RawDescriptionHelpFormatter,
description="Scan a directory for git repositories and add them to the "
"vcspull configuration file.",
)
create_add_from_fs_subparser(add_from_fs_parser)

if return_subparsers:
return parser, sync_parser
# Return all parsers needed by cli() function
return parser, (sync_parser, add_parser, add_from_fs_parser)
return parser


def cli(_args: list[str] | None = None) -> None:
"""CLI entry point for vcspull."""
parser, sync_parser = create_parser(return_subparsers=True)
parser, subparsers = create_parser(return_subparsers=True)
sync_parser, _add_parser, _add_from_fs_parser = subparsers
args = parser.parse_args(_args)

setup_logger(log=log, level=args.log_level.upper())
Expand All @@ -90,8 +112,34 @@ def cli(_args: list[str] | None = None) -> None:
return
if args.subparser_name == "sync":
sync(
repo_patterns=args.repo_patterns,
config=args.config,
exit_on_error=args.exit_on_error,
repo_patterns=args.repo_patterns if hasattr(args, "repo_patterns") else [],
config=(
pathlib.Path(args.config)
if hasattr(args, "config") and args.config
else None
),
exit_on_error=args.exit_on_error
if hasattr(args, "exit_on_error")
else False,
parser=sync_parser,
)
elif args.subparser_name == "add":
add_repo_kwargs = {
"name": args.name,
"url": args.url,
"config_file_path_str": args.config if hasattr(args, "config") else None,
"path": args.path if hasattr(args, "path") else None,
"base_dir": args.base_dir if hasattr(args, "base_dir") else None,
}
add_repo(**add_repo_kwargs)
elif args.subparser_name == "add-from-fs":
add_from_fs_kwargs = {
"scan_dir_str": args.scan_dir,
"config_file_path_str": args.config if hasattr(args, "config") else None,
"recursive": args.recursive if hasattr(args, "recursive") else False,
"base_dir_key_arg": args.base_dir_key
if hasattr(args, "base_dir_key")
else None,
"yes": args.yes if hasattr(args, "yes") else False,
}
add_from_filesystem(**add_from_fs_kwargs)
183 changes: 183 additions & 0 deletions src/vcspull/cli/add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Add repository functionality for vcspull."""

from __future__ import annotations

import logging
import pathlib
import typing as t

import yaml
from colorama import Fore, Style

from vcspull.config import find_home_config_files, save_config_yaml

if t.TYPE_CHECKING:
import argparse

log = logging.getLogger(__name__)


def create_add_subparser(parser: argparse.ArgumentParser) -> None:
"""Create ``vcspull add`` argument subparser."""
parser.add_argument(
"-c",
"--config",
dest="config",
metavar="file",
help="path to custom config file (default: .vcspull.yaml or ~/.vcspull.yaml)",
)
parser.add_argument(
"name",
help="Name for the repository in the config",
)
parser.add_argument(
"url",
help="Repository URL (e.g., https://github.com/user/repo.git)",
)
parser.add_argument(
"--path",
dest="path",
help="Local directory path where repo will be cloned "
"(determines base directory key if not specified with --dir)",
)
parser.add_argument(
"--dir",
dest="base_dir",
help="Base directory key in config (e.g., '~/projects/'). "
"If not specified, will be inferred from --path or use current directory.",
)


def add_repo(
name: str,
url: str,
config_file_path_str: str | None,
path: str | None,
base_dir: str | None,
) -> None:
"""Add a repository to the vcspull configuration.

Parameters
----------
name : str
Repository name for the config
url : str
Repository URL
config_file_path_str : str | None
Path to config file, or None to use default
path : str | None
Local path where repo will be cloned
base_dir : str | None
Base directory key to use in config
"""
# Determine config file
config_file_path: pathlib.Path
if config_file_path_str:
config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve()
else:
home_configs = find_home_config_files(filetype=["yaml"])
if not home_configs:
config_file_path = pathlib.Path.cwd() / ".vcspull.yaml"
log.info(
f"No config specified and no default found, will create at "
f"{config_file_path}",
)
elif len(home_configs) > 1:
log.error(
"Multiple home config files found, please specify one with -c/--config",
)
return
else:
config_file_path = home_configs[0]

# Load existing config
raw_config: dict[str, t.Any] = {}
if config_file_path.exists() and config_file_path.is_file():
try:
with config_file_path.open(encoding="utf-8") as f:
raw_config = yaml.safe_load(f) or {}
if not isinstance(raw_config, dict):
log.error(
f"Config file {config_file_path} is not a valid YAML dictionary. "
"Aborting.",
)
return
except Exception:
log.exception(f"Error loading YAML from {config_file_path}. Aborting.")
if log.isEnabledFor(logging.DEBUG):
import traceback

traceback.print_exc()
return
else:
log.info(
f"Config file {config_file_path} not found. A new one will be created.",
)

# Determine base directory key
if base_dir:
# Use explicit base directory
base_dir_key = base_dir if base_dir.endswith("/") else base_dir + "/"
elif path:
# Infer from provided path
repo_path = pathlib.Path(path).expanduser().resolve()
try:
# Try to make it relative to home
base_dir_key = "~/" + str(repo_path.relative_to(pathlib.Path.home())) + "/"
except ValueError:
# Use absolute path
base_dir_key = str(repo_path) + "/"
else:
# Default to current directory
base_dir_key = "./"

# Ensure base directory key exists in config
if base_dir_key not in raw_config:
raw_config[base_dir_key] = {}
elif not isinstance(raw_config[base_dir_key], dict):
log.error(
f"Configuration section '{base_dir_key}' is not a dictionary. Aborting.",
)
return

# Check if repo already exists
if name in raw_config[base_dir_key]:
existing_config = raw_config[base_dir_key][name]
# Handle both string and dict formats
current_url: str
if isinstance(existing_config, str):
current_url = existing_config
elif isinstance(existing_config, dict):
repo_value = existing_config.get("repo")
url_value = existing_config.get("url")
current_url = repo_value or url_value or "unknown"
else:
current_url = str(existing_config)

log.warning(
f"Repository '{name}' already exists under '{base_dir_key}'. "
f"Current URL: {current_url}. "
f"To update, remove and re-add, or edit the YAML file manually.",
)
return

# Add the repository in verbose format
raw_config[base_dir_key][name] = {"repo": url}

# Save config
try:
save_config_yaml(config_file_path, raw_config)
log.info(
f"{Fore.GREEN}✓{Style.RESET_ALL} Successfully added "
f"{Fore.CYAN}'{name}'{Style.RESET_ALL} "
f"({Fore.YELLOW}{url}{Style.RESET_ALL}) to "
f"{Fore.BLUE}{config_file_path}{Style.RESET_ALL} under "
f"'{Fore.MAGENTA}{base_dir_key}{Style.RESET_ALL}'.",
)
except Exception:
log.exception(f"Error saving config to {config_file_path}")
if log.isEnabledFor(logging.DEBUG):
import traceback

traceback.print_exc()
raise
Loading