Files
svn-git-server/README.md
T

365 lines
12 KiB
Markdown

# gitea-svn-mirror
Py sidecar SVN↔Git bidirectional mirror for Gitea. Uses own mapping DB (not `git svn`) so Git commit hashes never change.
## Architecture
```
┌──────────────┐ SVN poll ┌──────────────────┐
│ SVN remote │ ◄─────────── │ svn-mirror │
│ (http/svn+) │ ────────────► │ daemon / sync │
└──────────────┘ │ │
│ ┌──────────────┐ │
┌──────────────┐ git push │ │ canonical │ │
│ Gitea │ ◄─────────── │ │ 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)
- Gitea instance (bare repos accessible on disk, not just via HTTP API)
- 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
gitea:
owner: myorg
repo: myproject
repos_path: /var/lib/gitea/data/repositories # path to Gitea's bare repos
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 Gitea's repos on disk
The sidecar accesses Gitea'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` |
| `git` user home | `/home/git/repositories` |
| Gitea LXC | `/home/gitea/repo/` |
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=gitea-svn-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 Gitea push webhooks on `POST /webhook`. When a push arrives
it fetches the new commits from Gitea and runs `sync_git_to_svn`.
**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"
### Mode C: Both (recommended)
Run daemon AND webhook. The daemon handles SVN→Git polling; the webhook
handles instant Git→SVN on developer push.
```bash
screen -dmS daemon python3.11 -m svn_mirror daemon --config /etc/svn-mirror/config.yml
screen -dmS webhook python3.11 -m svn_mirror webhook --config /etc/svn-mirror/config.yml
```
### Mode D: Gitea post-receive hook (filesystem)
For setups without network access to a webhook receiver, install a
post-receive hook in Gitea's repo that calls sync directly:
```bash
# Create hook (one-time setup)
tee /home/gitea/repo/inpixal/pixkit.git/hooks/post-receive.d/sync-to-svn << 'HOOK'
#!/bin/bash
cd /home/git/apps/svn-git-server
. .venv/bin/activate
python3.11 -m svn_mirror sync --mirror pixkit --config /etc/svn-mirror/config.yml
HOOK
chmod +x /home/gitea/repo/inpixal/pixkit.git/hooks/post-receive.d/sync-to-svn
```
Each `git push` to Gitea then triggers an immediate SVN sync.
**Note:** Direct filesystem `git push` to Gitea's bare repo is blocked by
Gitea's `pre-receive` hook. To force-push (e.g. initial import to Gitea),
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` | Gitea webhook receiver |
| `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 Gitea push refspec.
- **Gitea filesystem push**: Direct `git push` to Gitea's on-disk bare repo
is blocked by Gitea's `pre-receive` hook. The daemon/sync uses its own
`push_to_gitea()` to work around this.