svn-git-mirror
License: PolyForm Noncommercial 1.0.0 Copyright (c) 2026 Uberwald. All rights reserved. Commercial use requires a separate license from Uberwald. Send email to contact@uberwald.me
Py sidecar SVN↔Git bidirectional mirror. Uses own mapping DB (not git svn) so Git commit hashes never change.
Validated against huge SVN/GIT repos with 70K commits and more than 120 branches
Works with any Git server that exposes bare repos on disk (Gitea, Forgejo,
GitLab, bare git init --bare, etc.). The Gitea-specific integration code
(svn_mirror/gitea.py, svn_mirror/webhook.py) serves as a working
example and can be adapted to other Git servers — see
Adapting to other Git servers.
Architecture
┌──────────────┐ SVN poll ┌───────────────────┐
│ SVN remote │ ◄─────────── │ svn-mirror │
│ (http/svn+) │ ────────────► │ daemon / sync │
└──────────────┘ │ │
│ ┌──────────────┐ │
┌──────────────┐ git push │ │ canonical │ │
│ Git server │ ◄─────────── │ │ bare repo │ │
│ (webhook) │ ────────────► │ └──────────────┘ │
└──────────────┘ git fetch │ │
│ SQLite mapping │
└───────────────────┘
- Canonical bare repo: the single source of truth on the Git side
- Mapping DB: SQLite table
(svn_revision, svn_branch) ↔ git_commit_hash - SVN WC: per-branch working copy checked out for applying Git→SVN diffs
- Daemon: polls SVN on interval, pushes Git→SVN on webhook or interval
Requirements
- Python 3.11+
git+git-svn(for one-timegit svn clone --no-metadataduring init)svnCLI (Subversion client)- A Git server with bare repos accessible on disk (Gitea, Forgejo, GitLab, or plain
git init --bare) - UTF-8 system locale (SVN needs it for non-ASCII filenames)
Install system deps
# Debian / Ubuntu
apt install python3 python3-pip git git-svn subversion
# RHEL / Rocky / Alma
yum install python3 python3-pip git git-svn subversion
# OpenSUSE
zypper install python311 python311-pip git git-svn subversion glibc-locale
UTF-8 locale
SVN requires a UTF-8 locale to handle filenames with accents/spaces:
sudo localedef -i en_US -f UTF-8 en_US.UTF-8
export LC_ALL=en_US.UTF-8
Add to ~/.bashrc or ~/.profile to make it permanent.
Installation
# Clone
git clone https://github.com/your-org/svn-git-server /opt/svn-git-server
cd /opt/svn-git-server
# Install Python deps
pip install -r requirements.txt
Only dependency: PyYAML. The rest is stdlib + CLI tools.
Configuration
Create /etc/svn-mirror/config.yml:
data_dir: /var/svn-mirror
mirrors:
- id: my-project
enabled: true
svn:
url: https://svn.example.com/svn/myproject
layout: std # "std" for standard /trunk /branches /tags
# For non-standard layout:
# layout: custom
# trunk: trunk
# branches: branches
# tags: tags
username: # SVN login (omit if anonymous access)
password: # SVN password
# ── Git server integration ──────────────────────────────────
# The config below uses "gitea" as the section name for historical
# reasons. In practice this works with any Git server that exposes
# bare repos on disk. See "Adapting to other Git servers" below.
gitea:
owner: myorg
repo: myproject
repos_path: /var/lib/gitea/data/repositories # path to bare repos on disk
webhook_secret: "choose-a-random-secret" # shared HMAC secret
webhook_host: "0.0.0.0"
webhook_port: 8080
authors:
"john": "John Doe <john@example.com>"
"jane": "Jane Doe <jane@example.com>"
sync_interval: 120 # SVN→Git poll interval (seconds)
repos_path — finding your Git server's repos on disk
The sidecar accesses your Git server's repos on disk, not via HTTP API. Find the path:
| Setup | Typical path |
|---|---|
| Gitea binary | /var/lib/gitea/data/repositories |
| Gitea Docker | /data/git/repositories |
| Forgejo | /var/lib/forgejo/data/repositories |
| GitLab | /var/opt/gitlab/git-data/repositories |
git user home |
/home/git/repositories |
| Plain bare repos | wherever you ran git init --bare |
If unset the tool tries common defaults. Set it explicitly to be safe.
Note: repo should be the repo name without .git suffix — the tool appends it.
For example: repo: myproject, not repo: myproject.git.
Quick start
1. Validate config
python -m svn_mirror check --config /etc/svn-mirror/config.yml
2. Create mirror directories
python -m svn_mirror create --mirror my-project --config /etc/svn-mirror/config.yml
Creates {data_dir}/mirrors/{id}/ with:
canonical-repo.git— bare Git repomapping.db— SQLite mapping DBstate.json— mirror statesync.lock— flock-based concurrency lock
3. Initial SVN import
python -m svn_mirror init --mirror my-project --config /etc/svn-mirror/config.yml
Runs git svn clone --no-metadata (one-time bootstrap), then tears down
git-svn metadata. All ongoing sync is handled by the tool's own engine.
Auth: If the SVN server requires login, set username and password
in the config. The password is piped to git svn's stdin prompt
(git svn does not accept --password on the command line).
The tool also caches credentials via svn info --username X --password Y
before git svn clone so the SVN auth cache is populated.
External definitions: If the SVN repo uses svn:externals pointing to
different repositories, the tool passes --ignore-externals to all
svn checkout / svn update calls. The externals are not followed.
4. Sync SVN → Git
python -m svn_mirror sync --mirror my-project --config /etc/svn-mirror/config.yml
5. Push Git → SVN
python -m svn_mirror push --mirror my-project --config /etc/svn-mirror/config.yml
6. Check status
python -m svn_mirror status --mirror my-project --config /etc/svn-mirror/config.yml
Deployment modes
Mode A: Polling daemon (simple)
python -m svn_mirror daemon --config /etc/svn-mirror/config.yml
Note: daemon does not accept --mirror — it processes all enabled
mirrors. Use screen (or tmux) to run in background:
screen -dmS svn-mirror bash -c 'python3.11 -m svn_mirror daemon --config /etc/svn-mirror/config.yml 2>&1 | tee /tmp/daemon.log'
Polls SVN every sync_interval seconds. Pushes new Git commits to SVN
on the same interval.
systemd unit
# /etc/systemd/system/svn-mirror.service
[Unit]
Description=svn-git-mirror daemon
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 -m svn_mirror daemon --config /etc/svn-mirror/config.yml
Restart=always
User=root
WorkingDirectory=/opt/svn-git-server
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now svn-mirror
Mode B: Webhook-only (event-driven)
python -m svn_mirror webhook --config /etc/svn-mirror/config.yml
Listens for push webhooks on POST /webhook. When a push arrives
it fetches the new commits from the Git server and runs sync_git_to_svn.
Example — Gitea webhook config: Go to repo → Settings → Webhooks → Add:
- Target URL:
http://your-server:8080/webhook - Secret: same as
webhook_secretin config - Events: "Push"
Example — GitLab webhook config: Go to repo → Settings → Webhooks:
- URL:
http://your-server:8080/webhook - Secret token: same as
webhook_secretin config - Trigger: "Push events"
Note: The webhook receiver currently parses the Gitea payload format (
X-Gitea-Signatureheader,repository.owner.loginfield). To use another Git server, adapt the header name and payload parsing insvn_mirror/webhook.py. See Adapting to other Git servers.
Mode C: systemd service (recommended)
The daemon handles bidirectional sync: SVN→Git polling AND Git→SVN push on each cycle. One service is all you need.
Quick install via script
Use the provided install script to generate and enable the systemd service
running as a given user (e.g. git):
sudo ./deploy/install.sh --install-dir /opt/svn-git-server --user git
This will:
- Create a Python virtualenv at
<install-dir>/.venvand install deps - Generate
svn-mirror.service - Enable the service (auto-start on boot)
Options:
| Option | Default | Description |
|---|---|---|
--install-dir DIR |
(required) | Where the code is installed |
--user USER |
gitea |
System user to run the service as |
--config FILE |
<install-dir>/config.yml |
Config file path |
--venv DIR |
<install-dir>/.venv |
Virtualenv path |
--uninstall |
— | Remove service instead of installing |
Note: The default
--userisgiteafor historical reasons. Use whatever user owns your Git server's repos (e.g.git,forgejo,gitlab).
After install, start the service:
sudo systemctl start svn-mirror
Check logs:
journalctl -u svn-mirror -f
To uninstall:
sudo ./deploy/install.sh --uninstall
Manual setup (screen/tmux)
screen -dmS svn-mirror python3.11 -m svn_mirror daemon --config /etc/svn-mirror/config.yml
Mode D: Post-receive hook (filesystem)
For setups without network access to a webhook receiver, install a post-receive hook in your Git server's repo that calls sync directly. Example — Gitea/Forgejo:
# Create hook (one-time setup)
tee /home/gitea/repo/myorg/myproject.git/hooks/post-receive.d/sync-to-svn << 'HOOK'
#!/bin/bash
cd /opt/svn-git-server
. .venv/bin/activate
python3.11 -m svn_mirror sync --mirror my-project --config /etc/svn-mirror/config.yml
HOOK
chmod +x /home/gitea/repo/myorg/myproject.git/hooks/post-receive.d/sync-to-svn
Each git push to the Git server then triggers an immediate SVN sync.
Note: Direct filesystem git push to a Git server's on-disk bare repo
may be blocked by the server's pre-receive hook (Gitea and GitLab both
do this). The daemon/sync uses its own push_to_gitea() to work around
this by using git fetch instead of git push. To force-push (e.g.
initial import), temporarily disable the hook:
mv /path/to/repo.git/hooks/pre-receive{,.disabled}
# push...
mv /path/to/repo.git/hooks/pre-receive{.disabled,}
Reconciliation (fixing divergence)
If the sync was interrupted (daemon down for days) and commits landed on both SVN and Git independently, the tool can re-sync them.
# 1. Assess divergence (dry-run, no changes)
python -m svn_mirror reconcile --assess --mirror my-project --config /etc/svn-mirror/config.yml
# 2. Reconcile (creates SVN commit merging Git changes into SVN)
python -m svn_mirror reconcile --mirror my-project --config /etc/svn-mirror/config.yml
# 3. Normal sync to bring Git in line
python -m svn_mirror sync --mirror my-project --config /etc/svn-mirror/config.yml
The reconciliation:
- Backs up the mapping DB
- Applies each Git developer commit (not yet in DB) onto the SVN trunk WC
- Commits to SVN as a single
[reconcile]revision - Records the mapping so
sync_git_to_svndoesn't re-push those commits - Leaves
last_svn_revisionunchanged —syncprocesses pending SVN revisions naturally
SVN author fix: After svn commit / svn copy, the tool sets the correct
author via svn propset --revprop -r REV svn:author "Real Author".
This requires the pre-revprop-change hook on the SVN server:
# On the SVN server — create this script, make it executable
# /srv/svn/pixkit/hooks/pre-revprop-change
#!/bin/bash
REPOS="$1"; REV="$2"; USER="$3"; PROPNAME="$4"; ACTION="$5"
[ "$PROPNAME" = "svn:author" ] || [ "$PROPNAME" = "svn:log" ] || exit 1
exit 0
Dry-run mode:
python -m svn_mirror reconcile --dry-run --mirror my-project --config /etc/svn-mirror/config.yml
Shows the assessment report and what would be done, without making any changes.
All commands
| Command | Description |
|---|---|
check |
Validate config and prerequisites |
create |
Create mirror directory structure |
init |
Run git svn clone initial import |
destroy |
Delete all mirror data |
status |
Show mirror status |
sync |
Bidirectional sync (SVN→Git + Git→SVN) |
push |
Git→SVN direction only |
daemon |
Continuous polling loop |
webhook |
Webhook receiver (Gitea format; adaptable) |
reconcile |
Fix divergence (see above) |
File layout
{data_dir}/mirrors/{id}/
├── canonical-repo.git/ # bare Git repo (canonical Git copy)
├── mapping.db # SQLite mapping DB
├── state.json # mirror metadata
├── authors.txt # git svn authors file
├── sync.lock # flock-based concurrency lock
└── svn-wc/ # SVN working copies (per branch)
Caveats
- Auth: SVN
username/passwordin config is sent togit svn clonepipe and CLI args. Password is stored in plaintext in config file. - Binary files: supported (fix in
sync.pyuses raw bytes forhash-object) - Empty directories: SVN tracks them, Git does not — skipped during sync
- Large repos: initial
git svn clonetime depends on SVN history size; 6+ GiB repos can take several hours over HTTPS - One trunk only: reconciliation only handles
refs/heads/master↔ SVN trunk. Other branches are not yet supported by the reconcile tool. git svnusername:--usernameis supported but--passwordis not (the tool pipes password to stdin instead).- All SVN branches become Git branches: If the SVN repo uses branches for
release tags, the Git canonical repo will contain hundreds of branches.
Consider filtering via
refs/tags/in the push refspec. - Git server filesystem push: Direct
git pushto a Git server's on-disk bare repo may be blocked by the server'spre-receivehook (Gitea, GitLab). The daemon/sync uses its ownpush_to_gitea()to work around this by usinggit fetchinstead.
Adapting to other Git servers
The core SVN↔Git sync engine is server-agnostic. The Gitea-specific code is limited to two files and can be adapted to other Git servers (Forgejo, GitLab, Gitea, plain bare repos, etc.):
| What | Where | What to change |
|---|---|---|
| Push to Git server | svn_mirror/gitea.py — push_to_gitea() |
Uses git fetch into the bare repo (generic). The _trigger_gitea_post_receive() function calls the gitea binary — replace with your server's hook trigger or remove it. |
| Webhook receiver | svn_mirror/webhook.py |
Parses X-Gitea-Signature header and Gitea JSON payload. Adapt the header name (X-Gitlab-Token, etc.) and payload fields (repository.owner.login → your server's equivalent). |
| Config section | svn_mirror/config.py — GiteaConfig |
The gitea: config block. The fields (owner, repo, repos_path, webhook_*) are generic; only the section name is Gitea-specific. |
| Default repo paths | svn_mirror/config.py — repo_dir |
Hardcoded Gitea defaults. Add your server's path or set repos_path explicitly. |
For plain bare repos (no Git server), no adaptation is needed — just
set repos_path to the directory containing your bare repos and skip
the webhook/post-receive integration (use the daemon or manual sync).