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
e79c9d7
test(helpers) Add `save_config_yaml()`
tony May 10, 2025
97bbc13
!squash initial: `add` and `add-from-fs`
tony May 10, 2025
af98f82
!squash wip
tony May 10, 2025
a3276ad
!suqash wip
tony May 10, 2025
f4643a1
!squash more
tony May 10, 2025
c928729
!squash wip
tony May 10, 2025
8bc05eb
!squash more
tony May 10, 2025
6fb91f0
!squash rm tests/cli/test_*.py
tony May 10, 2025
89c1414
`git restore --source=origin/master tests src`
tony May 10, 2025
8434989
Fix: Update sync function to accept optional config parameter
tony May 10, 2025
ce3e012
Fix: Resolve test issues and add missing imports
tony Jun 19, 2025
fa62658
Fix: Ensure config parameter is always passed to sync()
tony Jun 19, 2025
bf56390
style: Fix code style violations (Phase 1)
tony Jun 19, 2025
718eab2
refactor: Centralize save_config_yaml function (Phase 3)
tony Jun 19, 2025
bd13d0d
refactor: Simplify add command argument parsing (Phase 2)
tony Jun 19, 2025
26e43cd
cli(refactor[add/add_from_fs]): Complete refactoring to match vcspull…
tony Jun 19, 2025
e88beca
cli/add_from_fs(fix[variable-redefinition]): Remove duplicate type an…
tony Jun 19, 2025
690b2b7
cli/add_from_fs(feat[output]): Add detailed reporting of existing rep…
tony Jun 19, 2025
ba6c983
tests/add_from_fs(feat[enhanced-output]): Add comprehensive tests for…
tony Jun 19, 2025
e4161e3
log(feat[simple-formatter]): Add clean output formatter for CLI add c…
tony Jun 19, 2025
445336f
tests(feat[test_log]): Add comprehensive tests for vcspull logging ut…
tony Jun 19, 2025
ca9dc4f
cli(fix[error-handling]): Use logging.exception for better error repo…
tony Jun 19, 2025
794d27e
tests/add_from_fs(style[code-quality]): Fix formatting and style issues
tony Jun 19, 2025
e717604
log(feat[cli-sync-formatter]): Add SimpleLogFormatter to CLI sync for…
tony Jun 19, 2025
fe1f515
tests/cli(fix[output-capture]): Fix CLI test output capture to includ…
tony Jun 19, 2025
62f9f6f
tests/log(feat[sync-logger-tests]): Add comprehensive tests for CLI s…
tony Jun 19, 2025
4525405
cli/add_from_fs(style): Fix line length violations in colorized output
tony Jun 20, 2025
ddfde4a
cli/__init__(refactor[create_parser]): Simplify parser return handling
tony Jun 22, 2025
c4f60c0
cli/add_from_fs(feat[UX]): Improve output for many existing repositories
tony Jun 22, 2025
7584fd3
cli/add(feat[config-format]): Use verbose repo format for new configs
tony Jun 22, 2025
6066641
cli/add(fix[duplicate-check]): Handle both config formats when checki…
tony Jun 22, 2025
40c47a6
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