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).
370 lines
15 KiB
Bash
Executable File
370 lines
15 KiB
Bash
Executable File
#!/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
|