repos-manager
Clone and sync every Git repository you can access, on every provider, with one command.
GitHub · Documentation · Upstream (Forgejo)
If your code is spread over GitHub, a GitLab group, a self-hosted Forgejo and a couple of Bitbucket workspaces, keeping a local copy of everything up to date is tedious. repos-manager asks each provider for every repository you can access (personal, collaborations, organizations, groups and subgroups) and mirrors them into <base_dir>/<host>/<owner>/<repo>, cloning what is missing and fast-forwarding the rest.
It is a single native binary for Linux, macOS and Windows. At runtime it only needs git and the official CLI of each provider you sync.
$ repos-manager sync --all
$ repos-manager status
github.com/Dxsk/git-identity-manager dirty
github.com/Dxsk/mtd ahead (+2)
gitlab.com/work/api behind (-3)
Total: 42 repos - 39 clean, 1 dirty, 1 ahead, 1 behind, 0 diverged
Supported providers
| Provider | CLI | Notes |
|---|---|---|
| GitHub | gh |
github.com and GitHub Enterprise |
| GitLab | glab |
Groups and nested subgroups |
| Forgejo / Gitea / Gogs | tea |
Optional: tea is used for login only, and repos-manager forgejo login can store a token instead |
| Bitbucket | bitbucket |
Optional. Cloud with an Atlassian API token, Data Center with an HTTP access token |
| Sourcehut | hut |
Names keep their tilde: ~user/repo |
| OneDev | none | Self-hosted, an access token saved by repos-manager onedev login |
| Radicle | rad |
|
| Plain git server | none | Any server with no API: URLs from the config, or listed over SSH (gitolite, Soft Serve) |
Not every provider is equally proven. GitHub, GitLab, Forgejo and the config URLs of the plain git server are stable: their listing has been run against the live API, offline tests cover the parsing.
Six surfaces are alpha: Bitbucket Cloud, Bitbucket Data Center, Radicle, Sourcehut, OneDev, and the SSH discovery modes of the plain git server (gitolite, soft-serve, find:<root>). They are implemented against the published API documentation and covered by offline tests only, and no live server of that kind has ever answered a request from repos-manager. They may work on the first try, or list nothing. If one misbehaves, an issue with the raw payload the server returned and the server version is what makes a fix possible, and it becomes a test fixture. Per provider detail in the provider reference.
Installation
Prebuilt binaries for every release are on the GitHub releases page, which has every platform. The Forgejo releases carry the Linux and Windows x86_64 builds.
| Where | Package | Command |
|---|---|---|
| crates.io | repos-manager |
cargo install repos-manager |
| AUR (Arch, Manjaro, CachyOS...) | repos-manager-bin (prebuilt) |
yay -S repos-manager-bin |
| AUR | repos-manager (built from source) |
yay -S repos-manager |
The AUR packages also install the bash, zsh and fish completions. Update these installs through the package manager: repos-manager update detects them and tells you which command to run instead of replacing the binary.
|
The script downloads the archive that matches your OS and CPU, checks it against the release's SHA256SUMS, and puts the binary in ~/.local/bin. Options go after sh -s --, and most of them also have an environment variable:
| Option | Variable | Default | |
|---|---|---|---|
--version vX.Y.Z |
REPOS_MANAGER_VERSION |
latest | Release to install |
--prefix DIR |
PREFIX |
~/.local |
Installs into DIR/bin |
--source github|forge |
github |
Where to download from | |
--uninstall |
Removes the installed binary |
# Pin a version, system-wide
|
# Download from the forge instead of GitHub (Linux only, macOS builds are on GitHub)
|
# Uninstall
|
With PowerShell:
irm https://raw.githubusercontent.com/Dxsk/repos-manager/main/installers/install.ps1 | iex
It installs repos-manager.exe into %LOCALAPPDATA%\Programs\repos-manager and adds that folder to your user PATH. Open a new terminal afterwards.
The script accepts -Version, -Source github|forge, -InstallDir and -Uninstall. To pass them without saving the file first:
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Dxsk/repos-manager/main/installers/install.ps1))) -Version v1.0.0
Setting $env:REPOS_MANAGER_VERSION = "v1.0.0" before the one-liner pins a version too.
Prefer an installer? Download and run repos-manager-x86_64-setup.exe (or repos-manager-aarch64-setup.exe on ARM devices). It installs per user, so it does not ask for admin rights, adds the program to your PATH and shows up in Apps & features if you want to remove it later. For a silent install, pass /S:
.\repos-manager-x86_64-setup.exe /S
If you would rather not install anything, grab the portable .zip and put repos-manager.exe somewhere on your PATH.
You need a stable Rust toolchain and make.
Packagers can stage the install with DESTDIR. Cargo works too, from crates.io or straight from the repository:
Assets are named after the Rust target they were built for:
| Asset | Platform | GitHub | Forgejo |
|---|---|---|---|
repos-manager-x86_64-unknown-linux-musl.tar.gz |
Linux x86_64 (static) | ✓ | ✓ |
repos-manager-aarch64-unknown-linux-musl.tar.gz |
Linux aarch64 (static) | ✓ | ✓ |
repos-manager-x86_64-apple-darwin.tar.gz |
macOS Intel | ✓ | |
repos-manager-aarch64-apple-darwin.tar.gz |
macOS Apple Silicon | ✓ | |
repos-manager-x86_64-pc-windows-msvc.zip |
Windows x86_64 | ✓ | |
repos-manager-aarch64-pc-windows-msvc.zip |
Windows ARM64 | ✓ | |
repos-manager-x86_64-pc-windows-gnu.zip |
Windows x86_64 (MinGW build) | ✓ | |
repos-manager-x86_64-setup.exe |
Windows x86_64 installer | ✓ | ✓ |
repos-manager-aarch64-setup.exe |
Windows ARM64 installer | ✓ |
Every release ships two checksum files:
SHA256SUMSlists the archives and installers you download.SHA256SUMS-binarieslists therepos-managerbinary inside each archive. Use it to check a binary you already extracted or installed.
On Linux and macOS:
On Windows:
(Get-FileHash .\repos-manager-x86_64-setup.exe).Hash.ToLower()
(Get-FileHash "$env:LOCALAPPDATA\Programs\repos-manager\repos-manager.exe").Hash.ToLower()
Then compare the result with the matching line in the checksum file. The install scripts and repos-manager update do this for you.
Requirements
git- The CLI of each provider you sync:
gh,glab,tea,hut,bitbucket(optional) orrad ssh, to sync a plain git server that lists itself
OneDev and plain git servers need no extra CLI at all.
Nothing else: no bash, jq, yq or curl needed at runtime.
Getting started
After a sync, your workspace mirrors the remote namespaces:
~/Documents/
.repos.yml
github.com/
dxsk/
my-project/
my-org/
other-project/
gitlab.com/
my-group/
sub-group/
project/
On Windows, a host with a port (git.example.org:3000) is stored as git.example.org_3000, since : is not allowed in directory names.
Usage
Commands
| Command | What it does |
|---|---|
repos-manager login [<provider>] |
Logs in to one provider, or to every detected one |
repos-manager <provider> login |
Same, per provider |
repos-manager <provider> sync |
Syncs one provider (github, gitlab, forgejo, bitbucket, sourcehut, onedev, radicle, any) |
repos-manager sync --all |
Syncs every configured provider |
repos-manager status |
Lists repos that are dirty, ahead, behind or diverged |
repos-manager init |
Creates the config file |
repos-manager update |
Updates the binary in place |
repos-manager completions <shell> |
Prints a completion script |
repos-manager version |
Prints the version |
Aliases: gitea and gogs for forgejo, srht for sourcehut, git for any.
sync --all only runs providers whose CLI, or saved credentials, is available, and one failing host never stops the others. Every provider has its own help: repos-manager github --help.
Sync flags
| Flag | Description |
|---|---|
--filter <pattern> |
Only sync matching repos (Dxsk/* or Dxsk/project), repeatable |
--ignore <pattern> |
Skip matching repos, repeatable |
--base-dir <path> |
Base directory for repos (default: ~/Documents) |
--https |
Use HTTPS clone URLs instead of SSH |
--prune |
Remove local repos that no longer exist on the remote |
--force |
With --prune, go ahead even when most local repos would be removed; with --write-ignore, overwrite an existing file |
--dry-run |
Show what would be done without changing anything |
--plan |
Print the recon summary and stop, cloning nothing |
--write-ignore |
Write <base-dir>/.repos.yml listing every repo in scope, commented out, then stop |
-y, --yes |
Clone without asking, however much there is to download |
--host <host> |
Target a self-hosted instance (provider sync only) |
--parallel <n> |
Number of parallel jobs (default: 4) |
-v, --verbose |
Show debug output |
-q, --quiet |
Hide info and success messages (errors still show) |
status accepts --base-dir, --verbose and --quiet. update accepts -y / --yes.
Examples
# Only one owner, or a single repo
# Several patterns, and a few exceptions
# A big first sync: look at it, pick what to leave out, then run it
# uncomment in <base-dir>/.repos.yml what you do not want, then
# Or clone everything without being asked
# Preview, then clean up repos deleted on the remote
# Self-hosted instances (gitea and gogs are aliases of forgejo)
# A plain git server, with no forge API (see "Plain git servers" below)
# HTTPS, more jobs, another base directory
Existing clean repos get git fetch --all --prune, then a fast-forward of the remote default branch when it is checked out. Any other branch, or a detached HEAD, is only fetched. Repos with uncommitted changes are skipped. --host is rejected with sync --all.
--prune only deletes a local repo when all of these hold:
- the remote no longer lists it;
- it is in scope for that host: allowed by every
onlylist that applies to it, and matched by noignorelist (see.repos.yml); - it holds no local-only work: no uncommitted or untracked changes, no unpushed commits, no stash;
- it sits inside the base directory.
If more than half of the local repos of a host would go, the remote listing probably came back incomplete (revoked token, API hiccup) and prune stops. Review with --dry-run, then add --force.
Each host has its own lock, so two syncs of the same host cannot overlap while different hosts sync side by side. The lock is released by the OS if the process dies.
Before it clones
A sync lists every host first, then says what it is about to do:
github.com 42 in scope, 39 cloned, 3 new (1.2 GB)
biggest new: org/a 586 MB, org/b 586 MB
gitlab.corp 18 in scope, 0 cloned, 18 new (4.7 GB, 2 unknown)
biggest new: team/monorepo 3.1 GB, team/assets 900 MB
with --prune: 2 local repos would be deleted
21 clones, 39 updates, about 5.9 GB to download, 2 of unknown size.
Proceed? [Y/n]
- The volume covers the new repos only, and it is the size the forge reports on the server, not the size of the transfer, hence about. A size a provider does not report shows as
?and is never estimated. - You only see this above a threshold, by default 5 new clones or 1 GB across the whole run. A daily sync where nothing is new prints exactly what it always did.
- The question is asked once per run and only when stdin and stdout are both terminals, so cron and CI get the summary and carry on.
-yand--dry-runprint it without asking, andnexits successfully without cloning.
Two flags turn that summary into a workflow, and neither clones anything:
Uncomment in <base-dir>/.repos.yml what you do not want, then sync. Thresholds and prompting are set per machine with confirm_sync, confirm_new_repos and confirm_download_gb in the config file. Full rules in the usage docs.
Configuration
repos-manager init creates ~/.config/repos-manager/config.json. The path is the same on every OS, including Windows (%USERPROFILE%\.config\repos-manager\config.json), and REPOS_MANAGER_CONFIG overrides it.
| Field | Default | Description |
|---|---|---|
base_dir |
~/Documents |
Where repos are cloned |
parallel |
4 |
Parallel sync jobs |
protocol |
ssh |
ssh or https |
check_updates |
true |
Background update check, see Updating |
scan_network_mounts |
false |
Let status walk network and FUSE mounts (Linux) |
gitlab_statistics |
true |
Ask GitLab for repository sizes (a heavier listing); false keeps the light listing and sizes stay unknown |
confirm_sync |
auto |
When a sync asks before cloning: auto over the thresholds below, always as soon as there is anything to do, never for no question at all (a big run is still summarized) |
confirm_new_repos |
5 |
New clones, run-wide, that make auto ask |
confirm_download_gb |
1 |
Gigabytes to download, run-wide, that make auto ask |
hosts.<provider> |
provider's SaaS host | One hostname or a list. Each one is synced into its own <base_dir>/<host>/ |
repos.<host> |
none | Plain git servers, see the Plain git servers block below |
OneDev and plain git servers are self-hosted only and have no default host: nothing is synced until you name one.
Self-hosted instances need a matching login: gh auth login --hostname <host>, glab auth login --hostname <host>, tea login add or repos-manager <provider> login.
Precedence: flag > environment variable > config file > default.
The any provider syncs servers that have no forge API at all, including a bare git init --bare on a VPS. Its hosts are the keys of repos, and each one gets its own directory like any other host. There is nothing to log in to: these hosts use your SSH keys.
List the clone URLs by hand:
The local path comes from the URL, so those three land in infra/ansible, srv/git/notes and a/b/c under their host directory. A URL you listed is used exactly as written, so --https does not rewrite it.
Or let the server list itself, when it knows how:
discover |
What runs | Notes |
|---|---|---|
gitolite |
ssh [-p <port>] <user>@<host> info |
user defaults to git. Config patterns (CREATOR, wildcards) are not repos and are dropped |
soft-serve |
ssh -p <port> <host> repo list |
port defaults to 23231, Soft Serve's default |
find:<root> |
find <root> -name '*.git' -prune over SSH |
Needs a real shell on the remote account, so it does not work with git-shell |
urls and discover can be combined in one entry, and both are synced.
Discovery runs with BatchMode=yes and a connect timeout, so an unknown host key or a key with no agent fails with a message instead of stalling the sync.
Providers without a CLI (or whose CLI you did not install) keep a token per host under ~/.config/repos-manager/, written by repos-manager <provider> login with mode 0600 on unix. Each file is a JSON map keyed by host, so several instances coexist.
| File | Written by | Shape |
|---|---|---|
bitbucket-creds |
repos-manager bitbucket login |
{"bitbucket.org":{"user":"me@example.com","token":"..."}} |
forgejo-creds |
repos-manager forgejo login |
{"git.example.org":{"url":"https://git.example.org","token":"..."}} |
onedev-creds |
repos-manager onedev login |
{"onedev.example.org":{"user":"me","token":"..."}} |
Bitbucket Cloud: app passwords were removed by Atlassian on 2026-07-28. Use an Atlassian API token with your account email as the user. An old single-line user:password file is still read, with a warning telling you to log in again.
Bitbucket Data Center: any host other than bitbucket.org uses an HTTP access token (profile picture > Manage account > HTTP access tokens). Leave the user empty and it is sent as a Bearer, which is what project- and repository-scoped tokens require.
Forgejo / Gitea / Gogs: a matching tea login wins over this file. The file is what makes Gogs work, since tea login add may refuse a Gogs server.
OneDev: create the token at https://<host>/~my/access-tokens. Git over SSH uses OneDev's own port, 6611, which must be reachable unless you sync with --https.
One file at the root of the base directory decides what gets synced, globally and per host:
# Everywhere
only:
- Dxsk/*
ignore:
- test-org/*
# Per host, on top of the lists above
hosts:
gitlab.corp:
only:
ignore:
github.com:
ignore:
only is an allow-list, one matching pattern is enough. ignore is a deny-list, and a repo it catches is reported as ignored. Allow-lists stack and a repo must pass all of them (--filter flags, then the host block, then the global list) while deny-lists add up. So a hosts: block can only narrow what the global lists allow, and it can never pull a repository into the reach of --prune.
Patterns use * and ?, owner/* also matches nested paths, and they are matched against the remote full name rather than the local path. Host keys ignore case.
Since this file drives --prune, a typo in it is an error, not a shrug: an unknown key, malformed YAML or an unreadable file stops the run before any host is synced.
repos-manager sync --all --write-ignore writes the file for you, holding every repository in scope with its size, all commented out. .repos-filter and .repos-ignore keep working as in 1.0 while there is no .repos.yml.
Pattern syntax, stacking order and the generated file in detail: filtering docs.
| Variable | Description | Default |
|---|---|---|
REPOS_MANAGER_CONFIG |
Path to the config file | ~/.config/repos-manager/config.json |
REPOS_MANAGER_BASE_DIR |
Base directory for all repos | ~/Documents |
REPOS_MANAGER_PARALLEL |
Default parallel jobs | 4 |
REPOS_MANAGER_PROTOCOL |
Default protocol (ssh or https) |
ssh |
REPOS_MANAGER_NO_UPDATE_CHECK |
Set to 1 to skip the background update check |
unset |
REPOS_MANAGER_UPDATE_TTL |
Seconds between update checks | 86400 |
REPOS_MANAGER_UPDATE_CACHE |
Path to the cached latest-version file | <cache dir>/repos-manager/latest-version |
REPOS_MANAGER_GIT_TIMEOUT |
Seconds before a single git command is killed | 3600 |
REPOS_MANAGER_LIST_TIMEOUT |
Seconds before a provider CLI listing is killed | 300 |
REPOS_MANAGER_UPDATE_URL |
Release API used by the update check and update |
GitHub Releases API |
TEA_CONFIG |
Path to tea's config file, read by the Forgejo provider | tea's default location |
NO_COLOR |
Disable colored output | unset |
status skips vendored directories (node_modules, .venv, target, vendor, dist and friends) and shows a live progress line while it scans.
On Linux it also reads /proc/self/mountinfo and prunes every mount under base_dir whose filesystem is fuse, fuse.*, nfs, cifs, smb*, smbfs, afs, ceph or davfs. Cloud drives (kDrive, Dropbox, sshfs) mounted under ~/Documents would otherwise make the scan crawl. Set "scan_network_mounts": true if you really host repos on a reliable network share.
Shell completions
The binary generates them for bash, zsh, fish, PowerShell and elvish:
# Add this line to your $PROFILE to keep it
repos-manager completions powershell | Out-String | Invoke-Expression
From a source checkout, make completions installs the bash, zsh and fish files for you.
Updating
Commands that touch repositories print a one-line banner when a newer release is out:
⬆ repos-manager 1.1.0 available (current 1.0.0), run: repos-manager update
The banner reads a cached version, refreshed at most once a day by a detached background process that queries the GitHub Releases API, so the check never slows down the command in progress. Turn it off with "check_updates": false, or with REPOS_MANAGER_NO_UPDATE_CHECK=1 for a single run.
update downloads the GitHub release asset for your platform (the msvc zip on Windows), verifies it against the release's SHA256SUMS and replaces the binary in place. Re-running the install script works too.
Installed through a package manager (AUR, cargo install, and later Homebrew, Scoop or winget)? Then update does not touch the binary and the banner shows the package manager's command instead, for example run: cargo install repos-manager.
Upgrading from 0.x
1.0 is a breaking release: the Bash tool became a native binary, and the 0.x repos-manager update cannot install it. Your config file, .repos-filter, .repos-ignore and synced repositories are kept as they are (the two filter files are still read, see Filtering).
# 1. Remove the 0.x Bash install (make install put it here)
# 2. Install 1.x
|
# 3. Shell integration: drop the old `source .../sourceme*` lines from your
# shell rc file, delete the generated files, and use completions instead
# then set up completions with: repos-manager completions <shell> (see Shell completions)
Nix users: the flake is gone, use one of the install methods above. If you set REPOS_MANAGER_VERSION_URL, it is now REPOS_MANAGER_UPDATE_URL and points to a GitHub Releases API URL.
Development
Tests shell out to the real git with bare remotes in temporary directories, so they need git 2.28 or newer. Run the CLI without installing it with cargo run -- github sync --dry-run --base-dir /tmp/rm.
To build the Windows installer yourself, install NSIS and run:
cargo build --release
makensis /DVERSION=1.0.0 /DARCH=x86_64 /DBINARY="$PWD\target\release\repos-manager.exe" installers\repos-manager.nsi
repos-manager-x86_64-setup.exe is written next to the script unless you pass /DOUTFILE=path.
The full contributing guide is in CONTRIBUTING.md and on the documentation site. Issues and pull requests go to GitHub. Report vulnerabilities privately through a security advisory, see SECURITY.md.
The project lives on Forgejo and is push-mirrored to GitHub, which only receives main and tags. Each side has its own pipeline:
Forgejo (.forgejo/workflows/) |
GitHub mirror (.github/workflows/) |
|
|---|---|---|
| On every push | Format, lint, tests and coverage on Linux, ShellCheck | Format and lint, tests on Linux, macOS and Windows, ShellCheck, link check |
On a v* tag |
Linux x86_64 and aarch64 (musl), Windows x86_64 (MinGW) and its installer | Every asset: Linux, macOS (Intel and Apple Silicon), Windows x86_64 and ARM64 with both installers |
Both releases include SHA256SUMS and SHA256SUMS-binaries. The install script and repos-manager update download from GitHub by default, which is why the GitHub release carries every asset.
-
Describe the changes under
## Unreleasedin CHANGELOG.md. -
Run the release target with the new version:
The working tree must be clean. It sets the version in
Cargo.toml,Cargo.lockand the documentation site, turns theUnreleasedheading into## 1.1.0 (date), then commitschore: release v1.1.0and creates the signedv1.1.0tag (git tag -s, so a GPG signing key is needed). Nothing is pushed yet, so you can still check the result. -
Push to the forge:
The tag starts the Forgejo release, and the push mirror carries main and the tag to GitHub, which builds the full release. There is no automatic version bump: the version only changes through make release.
Since the mirror overwrites GitHub on every sync, never commit or tag directly on GitHub.