463 lines
16 KiB
Markdown
463 lines
16 KiB
Markdown
# svn-git-mirror
|
|
|
|
> **License:** [PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/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](#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-time `git svn clone --no-metadata` during init)
|
|
- `svn` CLI (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
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
# 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`:
|
|
|
|
```yaml
|
|
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
|
|
|
|
```bash
|
|
python -m svn_mirror check --config /etc/svn-mirror/config.yml
|
|
```
|
|
|
|
### 2. Create mirror directories
|
|
|
|
```bash
|
|
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 repo
|
|
- `mapping.db` — SQLite mapping DB
|
|
- `state.json` — mirror state
|
|
- `sync.lock` — flock-based concurrency lock
|
|
|
|
### 3. Initial SVN import
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
python -m svn_mirror sync --mirror my-project --config /etc/svn-mirror/config.yml
|
|
```
|
|
|
|
### 5. Push Git → SVN
|
|
|
|
```bash
|
|
python -m svn_mirror push --mirror my-project --config /etc/svn-mirror/config.yml
|
|
```
|
|
|
|
### 6. Check status
|
|
|
|
```bash
|
|
python -m svn_mirror status --mirror my-project --config /etc/svn-mirror/config.yml
|
|
```
|
|
|
|
## Deployment modes
|
|
|
|
### Mode A: Polling daemon (simple)
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```ini
|
|
# /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
|
|
```
|
|
|
|
```bash
|
|
systemctl daemon-reload
|
|
systemctl enable --now svn-mirror
|
|
```
|
|
|
|
### Mode B: Webhook-only (event-driven)
|
|
|
|
```bash
|
|
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_secret` in config
|
|
- Events: "Push"
|
|
|
|
**Example — GitLab webhook config:** Go to repo → Settings → Webhooks:
|
|
- URL: `http://your-server:8080/webhook`
|
|
- Secret token: same as `webhook_secret` in config
|
|
- Trigger: "Push events"
|
|
|
|
> **Note:** The webhook receiver currently parses the Gitea payload format
|
|
> (`X-Gitea-Signature` header, `repository.owner.login` field). To use
|
|
> another Git server, adapt the header name and payload parsing in
|
|
> `svn_mirror/webhook.py`. See
|
|
> [Adapting to other Git servers](#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`):
|
|
|
|
```bash
|
|
sudo ./deploy/install.sh --install-dir /opt/svn-git-server --user git
|
|
```
|
|
|
|
This will:
|
|
- Create a Python virtualenv at `<install-dir>/.venv` and 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 `--user` is `gitea` for historical reasons. Use
|
|
> whatever user owns your Git server's repos (e.g. `git`, `forgejo`,
|
|
> `gitlab`).
|
|
|
|
After install, start the service:
|
|
|
|
```bash
|
|
sudo systemctl start svn-mirror
|
|
```
|
|
|
|
Check logs:
|
|
|
|
```bash
|
|
journalctl -u svn-mirror -f
|
|
```
|
|
|
|
To uninstall:
|
|
|
|
```bash
|
|
sudo ./deploy/install.sh --uninstall
|
|
```
|
|
|
|
#### Manual setup (screen/tmux)
|
|
|
|
```bash
|
|
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:**
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
# 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_svn` doesn't re-push those commits
|
|
- Leaves `last_svn_revision` unchanged — `sync` processes 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:
|
|
|
|
```bash
|
|
# 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:**
|
|
|
|
```bash
|
|
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`/`password` in config is sent to `git svn clone`
|
|
pipe and CLI args. Password is stored in plaintext in config file.
|
|
- **Binary files**: supported (fix in `sync.py` uses raw bytes for `hash-object`)
|
|
- **Empty directories**: SVN tracks them, Git does not — skipped during sync
|
|
- **Large repos**: initial `git svn clone` time 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 svn` username**: `--username` is supported but `--password` is 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 push` to a Git server's
|
|
on-disk bare repo may be blocked by the server's `pre-receive` hook
|
|
(Gitea, GitLab). The daemon/sync uses its own `push_to_gitea()` to
|
|
work around this by using `git fetch` instead.
|
|
|
|
## 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`).
|