# refray
A tool to keep your repos in sync across all git platforms, while being able to work from everywhere all at once.
Created becasue github is so unusable and unreliable and I want to leave, but I don't want to leave the community behind.
- **∞-side sync**: Sync between any number of hosted/self-hosted git accounts/orgs/groups
- **read-write mirrors**: Make changes from any provider, and the changes will sync to the others
- **webhook support**: Sync right after push, reduce potential divergence window
- **conflict handling**: Rebase or open pull requests when two platforms diverge
Supported platforms: GitHub, GitLab, Gitea, Forgejo
## Install
### Option 1. Install from source
1. Install rust cargo if you don't have it: https://rustup.rs
2. `cargo install refray`
### Option 2. Download binary
Go to the [releases page](https://github.com/MaigoLabs/refray/releases), find the latest release, and download the appropriate binary for your platform.
### Option 3. Docker Compose
Run config wizard:
```sh
docker compose run --rm refray config
```
Start the webhook receiver as a service:
```sh
docker compose up -d --build
```
To edit config manually:
```sh
docker compose run --rm --entrypoint nano refray /data/config/refray/config.toml
```
## Configure
Run the interactive configuration wizard:
```sh
refray config
```
## One-time Sync
Run all configured mirror groups:
```sh
refray sync
```
Sync options
Run one group:
```sh
refray sync --group personal
```
Preview commands without writing to Git remotes:
```sh
refray sync --dry-run
```
Sync only repositories whose names match a regex:
```sh
refray sync --repo-pattern '^(foo|bar)-'
```
Retry only repositories that failed during the previous non-dry-run sync:
```sh
refray sync --retry-failed
```
Control repo-level parallelism:
```sh
refray sync --jobs 8
```
## Service & Webhooks
You can run `refray` as a service that listens for webhook events and runs full sync periodically. This is the recommended way to run `refray`.
If you want to use webhooks, you need to expose port 8787 to a public URL that can be accessed by the git provider (e.g. using port forwarding, reverse proxy, or cloudflare tunnel).
Start the receiver:
```sh
refray serve
```
Expose that listener with your reverse proxy or tunnel, then install repository webhooks. If `[webhook]` is configured, the URL and secret can come from config:
```sh
refray webhook install
```
Useful install filters:
```sh
refray webhook install \
--url https://mirror.example.com/webhook \
--secret-env REFRAY_WEBHOOK_SECRET \
--group personal \
--repo-pattern '^important-'
```
The receiver accepts `POST /` and `POST /webhook`. It verifies GitHub/Gitea HMAC SHA-256 signatures and GitLab webhook tokens, then queues `refray sync --group --repo-pattern '^$'` internally. Duplicate events for the same group/repo are coalesced while a job is queued or running. Sync jobs are serialized inside the receiver so the local ref and failure caches stay consistent.
When `[webhook].install = true`, normal `refray sync` also checks webhook installation status and installs missing webhooks for repositories that have not been recorded yet. Installation status is stored in `webhook-state.toml` under the work directory.
To uninstall webhooks previously installed by `refray`:
```sh
refray webhook uninstall
```
Manual `webhook uninstall` checks repositories on the provider instead of trusting only local state. To uninstall one repository exactly:
```sh
refray webhook uninstall important-repo
```
To move installed hooks to a new public URL, use `webhook update`. It removes hooks matching the current configured `[webhook].url`, installs the new URL, updates `[webhook].url` in the config, and refreshes local webhook state:
```sh
refray webhook update --url https://new.example.com/webhook
```
Serve can also run periodic full syncs. The interval can be configured in `[webhook].full_sync_interval_minutes` or overridden at startup:
```sh
refray serve --full-sync-interval-minutes 30
```
If `[webhook].reachability_check_interval_minutes` is configured, `serve` periodically checks that the public webhook URL is still reachable and logs a warning when it is not.
## Sync Semantics
Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints.
For every repository name found in any endpoint, `refray` will:
1. Create missing repositories on the other endpoints when `create_missing = true`.
2. Fetch all branches and tags from each existing endpoint into a local bare mirror cache.
3. Compare branch tips across endpoints.
4. Push the winning branch tip to every endpoint.
Branch conflict handling is intentionally conservative:
- If all endpoints agree on a branch tip, that tip is pushed everywhere.
- If one branch tip is a descendant of the others, the descendant wins and is pushed everywhere.
- If branch tips diverged, `conflict_resolution` controls what happens.
- If `allow_force = true` or `refray sync --force` is used, a diverged branch chooses the newest commit timestamp and force-pushes it.
Conflict resolution strategies are configured per mirror group:
- `fail`: fail the repository sync when branch tips diverge.
- `auto_rebase`: rebase divergent commits in endpoint order into one branch history, push fast-forward updates normally, and force-push only endpoints whose original tip was rewritten. If rebase hits a file conflict, fail.
- `pull_request`: push temporary `refray/conflicts/...` branches and open provider pull requests/merge requests so a person can resolve the divergence.
- `auto_rebase_pull_request`: try `auto_rebase` first, then fall back to pull requests if rebase hits a conflict.
When a previously opened conflict pull request is merged, the next sync sees the merged branch as the winning tip, pushes it to the other endpoints, and closes stale `refray/conflicts/...` pull requests for that branch.
Branch deletion is propagated only when it is safe to infer intent. If a branch existed on every endpoint in the previous successful sync, then disappears from one endpoint while the remaining endpoints still have the previous tip, `refray` deletes it from the remaining endpoints instead of recreating it. If the branch was deleted on one endpoint but changed elsewhere, it is treated as a conflict and skipped.
Tags are fetched into provider-specific cache refs and pushed only when the tag object agrees across providers or exists on one side. Divergent tags are skipped and reported. Tag deletion is not propagated.
## Example Config
```toml
[[sites]]
name = "github"
provider = "github"
base_url = "https://github.com"
token = { env = "GITHUB_TOKEN" }
[[sites]]
name = "gitea"
provider = "gitea"
base_url = "https://gitea.example.com"
token = { env = "GITEA_TOKEN" }
[[mirrors]]
name = "personal"
create_missing = true
visibility = "private"
allow_force = false
conflict_resolution = "auto_rebase_pull_request"
[[mirrors.endpoints]]
site = "github"
kind = "user"
namespace = "hykilpikonna"
[[mirrors.endpoints]]
site = "gitea"
kind = "user"
namespace = "azalea"
```
## Issues and Pull Requests
Issues and pull requests are not mirrored.