"""CLI entry point for svn-mirror.""" import argparse import logging import sys from pathlib import Path from .config import AppConfig, ConfigError from .mirror import Mirror, MirrorError from .gitea import push_to_gitea, GiteaError from .git_to_svn import sync_git_to_svn, GitToSVNError from .sync import sync_all, run_daemon_loop from .reconcile import reconcile, assess as reconcile_assess, print_assessment, ReconcileError from .webhook import run_webhook_server def setup_logging(verbose: bool = False): level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( level=level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) def cmd_create(args): config = AppConfig.load(args.config) mirrors = [config.mirrors[mid] for mid in (args.mirror and [args.mirror] or config.mirrors)] for mc in mirrors: m = Mirror(mc, Path(config.data_dir)) try: m.create() print(f" ✓ {mc.id}") except MirrorError as e: print(f" ✗ {mc.id}: {e}", file=sys.stderr) sys.exit(1) def cmd_init(args): config = AppConfig.load(args.config) mirrors = [config.mirrors[mid] for mid in (args.mirror and [args.mirror] or config.mirrors)] for mc in mirrors: m = Mirror(mc, Path(config.data_dir)) try: m.init_import() print(f" ✓ {mc.id}") except MirrorError as e: print(f" ✗ {mc.id}: {e}", file=sys.stderr) sys.exit(1) def cmd_destroy(args): config = AppConfig.load(args.config) mc = config.mirrors.get(args.mirror) if not mc: print(f"Error: mirror '{args.mirror}' not found in config", file=sys.stderr) sys.exit(1) m = Mirror(mc, Path(config.data_dir)) try: m.destroy() print(f" ✓ {args.mirror}") except MirrorError as e: print(f" ✗ {args.mirror}: {e}", file=sys.stderr) sys.exit(1) def cmd_status(args): config = AppConfig.load(args.config) mirrors = [config.mirrors[mid] for mid in (args.mirror and [args.mirror] or config.mirrors)] for mc in mirrors: m = Mirror(mc, Path(config.data_dir)) s = m.status() _print_status(s) def _print_status(s: dict): print(f"Mirror: {s['id']}") print(f" Status: {s['status']}") print(f" SVN URL: {s['svn_url']}") print(f" Enabled: {s.get('enabled', True)}") if s['status'] == 'not_created': print(f" (not yet created on disk)") return if s.get('imported'): print(f" Import: done") print(f" Last SVN: {s.get('last_svn_revision', '?')}") print(f" Mappings: {s.get('mapping_count', 0)}") else: print(f" Import: pending") print(f" Created: {s.get('created_at', '?')}") print(f" Last sync: {s.get('last_sync_at', 'never')}") print(f" Location: {s.get('base_dir', '?')}") print() def cmd_sync(args): """Run a sync cycle for each mirror.""" config = AppConfig.load(args.config) mirrors = [config.mirrors[mid] for mid in (args.mirror and [args.mirror] or config.mirrors)] for mc in mirrors: m = Mirror(mc, Path(config.data_dir)) if not m.exists: print(f" - {mc.id}: not created yet", file=sys.stderr) continue state = m._read_state() if not state.get("initial_import_done"): print(f" - {mc.id}: import not done yet", file=sys.stderr) continue try: total = 0 svn_count = 0 git_count = 0 if args.direction in ("both", "svn-to-git"): svn_count = sync_all(m) total += svn_count if svn_count: try: push_to_gitea(m) except GiteaError as ge: print(f" ⚠ {mc.id}: push to Gitea failed: {ge}", file=sys.stderr) if args.direction in ("both", "git-to-svn"): git_count = sync_git_to_svn(m) total += git_count if svn_count: print(f" → {mc.id}: {svn_count} SVN rev(s) synced to Git") if git_count: print(f" ← {mc.id}: {git_count} Git commit(s) pushed to SVN") if not total: print(f" ✓ {mc.id}: up to date") except Exception as e: print(f" ✗ {mc.id}: {e}", file=sys.stderr) def cmd_push(args): """Run a single Git→SVN push cycle for each mirror.""" config = AppConfig.load(args.config) mirrors = [config.mirrors[mid] for mid in (args.mirror and [args.mirror] or config.mirrors)] for mc in mirrors: m = Mirror(mc, Path(config.data_dir)) if not m.exists: print(f" - {mc.id}: not created yet", file=sys.stderr) continue state = m._read_state() if not state.get("initial_import_done"): print(f" - {mc.id}: import not done yet", file=sys.stderr) continue try: count = sync_git_to_svn(m) if count: print(f" ✓ {mc.id}: {count} Git commit(s) pushed to SVN") else: print(f" ✓ {mc.id}: up to date") except Exception as e: print(f" ✗ {mc.id}: {e}", file=sys.stderr) def cmd_webhook(args): """Start the Gitea webhook receiver server.""" config = AppConfig.load(args.config) mirrors = {} for mid, mc in config.mirrors.items(): if not mc.enabled: continue m = Mirror(mc, Path(config.data_dir)) mirrors[mid] = m if not mirrors: print("No enabled mirrors found", file=sys.stderr) sys.exit(1) # Use first mirror's webhook config (they should all be the same host/port) first = next(iter(mirrors.values())) host = first.config.gitea.webhook_host port = first.config.gitea.webhook_port print(f"Starting webhook server on {host}:{port} …") run_webhook_server(mirrors, host=host, port=port) def cmd_daemon(args): """Run continuous sync daemon.""" config = AppConfig.load(args.config) mirrors = {} for mid, mc in config.mirrors.items(): if not mc.enabled: continue m = Mirror(mc, Path(config.data_dir)) mirrors[mid] = m if not mirrors: print("No enabled mirrors found", file=sys.stderr) sys.exit(1) run_daemon_loop(mirrors) def cmd_reconcile(args): """Reconcile a diverged mirror (both sides have commits).""" config = AppConfig.load(args.config) mc = config.mirrors.get(args.mirror) if not mc: print(f"Error: mirror '{args.mirror}' not found in config", file=sys.stderr) sys.exit(1) m = Mirror(mc, Path(config.data_dir)) if not m.exists: print(f"Error: mirror '{args.mirror}' not created yet", file=sys.stderr) sys.exit(1) state = m._read_state() if not state.get("initial_import_done"): print(f"Error: mirror '{args.mirror}' import not done yet", file=sys.stderr) sys.exit(1) try: if args.assess: report = reconcile_assess(m) print_assessment(report) else: strategy = "git-wins" if args.merge: strategy = "merge" rev = reconcile(m, strategy=strategy, dry_run=args.dry_run) if rev: print(f" ✓ Reconciliation commit: SVN r{rev}") except ReconcileError as e: print(f" ✗ {e}", file=sys.stderr) sys.exit(1) def cmd_check(args): """Validate that the config is parseable and git svn is available.""" try: config = AppConfig.load(args.config) print(f"Config OK: {len(config.mirrors)} mirror(s) defined") print(f" Data dir: {config.data_dir}") for mid, mc in config.mirrors.items(): print(f" Mirror '{mid}': {mc.svn.url} → {mc.gitea.owner}/{mc.gitea.repo}") except ConfigError as e: print(f"Config error: {e}", file=sys.stderr) sys.exit(1) # optional: check git svn import subprocess try: r = subprocess.run(["git", "svn", "--version"], capture_output=True, text=True, timeout=30) if r.returncode == 0: print(f"git svn: {r.stdout.strip()}") else: print(f"git svn: NOT available (exit {r.returncode})", file=sys.stderr) except FileNotFoundError: print(f"git svn: NOT installed", file=sys.stderr) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="gitea-svn-mirror – Subgit-like SVN↔Git mirror daemon", ) parser.add_argument( "--config", "-c", default="/etc/svn-mirror/config.yml", help="Config file path (default: %(default)s)", ) parser.add_argument( "--verbose", "-v", action="store_true", help="Verbose output (debug logging)", ) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("create", help="Create mirror directory structure and DB") p.add_argument("--mirror", "-m", help="Mirror ID (omit for all)") p.set_defaults(func=cmd_create) p = sub.add_parser("init", help="Run initial SVN import via git svn clone") p.add_argument("--mirror", "-m", help="Mirror ID (omit for all)") p.set_defaults(func=cmd_init) p = sub.add_parser("destroy", help="Delete all mirror data from disk") p.add_argument("--mirror", "-m", required=True, help="Mirror ID") p.set_defaults(func=cmd_destroy) p = sub.add_parser("status", help="Show mirror status") p.add_argument("--mirror", "-m", help="Mirror ID (omit for all)") p.set_defaults(func=cmd_status) p = sub.add_parser("sync", help="Bidirectional sync (SVN→Git + Git→SVN)") p.add_argument("--mirror", "-m", help="Mirror ID (omit for all)") p.add_argument("--direction", choices=["svn-to-git", "git-to-svn", "both"], default="both", help="Sync direction (default: both)") p.set_defaults(func=cmd_sync) p = sub.add_parser("push", help="Run a single Git→SVN push cycle") p.add_argument("--mirror", "-m", help="Mirror ID (omit for all)") p.set_defaults(func=cmd_push) p = sub.add_parser("daemon", help="Run continuous sync daemon") p.set_defaults(func=cmd_daemon) p = sub.add_parser("webhook", help="Start Gitea webhook receiver") p.set_defaults(func=cmd_webhook) p = sub.add_parser("reconcile", help="Reconcile diverged SVN↔Git mirror") p.add_argument("--mirror", "-m", required=True, help="Mirror ID") p.add_argument("--dry-run", "-n", action="store_true", help="Show assessment without making changes") p.add_argument("--assess", action="store_true", help="Show divergence assessment report") p.add_argument("--merge", action="store_true", help="Use three-way merge for conflicting files (experimental)") p.set_defaults(func=cmd_reconcile) p = sub.add_parser("check", help="Validate config and prerequisites") p.set_defaults(func=cmd_check) return parser def main(): parser = build_parser() args = parser.parse_args() setup_logging(args.verbose) args.func(args) if __name__ == "__main__": main()