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
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env bash
# ─── End-to-end regression test for svn-mirror ─────────────────
# Run from the project root: bash tests/test_e2e.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMP="/tmp/svn-mirror-e2e-$$"
SVN_REPO="$TMP/svn-repo"
GITEA_REPO="$TMP/gitea/testorg/testrepo.git"
MIRROR_DATA="$TMP/mirror-data"
CONFIG="$TMP/config.yml"
PASS=0
FAIL=0
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1"; }
die() { echo "FATAL: $1" >&2; exit 1; }
# ─── 1. Setup SVN repo ─────────────────────────────────────────
setup_svn() {
mkdir -p "$TMP"
svnadmin create "$SVN_REPO"
mkdir -p "$TMP/layout/trunk" "$TMP/layout/branches" "$TMP/layout/tags"
svn import "$TMP/layout" "file://$SVN_REPO" -m "Initial layout" --quiet
rm -rf "$TMP/layout"
svn co "file://$SVN_REPO/trunk" "$TMP/wc" --quiet
echo "v1" > "$TMP/wc/README"
svn add "$TMP/wc/README" --quiet
svn commit -m "Add README v1" "$TMP/wc" --username dev1 --quiet
rm -rf "$TMP/wc"
}
# ─── 2. Setup Gitea bare repo ──────────────────────────────────
setup_gitea() {
mkdir -p "$(dirname "$GITEA_REPO")"
git init --bare "$GITEA_REPO" 2>/dev/null
}
# ─── 3. Write config ───────────────────────────────────────────
write_config() {
cat > "$CONFIG" <<CONF
data_dir: $MIRROR_DATA
mirrors:
- id: test-e2e
enabled: true
svn:
url: file://$SVN_REPO
layout: std
gitea:
owner: testorg
repo: testrepo
repos_path: $TMP/gitea
authors:
"dev1": "Dev One <dev1@example.com>"
sync_interval: 10
CONF
}
# ─── 4. Init (git svn clone) ───────────────────────────────────
test_init() {
python -m svn_mirror --config "$CONFIG" init 2>&1 | grep -q "✓ test-e2e" \
&& ok "init completed" || fail "init failed"
}
# ─── 5. SVN→Git sync ──────────────────────────────────────────
test_svn_to_git() {
# Add SVN commits
svn co "file://$SVN_REPO/trunk" "$TMP/wc" --quiet
echo "v2 from SVN" > "$TMP/wc/README"
svn commit -m "Update README to v2" "$TMP/wc" --username dev1 --quiet
rm -rf "$TMP/wc"
out=$(python -m svn_mirror --config "$CONFIG" sync --direction svn-to-git 2>&1)
echo "$out" | grep -q "SVN rev(s) synced to Git" \
&& ok "SVN→Git sync created commits" || fail "SVN→Git sync failed"
# Verify Git content
git --git-dir="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git" show master:README \
| grep -q "v2 from SVN" \
&& ok "Master README is v2 from SVN" || fail "Master README wrong"
}
# ─── 6. Git→SVN push ──────────────────────────────────────────
test_git_to_svn() {
GIT_DIR="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git"
export GIT_DIR
export GIT_AUTHOR_NAME="Dev One"
export GIT_AUTHOR_EMAIL="dev1@example.com"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
BLOB=$(echo "v3 from Git push" | git hash-object -w --stdin)
TREE=$(echo "100644 blob $BLOB README" | git mktree)
COMMIT=$(git commit-tree $TREE -p master -m "Git-originated change")
git update-ref refs/heads/master "$COMMIT"
unset GIT_DIR GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL
out=$(python -m svn_mirror --config "$CONFIG" push 2>&1)
echo "$out" | grep -q "Git commit(s) pushed to SVN" \
&& ok "Git→SVN push created SVN revision" || fail "Git→SVN push failed"
svn cat "file://$SVN_REPO/trunk/README" | grep -q "v3 from Git push" \
&& ok "SVN README is v3 from Git push" || fail "SVN README wrong"
}
# ─── 7. Branch sync (SVN branch → Git branch) ──────────────────
test_svn_branch() {
svn copy "file://$SVN_REPO/trunk" "file://$SVN_REPO/branches/stable" \
-m "Create stable branch" --username dev1 --quiet
out=$(python -m svn_mirror --config "$CONFIG" sync --direction svn-to-git 2>&1)
echo "$out" | grep -q "SVN rev(s) synced to Git" \
&& ok "Branch synced to Git" || fail "Branch sync failed"
git --git-dir="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git" \
rev-parse --verify --quiet refs/heads/stable >/dev/null \
&& ok "Git refs/heads/stable exists" || fail "Git refs/heads/stable missing"
}
# ─── 8. Tag sync (SVN tag → Git tag) ───────────────────────────
test_svn_tag() {
svn copy "file://$SVN_REPO/trunk" "file://$SVN_REPO/tags/v1.0" \
-m "Tag v1.0" --username dev1 --quiet
out=$(python -m svn_mirror --config "$CONFIG" sync --direction svn-to-git 2>&1)
echo "$out" | grep -q "SVN rev(s) synced to Git" \
&& ok "Tag synced to Git" || fail "Tag sync failed"
git --git-dir="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git" \
rev-parse --verify --quiet refs/tags/v1.0 >/dev/null \
&& ok "Git refs/tags/v1.0 exists" || fail "Git refs/tags/v1.0 missing"
# Verify tag content matches trunk at the SVN revision it was created
TAG_README=$(git --git-dir="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git" show refs/tags/v1.0:README)
MASTER_README=$(git --git-dir="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git" show master:README)
[ "$TAG_README" = "$MASTER_README" ] \
&& ok "Tag v1.0 content matches trunk HEAD" \
|| fail "Tag v1.0 content mismatch"
echo " tag README: $TAG_README, master README: $MASTER_README"
}
# ─── 9. Git tag → SVN tag ──────────────────────────────────────
test_git_tag() {
export GIT_DIR="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git"
git tag v2.0 master
unset GIT_DIR
out=$(python -m svn_mirror --config "$CONFIG" push 2>&1)
echo "$out" | grep -q "Git commit(s) pushed to SVN" \
&& ok "Git tag pushed to SVN" || fail "Git tag push failed"
svn ls "file://$SVN_REPO/tags/" | grep -q "v2.0/" \
&& ok "SVN tag v2.0 exists" || fail "SVN tag v2.0 missing"
svn cat "file://$SVN_REPO/tags/v2.0/README" | grep -q "v3 from Git push" \
&& ok "SVN tag v2.0 content correct" || fail "SVN tag v2.0 content wrong"
}
# ─── 10. Push to Gitea ─────────────────────────────────────────
test_gitea_push() {
# Sync then push to Gitea
out=$(python -m svn_mirror --config "$CONFIG" sync --direction svn-to-git 2>&1)
export GIT_DIR="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git"
git push "$GITEA_REPO" refs/heads/* refs/tags/* 2>&1
unset GIT_DIR
git --git-dir="$GITEA_REPO" rev-parse --verify --quiet refs/heads/master >/dev/null \
&& ok "Gitea has master" || fail "Gitea missing master"
git --git-dir="$GITEA_REPO" rev-parse --verify --quiet refs/heads/stable >/dev/null \
&& ok "Gitea has stable" || fail "Gitea missing stable"
git --git-dir="$GITEA_REPO" rev-parse --verify --quiet refs/tags/v1.0 >/dev/null \
&& ok "Gitea has tag v1.0" || fail "Gitea missing tag v1.0"
git --git-dir="$GITEA_REPO" rev-parse --verify --quiet refs/tags/v2.0 >/dev/null \
&& ok "Gitea has tag v2.0" || fail "Gitea missing tag v2.0"
}
# ─── 11. Gitea webhook → fetch → SVN ───────────────────────────
test_webhook_flow() {
# Start webhook server in background
python -m svn_mirror --config "$CONFIG" webhook &
WEBHOOK_PID=$!
sleep 1
# Simulate a developer push to Gitea
export GIT_DIR="$GITEA_REPO"
export GIT_AUTHOR_NAME="Dev Two"
export GIT_AUTHOR_EMAIL="dev2@example.com"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
BLOB=$(echo "v4 from webhook" | git hash-object -w --stdin)
TREE=$(echo "100644 blob $BLOB README" | git mktree)
COMMIT=$(git commit-tree $TREE -p master -m "Webhook test commit")
git update-ref refs/heads/master "$COMMIT"
unset GIT_DIR GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL
# Trigger webhook
curl -s -X POST http://localhost:8080/webhook \
-H "Content-Type: application/json" \
-d "{
\"ref\": \"refs/heads/master\",
\"repository\": {
\"owner\": {\"login\": \"testorg\"},
\"name\": \"testrepo\"
},
\"pusher\": {\"login\": \"dev2\"}
}" 2>/dev/null | grep -q '"status": "ok"' \
&& ok "Webhook accepted" || fail "Webhook rejected"
sleep 1
svn cat "file://$SVN_REPO/trunk/README" | grep -q "v4 from webhook" \
&& ok "Webhook-triggered SVN commit content correct" \
|| fail "Webhook-triggered SVN commit wrong"
kill "$WEBHOOK_PID" 2>/dev/null; wait "$WEBHOOK_PID" 2>/dev/null || true
}
# ─── 12. Idempotent: no double-translations ────────────────────
test_idempotent() {
out=$(python -m svn_mirror --config "$CONFIG" sync 2>&1)
echo "$out" | grep -q "up to date" \
&& ok "Second sync reports up to date" || fail "Second sync not up to date"
out=$(python -m svn_mirror --config "$CONFIG" push 2>&1)
echo "$out" | grep -q "up to date" \
&& ok "Second push reports up to date" || fail "Second push not up to date"
}
# ─── 11. Reconciliation (divergence recovery) ─────────────────
test_reconciliation() {
# ── Create divergence ─────────────────────────────────
# SVN commit (bypassing sync daemon)
svn co "file://$SVN_REPO/trunk" "$TMP/wc2" --quiet
echo "SVN-only notes" > "$TMP/wc2/NOTES"
svn add "$TMP/wc2/NOTES" --quiet
svn commit -m "Add NOTES (SVN-only change)" "$TMP/wc2" --username dev1 --quiet
rm -rf "$TMP/wc2"
# Git commit (bypassing sync — direct to canonical repo)
export GIT_DIR="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git"
export GIT_AUTHOR_NAME="Dev Two"
export GIT_AUTHOR_EMAIL="dev2@example.com"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
PARENT=$(git rev-parse refs/heads/master)
BLOB=$(echo "Git projects list" | git hash-object -w --stdin)
TREE=$(echo "100644 blob $BLOB PROJECTS" | git mktree)
COMMIT=$(git commit-tree $TREE -p "$PARENT" -m "Add PROJECTS (Git-only change)")
git update-ref refs/heads/master "$COMMIT"
GIT_DEV_HASH="$COMMIT"
unset GIT_DIR GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL
# ── Assess (dry-run) ─────────────────────────────────
out=$(python -m svn_mirror --config "$CONFIG" reconcile --assess --mirror test-e2e 2>&1)
echo "$out" | grep -q "Pending SVN revisions" \
&& ok "Assess shows pending SVN" || fail "Assess missing SVN"
echo "$out" | grep -q "Pending Git commits" \
&& ok "Assess shows pending Git" || fail "Assess missing Git"
echo "$out" | grep -q "Last synced commit" \
&& ok "Assess shows boundary" || fail "Assess missing boundary"
echo "$out" | grep -q "Changed only on Git side" \
&& ok "Assess shows Git-only files" || fail "Assess missing Git-only"
# ── Reconcile ────────────────────────────────────────
out=$(python -m svn_mirror --config "$CONFIG" reconcile --mirror test-e2e 2>&1)
echo "$out" | grep -q "Reconciliation complete" \
&& ok "Reconciliation complete" || fail "Reconciliation failed"
# Verify SVN has both changes
svn cat "file://$SVN_REPO/trunk/NOTES" | grep -q "SVN-only notes" \
&& ok "SVN NOTES preserved" || fail "SVN NOTES missing after reconcile"
svn cat "file://$SVN_REPO/trunk/PROJECTS" | grep -q "Git projects list" \
&& ok "Git PROJECTS present in SVN after reconcile" \
|| fail "Git PROJECTS missing in SVN"
# ── Sync to process pending SVN revs → Git ───────────
out=$(python -m svn_mirror --config "$CONFIG" sync 2>&1)
# Verify Git canonical has both changes
git --git-dir="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git" \
show master:NOTES | grep -q "SVN-only notes" \
&& ok "SVN NOTES in Git after sync" || fail "SVN NOTES missing in Git"
git --git-dir="$MIRROR_DATA/mirrors/test-e2e/canonical-repo.git" \
show master:PROJECTS | grep -q "Git projects list" \
&& ok "Git PROJECTS in canonical after sync" \
|| fail "Git PROJECTS missing in canonical"
# ── Verify idempotent ────────────────────────────────
out=$(python -m svn_mirror --config "$CONFIG" sync 2>&1)
echo "$out" | grep -q "up to date" \
&& ok "Sync is up to date after reconciliation" \
|| fail "Sync not up to date after reconciliation"
out=$(python -m svn_mirror --config "$CONFIG" reconcile --assess --mirror test-e2e 2>&1)
echo "$out" | grep -q "already in sync" \
&& ok "Reconcile assess shows in sync after fix" \
|| fail "Reconcile assess wrong after fix"
}
# ─── 12. Status display ────────────────────────────────────────
test_status() {
out=$(python -m svn_mirror --config "$CONFIG" status --mirror test-e2e 2>&1)
echo "$out" | grep -q "Last SVN:" \
&& ok "Status shows Last SVN" || fail "Status missing Last SVN"
echo "$out" | grep -q "Mappings:" \
&& ok "Status shows Mappings" || fail "Status missing Mappings"
}
# ─── Run all tests ─────────────────────────────────────────────
echo "=== Phase 1: Setup ==="
setup_svn
setup_gitea
write_config
echo " SVN repo: file://$SVN_REPO"
echo " Gitea repo: $GITEA_REPO"
echo " Config: $CONFIG"
echo ""
echo "=== Phase 2: Init ==="
test_init
echo ""
echo "=== Phase 3: SVN→Git sync ==="
test_svn_to_git
echo ""
echo "=== Phase 4: Git→SVN push ==="
test_git_to_svn
echo ""
echo "=== Phase 5: Branch sync ==="
test_svn_branch
echo ""
echo "=== Phase 6: Tag sync ==="
test_svn_tag
echo ""
echo "=== Phase 7: Git tag → SVN tag ==="
test_git_tag
echo ""
echo "=== Phase 8: Gitea push ==="
test_gitea_push
echo ""
echo "=== Phase 9: Webhook flow ==="
test_webhook_flow
echo ""
echo "=== Phase 10: Idempotent ==="
test_idempotent
test_status
echo ""
echo "=== Phase 11: Reconciliation ==="
test_reconciliation
echo ""
echo "=============================="
echo " $PASS passed, $FAIL failed"
echo "=============================="
[ "$FAIL" -eq 0 ] || exit 1