Binary Cache:
- Cache: https://cache.ysun.co
- Key:
cache.ysun.co-1:WxPYwT5g3kt9XhUhHPpNLZKI9HIOsVVAuqSHpok8Qt4=
Declarative git repo manager and code search server. Synchronize multiple remotes, execute commands across repos, manage forge metadata from a single TOML config, and serve full-text code search via zoekt.
Miroir can double as a forge-to-forge migration tool, say, move everything off GitHub (or any other forge):
- Enumerate source repos via
gh repo list(or each forge's API, or by hand) and write a[repo.*]entry per repo into your config - Add an SSH key and generate an API token for the source and each destination
forge, then add them as
[platform.*]entries. Mark the migration source asorigin = true miroir init -aclones from the current origin,miroir sync -acreates the destination repos with the configured description/visibility, andmiroir push -apopulates them across every platform remote- Mark the new forge
origin = trueand set migration sourceorigin = false
Caution
Miroir sync command is reconciliation, not append. It treats your config as
the source of truth for every configured forge. It will set repos private
when the config says so (on GitHub this wipes stars, forks, and watchers,
because they are public-graph artifacts attached to the public listing), flip
archived, and overwrite descriptions. A typo or a misaimed config against
the wrong account can do real damage. Review your TOML carefully, try a
single repo end-to-end before -a, and keep the source forge intact until
you have verified the destination.
Config
Miroir looks for config in this order:
--config/-cflagMIROIR_CONFIGenvironment variable$XDG_CONFIG_HOME/miroir/config.toml(typically~/.config/miroir/config.toml)
Unknown or misspelled keys are rejected at load time, so a typo cannot silently
fall back to a default that sync would then reconcile onto your forges.
Example
See my account's repo configuration here or some screen recordings here or a simplified version below:
[general] home = "~/Workspace" branch = "master" [general.concurrency] repo = 2 remote = 0 # 0 = no limit [general.env] GIT_SSH_COMMAND = "ssh -o StrictHostKeyChecking=no" [platform.github] origin = true # Exactly one platform must be origin domain = "github.com" user = "alice" access = "ssh" # "ssh" (default) or "https" [platform.gitlab] domain = "gitlab.com" user = "alice" access = "https" forge = "gitlab" # Auto-detected from domain if omitted token = "glpat-xxxxx" # Or set MIROIR_GITLAB_TOKEN env [platform.codeberg] domain = "codeberg.org" user = "alice" [repo.dotfiles] description = "my dotfiles" visibility = "public" [repo.notes] visibility = "private" branch = "main" # Per-repo branch override [repo.old-project] visibility = "private" archived = true # Excluded from git ops and archived on supporting forges via sync [index] listen = ":6070" # HTTP listen address for search API database = "/data/miroir/idx" # Zoekt shard storage (default: $XDG_DATA_HOME/miroir/index) interval = 300 # Seconds between fetch+index cycles bare = true # true = daemon bare repo synced from origin and indexed at HEAD include = [ # Extra directories of repos to index (one level deep) "/var/lib/gitea/repositories/alice", ]
General
| Field | Default | Description |
|---|---|---|
home |
~/ |
Base directory containing managed repos |
branch |
master |
Default branch for all repos |
concurrency.repo |
1 |
Max concurrent repo operations must be at least 1 |
concurrency.remote |
0 |
Max concurrent remote ops per repo (0 = no limit) |
env |
Extra environment variables added unless already set in the shell |
Managed repos are a flat set of direct children under general.home. Nested
repo names such as group/repo are not supported.
Platform
| Field | Default | Description |
|---|---|---|
origin |
false |
Treat as origin remote (exactly one platform must set this to true) |
domain |
Forge domain | |
user |
Username on forge | |
access |
ssh |
ssh or https |
forge |
github, gitlab, codeberg, or sourcehut (auto-detected from domain) |
|
token |
API token for forge operations |
Tokens can also be set via environment: MIROIR_<PLATFORM_NAME>_TOKEN (e.g.
MIROIR_GITHUB_TOKEN). Platform names are uppercased and non-alphanumeric
characters are replaced with _, so gitlab-main maps to
MIROIR_GITLAB_MAIN_TOKEN. Platform names must normalize uniquely.
Repo
| Field | Default | Description |
|---|---|---|
description |
Repo description synced to forges | |
visibility |
private |
public or private |
archived |
false |
Skip in git ops; archive on supporting forges via sync |
branch |
Per-repo branch override |
Index
| Field | Default | Description |
|---|---|---|
listen |
:6070 |
HTTP listen address |
database |
$XDG_DATA_HOME/miroir/index |
Directory for zoekt index shards |
interval |
300 |
Seconds between fetch+index cycles, must be at least 1 |
bare |
true |
true keeps a daemon-owned bare repo mirrored from origin and indexes HEAD, false keeps a normal clone and indexes its checked out HEAD |
include |
[] |
Extra directories to discover repos (1 level) |
The include paths are scanned one level deep for both bare and non-bare git
repos. Included repos are only indexed, miroir never fetches, pulls, or pushes
them. This is useful for indexing self-hosted Gitea or GitLab repositories
directly from their storage directories.
Usage
Target Selection
By default, miroir targets the repo matching your current directory.
-n, --name <repo>targets a specific repo by name-a, --alltargets all non-archived repos-f, --forceforces the operation
Flags are scoped per command: -n/-a apply to init, fetch, pull,
push, exec, and sync. -f applies to init, fetch, pull, push, and
sweep. -t applies to push. --tty/--no-tty apply to the commands that
render progress output (init, fetch, pull, push, exec, sync). A flag
a command does not take is rejected with an error rather than silently ignored.
Commands
init clones and sets up repo(s) with all configured remotes
miroir init # Init repo for cwd miroir init -a # Init all repos
Creates the directory, initializes git, adds all named platform remotes plus
origin, fetches, resets to origin/<branch>, and initializes submodules. When
the repo already exists, init refuses to overwrite a dirty working tree unless
you pass -f.
fetch fetches from all remotes concurrently
The platform marked origin = true is operated through the literal origin
remote so shell prompt tooling sees up-to-date upstream state, while progress
output still shows the configured platform name.
pull pulls from origin
miroir pull # Fails if working tree is dirty miroir pull -f # Hard reset then pull
Also updates submodules recursively.
push pushes to all remotes concurrently
miroir push -a miroir push -f # Force push miroir push -t # Push every local tag as well
exec runs a command in repo(s)
miroir exec -a -- git status miroir exec -n myrepo -- make build
Runs sequentially with direct stdout/stderr passthrough.
sync synchronizes repo metadata to all forges
Creates repos that don't exist, updates description/visibility on existing ones,
and archives repos marked archived = true on forges that support archiving.
Each repo's sync against one forge, its read, its write, and one retry when the
repo appeared in between, has a 30-second timeout.
sweep removes archived and untracked repos from the workspace
miroir sweep # Dry run miroir sweep -f # Actually delete
sweep assumes every top-level directory under general.home is a managed repo
directory. It is intended for dedicated miroir workspaces, not mixed folders
such as a general ~/Workspace.
sweep rejects --name and --all. It always scans the whole workspace root
and removes directories for archived repos plus directories not present in
[repo.*].
index starts the index daemon on the server
miroir index miroir index -c /path/to/config.toml
Starts a long-running daemon that:
- Synchronizes managed repos (from
[repo.*]config) on a timer - Discovers repos from
[index].includepaths (one level deep, no git ops) - Indexes each managed repo using zoekt's trigram indexer
- Removes daemon-managed repo directories and shards for repos removed from
config or marked archived, a shard counts as daemon-managed when its source
sits under
general.homeor its name starts with the origin's<domain>/<user>/ - Removes stale shards for disappeared
index.includerepos - Serves the zoekt search API and web UI over HTTP
With index.bare = true, miroir keeps a daemon-owned bare repo at
general.home/<repo>.git. Each cycle fetches origin heads straight into
refs/heads/* with prune and without tags, so the local branch set mirrors
origin, then points HEAD at the configured branch and indexes HEAD. Auto gc
runs in the foreground of the fetch, so no detached repack can remove a pack
while the indexer reads it.
With index.bare = false, miroir keeps a normal clone at general.home/<repo>.
The first clone uses the configured branch, later cycles only run
git fetch --prune origin, and indexing always follows the repo's current
checked out HEAD.
Included repos from index.include are never fetched or deleted by miroir. Only
their shards are removed if the source repo disappears from discovery.
The searcher hot-reloads index shards, so re-indexing needs no restart. On SIGINT/SIGTERM, miroir stops serving immediately, cancels the current cycle, waits for any in-flight fetch or index step to finish, and exits 0.
Compatible with any zoekt frontend (e.g. neogrok):
ZOEKT_URL=http://localhost:6070 neogrok
completion generates shell completions
miroir completion bash >> ~/.bashrc miroir completion zsh > ~/.zfunc/_miroir miroir completion fish > ~/.config/fish/completions/miroir.fish
Supported Forges
| Forge | Sync | Archive via sync |
|---|---|---|
| GitHub | Yes | Yes |
| GitLab | Yes | Yes |
| Codeberg | Yes | Yes |
| SourceHut | Yes | No |
sync creates missing repos and updates description, visibility, and archived
state on existing ones.
Forge type is auto-detected from the platform domain:
github.comandgithub.*map to GitHubgitlab.comandgitlab.*map to GitLabcodeberg.orgmaps to Codeberg*.sr.htandsr.htmap to SourceHut
Set forge = "..." explicitly to override. Forge API calls target the
configured domain, so GitHub Enterprise, self-hosted GitLab, and Gitea or
Forgejo instances (via forge = "codeberg") work with an explicit setting.
Concurrency
Miroir runs git operations concurrently at two levels:
- Repo-level: Controlled by
concurrency.repo(default 1) - Remote-level: Controlled by
concurrency.remote(default 0, no limit)
Keep concurrency.repo low (2-4) as some forges rate-limit SSH connections.
[general.concurrency] repo = 2 remote = 0
Display
When stdout is a TTY, miroir uses a real-time TUI showing per-repo and
per-remote progress. When piped, it falls back to structured log output. The
index command always uses structured logging (no TTY mode). When a git command
produces no stdout/stderr for a remote, miroir renders [no output] to preserve
the output row ordering.
License
The contents inside this repository, excluding all submodules, are licensed under the MIT License. Third-party file(s) and/or code(s) are subject to their original term(s) and/or license(s).