Server-side sidecar daemon. Uses own SQLite mapping table (not git svn) so Git commit hashes never change. Pure Python, zero framework deps. Includes: - SVN→Git sync engine with nested-directory tree reconstruction - Git→SVN push with author mapping and working-copy management - Branch/tag sync (svn copy → git branch/tag and reverse) - Gitea integration (on-disk push/fetch, webhook receiver) - Reconciliation tool for divergence recovery (assess + auto-fix) - Full end-to-end regression test (bash, 34 checks) - systemd unit and deployment docs Only external dependency: PyYAML (config parsing).
325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""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()
|