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
+138
View File
@@ -0,0 +1,138 @@
"""Gitea integration push/pull between canonical repo and Gitea's on-disk repo."""
import logging
import os
import subprocess
from pathlib import Path
from typing import Optional
from .mirror import Mirror
logger = logging.getLogger(__name__)
class GiteaError(Exception):
pass
def _get_gitea_repo_path(mirror: Mirror) -> Path:
"""Return the filesystem path to Gitea's bare repo for this mirror."""
try:
return Path(mirror.config.gitea.repo_dir)
except Exception as e:
raise GiteaError(str(e))
# ─── Git subprocess helper ─────────────────────────────────────
def _git(*args: str, timeout: int = 300, check: bool = True) -> str:
"""Run a git command (uses CWD for repo discovery)."""
try:
r = subprocess.run(
["git"] + list(args),
capture_output=True,
text=True,
timeout=timeout,
env={**os.environ, "LC_ALL": "C"},
)
except FileNotFoundError:
raise GiteaError("git not found on PATH")
except subprocess.TimeoutExpired:
raise GiteaError(f"git timed out: {' '.join(args)}")
if check and r.returncode != 0:
raise GiteaError(
f"git failed (exit {r.returncode}): "
f"git {' '.join(args)}\n{r.stderr.strip()}"
)
return r.stdout.strip() if r.returncode == 0 else ""
# ─── Push canonical → Gitea ───────────────────────────────────
def push_to_gitea(mirror: Mirror) -> bool:
"""Push all heads and tags from the canonical repo to Gitea's bare repo.
Returns True if anything was pushed.
"""
gitea_path = _get_gitea_repo_path(mirror)
if not gitea_path.exists():
logger.warning("Gitea repo not found at %s skipping push", gitea_path)
return False
canonical = str(mirror.canonical_dir)
# Check if there's anything to push by comparing refs
out = _git("--git-dir", canonical, "for-each-ref",
"--format=%(objectname) %(refname)",
"refs/heads/", "refs/tags/",
timeout=30, check=False)
if out:
has_new = False
for line in out.splitlines():
line = line.strip()
if not line:
continue
obj_hash, ref = line.split(None, 1)
gitea_hash = _git("--git-dir", str(gitea_path), "rev-parse",
"--verify", "--quiet", ref,
timeout=30, check=False)
if obj_hash != gitea_hash:
has_new = True
break
if not has_new:
logger.debug("No new commits to push to Gitea")
return False
logger.info("Pushing canonical → Gitea (%s) …", gitea_path)
# Use `git push --prune` with explicit refspecs to mirror branches and tags
_git(
"--git-dir", canonical, "push", "--prune",
str(gitea_path),
"+refs/heads/*:refs/heads/*",
"+refs/tags/*:refs/tags/*",
timeout=300,
)
logger.info("Push to Gitea complete")
return True
# ─── Fetch Gitea → canonical ──────────────────────────────────
def fetch_from_gitea(mirror: Mirror, ref: Optional[str] = None) -> bool:
"""Fetch refs from Gitea's bare repo into the canonical repo.
If *ref* is given (e.g. ``refs/heads/master``), only that ref is
fetched. Otherwise all heads are fetched.
Returns True if anything new was fetched.
"""
gitea_path = _get_gitea_repo_path(mirror)
if not gitea_path.exists():
raise GiteaError(f"Gitea repo not found at {gitea_path}")
canonical = str(mirror.canonical_dir)
if ref:
refspec = f"+{ref}:{ref}"
else:
refspec = "+refs/heads/*:refs/heads/*"
logger.info("Fetching %s → canonical …", gitea_path)
_git(
"--git-dir", canonical, "fetch", "--prune",
str(gitea_path), refspec,
timeout=300,
)
# Check if anything changed
out = _git("--git-dir", canonical, "rev-list", "--count",
f"HEAD..FETCH_HEAD", timeout=30, check=False)
if out and out.strip() != "0":
logger.info("Fetched new commits from Gitea")
return True
logger.debug("No new commits from Gitea")
return False