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
d53f9f9
test(helpers) Add `save_config_yaml()`
tony May 10, 2025
26859dd
!squash initial: `add` and `add-from-fs`
tony May 10, 2025
d96bb64
!squash wip
tony May 10, 2025
fc94b03
!suqash wip
tony May 10, 2025
c532039
!squash more
tony May 10, 2025
6fbc0ec
!squash wip
tony May 10, 2025
519606f
!squash more
tony May 10, 2025
2c56b19
!squash rm tests/cli/test_*.py
tony May 10, 2025
1f06c85
`git restore --source=origin/master tests src`
tony May 10, 2025
254d2c7
Fix: Update sync function to accept optional config parameter
tony May 10, 2025
eec31b8
Fix: Resolve test issues and add missing imports
tony Jun 19, 2025
4b4930c
Fix: Ensure config parameter is always passed to sync()
tony Jun 19, 2025
ce2b2ec
style: Fix code style violations (Phase 1)
tony Jun 19, 2025
d413e55
refactor: Centralize save_config_yaml function (Phase 3)
tony Jun 19, 2025
62acdf0
refactor: Simplify add command argument parsing (Phase 2)
tony Jun 19, 2025
2a74625
cli(refactor[add/add_from_fs]): Complete refactoring to match vcspull…
tony Jun 19, 2025
eeb88a3
cli/add_from_fs(fix[variable-redefinition]): Remove duplicate type an…
tony Jun 19, 2025
62dc816
cli/add_from_fs(feat[output]): Add detailed reporting of existing rep…
tony Jun 19, 2025
42d1fa0
tests/add_from_fs(feat[enhanced-output]): Add comprehensive tests for…
tony Jun 19, 2025
d113541
log(feat[simple-formatter]): Add clean output formatter for CLI add c…
tony Jun 19, 2025
90c8115
tests(feat[test_log]): Add comprehensive tests for vcspull logging ut…
tony Jun 19, 2025
fc9efbc
cli(fix[error-handling]): Use logging.exception for better error repo…
tony Jun 19, 2025
405cd5b
tests/add_from_fs(style[code-quality]): Fix formatting and style issues
tony Jun 19, 2025
7c668b0
log(feat[cli-sync-formatter]): Add SimpleLogFormatter to CLI sync for…
tony Jun 19, 2025
109d3b8
tests/cli(fix[output-capture]): Fix CLI test output capture to includ…
tony Jun 19, 2025
51323af
tests/log(feat[sync-logger-tests]): Add comprehensive tests for CLI s…
tony Jun 19, 2025
f3225b9
cli/add_from_fs(style): Fix line length violations in colorized output
tony Jun 20, 2025
55432a0
cli/__init__(refactor[create_parser]): Simplify parser return handling
tony Jun 22, 2025
1412de7
cli/add_from_fs(feat[UX]): Improve output for many existing repositories
tony Jun 22, 2025
d51b7c5
cli/add(feat[config-format]): Use verbose repo format for new configs
tony Jun 22, 2025
db3c5da
cli/add(fix[duplicate-check]): Handle both config formats when checki…
tony Jun 22, 2025
c68f1d5
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