Files
svn-git-server/svn_mirror/cli.py
T
uberwald 45fb8c40d0 feat: initial import — SVN↔Git bi-directional mirror for Gitea
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).
2026-06-23 09:47:02 +02:00

325 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()