Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,26 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force

## vcspull v1.36.x (unreleased)

- _Notes on upcoming releases will be added here_

<!-- Maintainers, insert changes / features for the next release here -->

### New features

#### New command: `vcspull add` (#465)

- Register a single repository in the active configuration, with optional `--dir`/`--path` helpers for base-directory detection.

#### New command: `vcspull add-from-fs` (#465)

- Scan a directory of git repositories (recursively with `--recursive`) and append new remotes to your config, with an interactive confirmation prompt or `--yes` for unattended runs.

#### New command: `vcspull fmt` (#465)

- Normalize configuration files by expanding compact entries to `{repo: ...}`, sorting directories/repos, and standardizing keys; pair with `--write` to persist the formatted output.

### Bug fixes

- Restore CLI logging output after the logging refactor by replacing the `NullHandler` placeholder with a stdout stream handler and adding regression coverage for the new commands (#465).

## vcspull v1.35.0 (2025-09-04)

### Development
Expand Down
2 changes: 1 addition & 1 deletion src/vcspull/_internal/config_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]:
{'session_name': 'my session'}
"""
assert isinstance(path, pathlib.Path)
content = path.open().read()
content = path.open(encoding="utf-8").read()

if path.suffix in {".yaml", ".yml"}:
fmt: FormatLiteral = "yaml"
Expand Down
57 changes: 54 additions & 3 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,9 @@
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 .fmt import create_fmt_subparser, format_config_file
from .sync import create_sync_subparser, sync

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -73,14 +77,43 @@ 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)

fmt_parser = subparsers.add_parser(
"fmt",
help="format vcspull configuration files",
formatter_class=argparse.RawDescriptionHelpFormatter,
description="Format vcspull configuration files for consistency. "
"Normalizes compact format to verbose format, standardizes on 'repo' key, "
"and sorts directories and repositories alphabetically.",
)
create_fmt_subparser(fmt_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, fmt_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, _fmt_parser = subparsers
args = parser.parse_args(_args)

setup_logger(log=log, level=args.log_level.upper())
Expand All @@ -91,7 +124,25 @@ def cli(_args: list[str] | None = None) -> None:
if args.subparser_name == "sync":
sync(
repo_patterns=args.repo_patterns,
config=args.config,
config=pathlib.Path(args.config) if args.config else None,
exit_on_error=args.exit_on_error,
parser=sync_parser,
)
elif args.subparser_name == "add":
add_repo(
name=args.name,
url=args.url,
config_file_path_str=args.config,
path=args.path,
base_dir=args.base_dir,
)
elif args.subparser_name == "add-from-fs":
add_from_filesystem(
scan_dir_str=args.scan_dir,
config_file_path_str=args.config,
recursive=args.recursive,
base_dir_key_arg=args.base_dir_key,
yes=args.yes,
)
elif args.subparser_name == "fmt":
format_config_file(args.config, args.write, args.all)
198 changes: 198 additions & 0 deletions src/vcspull/cli/add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Add repository functionality for vcspull."""

from __future__ import annotations

import logging
import pathlib
import traceback
import typing as t

from colorama import Fore, Style

from vcspull._internal.config_reader import ConfigReader
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(
"No config specified and no default found, will create at %s",
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:
loaded_config = ConfigReader._from_file(config_file_path)
except Exception:
log.exception("Error loading YAML from %s. Aborting.", config_file_path)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return

if loaded_config is None:
raw_config = {}
elif isinstance(loaded_config, dict):
raw_config = loaded_config
else:
log.error(
"Config file %s is not a valid YAML dictionary.",
config_file_path,
)
return
else:
log.info(
"Config file %s not found. A new one will be created.",
config_file_path,
)

# 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(
"Configuration section '%s' is not a dictionary. Aborting.",
base_dir_key,
)
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(
"Repository '%s' already exists under '%s'. Current URL: %s. "
"To update, remove and re-add, or edit the YAML file manually.",
name,
base_dir_key,
current_url,
)
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(
"%s✓%s Successfully added %s'%s'%s (%s%s%s) to %s%s%s under '%s%s%s'.",
Fore.GREEN,
Style.RESET_ALL,
Fore.CYAN,
name,
Style.RESET_ALL,
Fore.YELLOW,
url,
Style.RESET_ALL,
Fore.BLUE,
config_file_path,
Style.RESET_ALL,
Fore.MAGENTA,
base_dir_key,
Style.RESET_ALL,
)
except Exception:
log.exception("Error saving config to %s", config_file_path)
if log.isEnabledFor(logging.DEBUG):
traceback.print_exc()
return
Loading