"""
Command-line interface for changes-roller.
"""
import sys
from pathlib import Path
import click
from .config import ConfigParser
from .executor import PatchExecutor
from .reporter import Reporter
@click.group()
@click.version_option(version="0.1.0", prog_name="roller")
[docs]
def cli() -> None:
"""
changes-roller: A tool for creating and managing coordinated patch series
across multiple Git repositories.
"""
pass
@cli.command()
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default="series.ini",
help="Output file path (default: series.ini)",
)
@click.option("--force", "-f", is_flag=True, help="Overwrite existing file")
[docs]
def init(output: Path, force: bool) -> None:
"""
Generate a template configuration file.
Creates a new configuration file with all available options documented
and example values. Edit this file to customize your patch series.
Example:
roller init
roller init --output my-series.ini
roller init --output config.ini --force
"""
# Check if file already exists
if output.exists() and not force:
click.echo(
f"Error: File '{output}' already exists. Use --force to overwrite.",
err=True,
)
sys.exit(1)
# Template configuration
template = """# changes-roller Configuration File
# This file defines a patch series to apply across multiple repositories
[SERIE]
# List of Git repository URLs (comma-separated, can span multiple lines)
# Examples:
# - https://github.com/org/repo.git
# - git@github.com:org/repo.git
# - /path/to/local/repo
projects = https://github.com/org/repo1,
https://github.com/org/repo2,
https://github.com/org/repo3
# Path to the patch script (must be executable)
# This script will be executed in each repository's directory
commands = ./patch.sh
# Commit message template
# Use {{ project_name }} to insert the repository name
commit_msg = Update dependencies in {{ project_name }}
This patch updates the project dependencies to their
latest versions for improved security and performance.
# Optional: Gerrit topic for grouping related patches
# Leave empty if not using Gerrit
topic = dependency-updates-2025
# Automatically commit changes (default: true)
# Set to false to only apply patches without committing
commit = true
# Submit to Gerrit for code review (default: false)
# Requires git-review to be installed and configured
review = false
# Optional: Target branch to switch to before applying changes
# branch = stable/1.x
# Optional: Create branch if it doesn't exist (default: false)
# Requires 'branch' to be set
# create_branch = false
# Optional: Stay on target branch after completion (default: false)
# By default, returns to original branch
# stay_on_branch = false
# Optional: Commands to run before applying changes (one per line)
# WARNING: Commands from config files can be dangerous. Only use trusted configs.
# pre_commands = git pull origin main
# pytest tests/
# Optional: Commands to run after applying changes (one per line)
# post_commands = git push origin feature-branch
# Optional: Continue processing if commands fail (default: false)
# continue_on_error = false
# Optional: Preview operations without executing (default: false)
# dry_run = false
[TESTS]
# Run tests before committing (default: false)
run = false
# Fail the patch if tests fail (default: false)
# - true: Tests must pass for commit to be created
# - false: Tests run but failures are warnings only
blocking = false
# Command to execute for running tests (default: tox)
# Examples: tox, pytest, npm test, make test, ./run_tests.sh
command = tox
"""
try:
output.write_text(template)
click.echo(f"Created configuration file: {output}")
click.echo("\nNext steps:")
click.echo(f" 1. Edit {output} to customize your patch series")
click.echo(" 2. Create your patch script (e.g., patch.sh)")
click.echo(f" 3. Run: roller create --config-file {output}")
except Exception as e:
click.echo(f"Error creating file: {e}", err=True)
sys.exit(1)
@cli.command()
@click.option(
"--config-file",
type=click.Path(exists=True, path_type=Path),
required=True,
help="Path to configuration file",
)
@click.option(
"--config-dir",
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Additional directory for config files (optional)",
)
@click.option(
"-e", "--exit-on-error", is_flag=True, help="Exit immediately on first failure"
)
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
@click.option(
"--branch",
type=str,
help="Target branch to switch to before applying changes",
)
@click.option(
"--create-branch",
is_flag=True,
help="Create branch if it doesn't exist (requires --branch)",
)
@click.option(
"--stay-on-branch",
is_flag=True,
help="Don't return to original branch after completion",
)
@click.option(
"--pre-command",
multiple=True,
help="Command to execute before applying changes (repeatable)",
)
@click.option(
"--post-command",
multiple=True,
help="Command to execute after applying changes (repeatable)",
)
@click.option(
"--continue-on-error",
is_flag=True,
help="Continue if commands fail instead of stopping",
)
@click.option(
"--dry-run",
is_flag=True,
help="Preview operations without executing them",
)
[docs]
def create(
config_file: Path,
config_dir: Path,
exit_on_error: bool,
verbose: bool,
branch: str | None,
create_branch: bool,
stay_on_branch: bool,
pre_command: tuple[str, ...],
post_command: tuple[str, ...],
continue_on_error: bool,
dry_run: bool,
) -> None:
"""
Create a new patch series across multiple repositories.
This command reads a configuration file, clones the specified repositories,
applies patch scripts, runs tests, creates commits, and optionally submits
them for code review.
Example:
roller create --config-file my-series.ini
roller create --config-file my-series.ini --branch stable/1.x
roller create --config-file my-series.ini --pre-command "git pull" --post-command "git push"
"""
try:
# Validate options
if create_branch and not branch:
click.echo(
"Error: --create-branch requires --branch to be specified", err=True
)
sys.exit(1)
# Parse configuration
parser = ConfigParser(config_file)
config = parser.parse()
# Override config with CLI options
if branch is not None:
config.branch = branch
if create_branch:
config.create_branch = True
if stay_on_branch:
config.stay_on_branch = True
if pre_command:
# CLI pre-commands are added to config file pre-commands
config.pre_commands = list(pre_command) + config.pre_commands
if post_command:
# CLI post-commands are added to config file post-commands
config.post_commands = list(post_command) + config.post_commands
if continue_on_error:
config.continue_on_error = True
if dry_run:
config.dry_run = True
# Create reporter
reporter = Reporter(verbose=verbose)
# Execute patch series
executor = PatchExecutor(config, reporter, exit_on_error)
success = executor.execute()
# Exit with appropriate code
sys.exit(0 if success else 1)
except FileNotFoundError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except ValueError as e:
click.echo(f"Configuration error: {e}", err=True)
sys.exit(1)
except KeyboardInterrupt:
click.echo("\n\nInterrupted by user", err=True)
sys.exit(130)
except Exception as e:
click.echo(f"Unexpected error: {e}", err=True)
if verbose:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
cli()