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).
This commit is contained in:
2026-06-23 09:47:02 +02:00
commit 45fb8c40d0
17 changed files with 3654 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
"""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()