mod config; mod git; mod interactive; mod logging; mod provider; mod sync; mod webhook; use std::env; use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand}; use crate::config::{Config, default_config_path}; use crate::sync::{DEFAULT_JOBS, SyncOptions, sync_all}; use crate::webhook::{ ServeOptions, WebhookInstallOptions, WebhookUninstallOptions, install_webhooks, serve, uninstall_webhooks, }; #[derive(Parser, Debug)] #[command(name = "git-sync")] #[command(about = "Mirror repositories between Git hosting providers")] struct Cli { #[arg(long, global = true, value_name = "PATH")] config: Option, #[command(subcommand)] command: Command, } #[derive(Subcommand, Debug)] enum Command { /// Run the interactive configuration wizard Config, /// Sync configured mirror groups once Sync(SyncCommand), /// Run the webhook receiver Serve(ServeCommand), /// Install or uninstall repository webhooks #[command(subcommand)] Webhook(WebhookCommand), } #[derive(Args, Debug)] struct SyncCommand { #[arg(long, value_name = "NAME")] group: Option, #[arg(long)] dry_run: bool, #[arg(long)] no_create: bool, #[arg(long)] force: bool, #[arg(long, value_name = "REGEX")] repo_pattern: Option, #[arg(long)] retry_failed: bool, #[arg(long, value_name = "PATH")] work_dir: Option, #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] jobs: usize, } #[derive(Args, Debug)] struct ServeCommand { #[arg(long, default_value = "127.0.0.1:8787", value_name = "HOST:PORT")] listen: String, #[arg(long, conflicts_with = "secret_env")] secret: Option, #[arg(long, value_name = "ENV", conflicts_with = "secret")] secret_env: Option, #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] jobs: usize, #[arg(long, value_name = "PATH")] work_dir: Option, #[arg(long, value_name = "MINUTES")] full_sync_interval_minutes: Option, } #[derive(Subcommand, Debug)] enum WebhookCommand { Install(WebhookInstallCommand), Uninstall(WebhookUninstallCommand), } #[derive(Args, Debug)] struct WebhookInstallCommand { #[arg(long, value_name = "URL")] url: Option, #[arg(long, conflicts_with = "secret_env")] secret: Option, #[arg(long, value_name = "ENV", conflicts_with = "secret")] secret_env: Option, #[arg(long, value_name = "NAME")] group: Option, #[arg(long, value_name = "REGEX")] repo_pattern: Option, #[arg(long)] dry_run: bool, #[arg(long, value_name = "PATH")] work_dir: Option, } #[derive(Args, Debug)] struct WebhookUninstallCommand { #[arg(long, value_name = "NAME")] group: Option, #[arg(long)] dry_run: bool, #[arg(long, value_name = "PATH")] work_dir: Option, } fn main() -> Result<()> { let cli = Cli::parse(); let config_path = cli.config.unwrap_or_else(default_config_path); match cli.command { Command::Config => interactive::run_config_wizard(&config_path), Command::Sync(command) => { let config = Config::load(&config_path) .with_context(|| format!("failed to load config at {}", config_path.display()))?; sync_all( &config, SyncOptions { group: command.group, dry_run: command.dry_run, create_missing_override: command.no_create.then_some(false), force_override: command.force.then_some(true), repo_pattern: command.repo_pattern, retry_failed: command.retry_failed, work_dir: command.work_dir, jobs: command.jobs, }, ) } Command::Serve(command) => { let config = Config::load(&config_path) .with_context(|| format!("failed to load config at {}", config_path.display()))?; let full_sync_interval_minutes = command.full_sync_interval_minutes.or_else(|| { config .webhook .as_ref() .and_then(|webhook| webhook.full_sync_interval_minutes) }); let reachability_url = config.webhook.as_ref().map(|webhook| webhook.url.clone()); let reachability_check_interval_minutes = config .webhook .as_ref() .and_then(|webhook| webhook.reachability_check_interval_minutes); let secret = resolve_webhook_secret(&config, command.secret, command.secret_env)?; serve( config, ServeOptions { listen: command.listen, secret, workers: command.jobs, work_dir: command.work_dir, full_sync_interval_minutes, reachability_url, reachability_check_interval_minutes, }, ) } Command::Webhook(WebhookCommand::Install(command)) => { let config = Config::load(&config_path) .with_context(|| format!("failed to load config at {}", config_path.display()))?; let secret = resolve_webhook_secret(&config, command.secret, command.secret_env)?; let url = resolve_webhook_url(&config, command.url)?; install_webhooks( &config, WebhookInstallOptions { url, secret, group: command.group, repo_pattern: command.repo_pattern, dry_run: command.dry_run, work_dir: command.work_dir, }, ) } Command::Webhook(WebhookCommand::Uninstall(command)) => { let config = Config::load(&config_path) .with_context(|| format!("failed to load config at {}", config_path.display()))?; uninstall_webhooks( &config, WebhookUninstallOptions { group: command.group, dry_run: command.dry_run, work_dir: command.work_dir, }, ) } } } fn resolve_webhook_secret( config: &Config, value: Option, env_name: Option, ) -> Result { match (value, env_name) { (Some(value), None) => Ok(value), (None, Some(env_name)) => env::var(&env_name) .with_context(|| format!("environment variable {env_name} is not set")), (None, None) => config .webhook .as_ref() .map(|webhook| webhook.secret()) .transpose()? .ok_or_else(|| anyhow::anyhow!("pass either --secret or --secret-env")), (Some(_), Some(_)) => unreachable!("clap enforces secret conflicts"), } } fn resolve_webhook_url(config: &Config, value: Option) -> Result { value .or_else(|| config.webhook.as_ref().map(|webhook| webhook.url.clone())) .ok_or_else(|| anyhow::anyhow!("pass --url or configure [webhook].url")) } #[cfg(test)] mod tests { use super::*; #[test] fn cli_config_opens_wizard() { let cli = Cli::try_parse_from(["git-sync", "config"]).unwrap(); assert!(matches!(cli.command, Command::Config)); } #[test] fn cli_rejects_removed_config_subcommands() { for args in [ ["git-sync", "config", "wizard"].as_slice(), ["git-sync", "config", "init"].as_slice(), ["git-sync", "config", "show"].as_slice(), ["git-sync", "config", "site", "list"].as_slice(), ["git-sync", "config", "mirror", "list"].as_slice(), ] { assert!(Cli::try_parse_from(args).is_err()); } } #[test] fn cli_accepts_sync_repo_pattern() { let cli = Cli::try_parse_from([ "git-sync", "sync", "--repo-pattern", "^(foo|bar)-", "--dry-run", ]) .unwrap(); let Command::Sync(args) = cli.command else { panic!("parsed unexpected command"); }; assert_eq!(args.repo_pattern, Some("^(foo|bar)-".to_string())); assert!(args.dry_run); } #[test] fn cli_accepts_sync_retry_failed() { let cli = Cli::try_parse_from(["git-sync", "sync", "--retry-failed"]).unwrap(); let Command::Sync(args) = cli.command else { panic!("parsed unexpected command"); }; assert!(args.retry_failed); } #[test] fn cli_accepts_sync_jobs() { let cli = Cli::try_parse_from(["git-sync", "sync", "--jobs", "8"]).unwrap(); let Command::Sync(args) = cli.command else { panic!("parsed unexpected command"); }; assert_eq!(args.jobs, 8); } #[test] fn cli_accepts_webhook_serve() { let cli = Cli::try_parse_from([ "git-sync", "serve", "--listen", "127.0.0.1:9000", "--secret-env", "WEBHOOK_SECRET", "--jobs", "2", "--full-sync-interval-minutes", "30", ]) .unwrap(); let Command::Serve(args) = cli.command else { panic!("parsed unexpected command"); }; assert_eq!(args.listen, "127.0.0.1:9000"); assert_eq!(args.secret_env, Some("WEBHOOK_SECRET".to_string())); assert_eq!(args.jobs, 2); assert_eq!(args.full_sync_interval_minutes, Some(30)); } #[test] fn cli_accepts_webhook_install() { let cli = Cli::try_parse_from([ "git-sync", "webhook", "install", "--url", "https://mirror.example.test/webhook", "--secret", "secret", "--group", "sync-1", "--repo-pattern", "^repo$", ]) .unwrap(); let Command::Webhook(WebhookCommand::Install(args)) = cli.command else { panic!("parsed unexpected command"); }; assert_eq!( args.url, Some("https://mirror.example.test/webhook".to_string()) ); assert_eq!(args.secret, Some("secret".to_string())); assert_eq!(args.group, Some("sync-1".to_string())); assert_eq!(args.repo_pattern, Some("^repo$".to_string())); } #[test] fn cli_accepts_webhook_uninstall() { let cli = Cli::try_parse_from([ "git-sync", "webhook", "uninstall", "--group", "sync-1", "--dry-run", ]) .unwrap(); let Command::Webhook(WebhookCommand::Uninstall(args)) = cli.command else { panic!("parsed unexpected command"); }; assert_eq!(args.group, Some("sync-1".to_string())); assert!(args.dry_run); } }