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
+142
View File
@@ -0,0 +1,142 @@
"""SQLite mapping database for SVN↔Git revision mapping."""
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
class MappingDB:
def __init__(self, path: Path):
self.path = path
self.conn = sqlite3.connect(str(path))
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA synchronous=NORMAL")
self._init_schema()
def _init_schema(self):
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS svn_to_git (
svn_revision INTEGER NOT NULL,
git_commit_hash TEXT NOT NULL,
svn_branch TEXT NOT NULL,
git_ref TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
source TEXT NOT NULL DEFAULT 'svn'
CHECK (source IN ('svn', 'git')),
UNIQUE(svn_revision, svn_branch)
);
CREATE INDEX IF NOT EXISTS idx_svn_to_git_hash
ON svn_to_git(git_commit_hash);
CREATE INDEX IF NOT EXISTS idx_svn_to_git_svn
ON svn_to_git(svn_revision);
CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO schema_version (version) VALUES (1);
""")
self.conn.commit()
def close(self):
self.conn.close()
# --- Mapping CRUD ---
def record_mapping(
self,
svn_revision: int,
git_commit_hash: str,
svn_branch: str,
git_ref: str,
source: str = "svn",
):
self.conn.execute(
"""INSERT OR REPLACE INTO svn_to_git
(svn_revision, git_commit_hash, svn_branch, git_ref, source)
VALUES (?, ?, ?, ?, ?)""",
(svn_revision, git_commit_hash, svn_branch, git_ref, source),
)
self.conn.commit()
def get_git_hash(self, svn_revision: int, svn_branch: str) -> Optional[str]:
row = self.conn.execute(
"SELECT git_commit_hash FROM svn_to_git WHERE svn_revision=? AND svn_branch=?",
(svn_revision, svn_branch),
).fetchone()
return row[0] if row else None
def get_svn_revision(self, git_commit_hash: str) -> Optional[int]:
row = self.conn.execute(
"SELECT svn_revision FROM svn_to_git WHERE git_commit_hash=?",
(git_commit_hash,),
).fetchone()
return row[0] if row else None
def get_last_svn_revision(self, svn_branch: str = "trunk") -> Optional[int]:
row = self.conn.execute(
"SELECT MAX(svn_revision) FROM svn_to_git WHERE svn_branch=?",
(svn_branch,),
).fetchone()
return row[0] if row and row[0] is not None else None
def mapping_exists(self, svn_revision: int, svn_branch: str) -> bool:
row = self.conn.execute(
"SELECT 1 FROM svn_to_git WHERE svn_revision=? AND svn_branch=?",
(svn_revision, svn_branch),
).fetchone()
return row is not None
def get_all_mappings(self):
return self.conn.execute(
"SELECT svn_revision, git_commit_hash, svn_branch, git_ref, source "
"FROM svn_to_git ORDER BY svn_revision"
).fetchall()
def ref_exists(self, git_ref: str) -> bool:
row = self.conn.execute(
"SELECT 1 FROM svn_to_git WHERE git_ref=?", (git_ref,)
).fetchone()
return row is not None
def get_mapping_by_git_hash(self, git_hash: str) -> Optional[dict]:
row = self.conn.execute(
"SELECT svn_revision, svn_branch, git_ref, source "
"FROM svn_to_git WHERE git_commit_hash=? LIMIT 1",
(git_hash,),
).fetchone()
return dict(row) if row else None
def get_max_revision_across_all_branches(self) -> Optional[int]:
row = self.conn.execute(
"SELECT MAX(svn_revision) FROM svn_to_git",
).fetchone()
return row[0] if row and row[0] is not None else None
def mapping_count(self) -> int:
row = self.conn.execute("SELECT COUNT(*) FROM svn_to_git").fetchone()
return row[0] if row else 0
# --- Sync State ---
def set_state(self, key: str, value: str):
self.conn.execute(
"INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)",
(key, value),
)
self.conn.commit()
def get_state(self, key: str) -> Optional[str]:
row = self.conn.execute(
"SELECT value FROM sync_state WHERE key=?", (key,)
).fetchone()
return row[0] if row else None