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. The forge layer also implements repo
deletion across every supported provider, so 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)
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 |
bare |
true |
true keeps a daemon-owned bare repo synced from origin and indexes HEAD false keeps a normal clone and indexes the local 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. No git operations (fetch/pull/push) are run on included repos -- they are
only indexed. 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>-- Target a specific repo by name-a, --all-- Target all non-archived repos-f, --force-- Force operation
Commands
init -- Clone and set 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 -- Fetch from all remotes (concurrent)
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 -- Pull from origin
miroir pull # Fails if working tree is dirty miroir pull -f # Hard reset then pull
Also updates submodules recursively.
push -- Push to all remotes (concurrent)
miroir push -a
miroir push -f # Force pushexec -- Run a command in repo(s)
miroir exec -a -- git status miroir exec -n myrepo -- make build
Runs sequentially with direct stdout/stderr passthrough.
sync -- Synchronize 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 forge API call has a 30-second timeout.
sweep -- Remove archived and untracked repos from 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 does not use --name or --all to narrow its scope. It always scans
the whole workspace root and removes directories for archived repos plus
directories not present in [repo.*].
index -- Start the index daemon (server-side)
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
- 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 rewrites the origin fetch refspec to
track refs/remotes/origin/*, runs git fetch --prune origin, force-syncs the
local refs/heads/* set to match origin, points HEAD at the configured
branch, and indexes HEAD.
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 -- no restart needed after re-indexing. On SIGINT/SIGTERM, miroir stops serving immediately, cancels the current cycle, and waits for any in-flight fetch or index step to finish before exiting.
Compatible with any zoekt frontend (e.g. neogrok):
ZOEKT_URL=http://localhost:6070 neogrok
completion -- Generate shell completions
miroir completion bash >> ~/.bashrc miroir completion zsh > ~/.zfunc/_miroir miroir completion fish > ~/.config/fish/completions/miroir.fish
Supported Forges
| Forge | Create | Update | Archive | Delete | List | Sync |
|---|---|---|---|---|---|---|
| GitHub | Yes | Yes | Yes | Yes | Yes | Yes |
| GitLab | Yes | Yes | Yes | Yes | Yes | Yes |
| Codeberg | Yes | Yes | Yes | Yes | Yes | Yes |
| SourceHut | Yes | Yes | No | Yes | Yes | Yes |
Forge type is auto-detected from the platform domain:
github.com,github.*-- GitHubgitlab.com,gitlab.*-- GitLabcodeberg.org-- Codeberg*.sr.ht,sr.ht-- SourceHut
Set forge = "..." explicitly to override.
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).