Fast CLI Git manager for multiple repositories grouped by projects, with GitHub/GitLab/Bitbucket support.
gits runs one command across many git repositories at once. Point it at any
directory and it works immediately, no config needed:
$ gits status ~/code
Repo Branch Status Δ± Upstream⇅ Commit Age Message
+ api main ! – ≠1 ↑3 c1abd2ef now Add feature
web main – 4f2ba910 2d Fix layout
/ tools – not cloned
○ Showing 3 repos, 1 with changes, 1 errorGroup repositories into projects in a config file and address them by name:
gits status acme # one-line status for every repository in 'acme'
gits pull acme # pull them all
gits clone acme # clone the ones that aren't there yetA project is just a label for a group of repositories. It either lists them by hand or discovers them from GitHub, GitLab, Bitbucket or a local directory - cached, so day-to-day commands never wait on the network.
It is a single static Go binary needing nothing but git.
- One command, many repositories. Clone, fetch, pull, push, check status, or run an arbitrary command across a whole project, in parallel.
- Your repositories, discovered for you. Point a project at a GitHub user, a
GitLab group, a Bitbucket workspace, or a directory, and
gitsfinds the repositories. Results are cached with a configurable TTL. - A status view that fits on a screen. One line per repository, with branch, staged/unstaged/untracked counts, ahead/behind, version and last commit.
- Scriptable.
-o jsonemits one well-defined document fromlist,status, and every bulk command, so you can pipe it intojq. Results go to stdout and progress goes to stderr, so piping is always safe. - Interactive when you want it. Fuzzy-pick projects, repositories, branches
and tags;
cdgitjumps your shell straight into a repository. - Safe by default.
gits pushrefuses to reach--forceor--mirror, and skips branches it should not re-create.gits doctortells you what is wrong with your setup before it bites.
Most repo wranglers pick a lane: bulk cloner, read-only dashboard, or pull
request cannon. gits covers the first two in one binary.
- Nothing to set up. No manifest, no
init, no registering repos one by one. Point it at a directory and go. Bring a config later, if ever. - Pipe-friendly by design.
-o jsonon every command that lists or reports, clean stdout, progress on stderr. Your scripts, your CI, and your agents all get a straiacme answer. - Projects that nest. GitHub, GitLab, and Bitbucket, grouped into sub-projects and filtered down. Flat tag lists give up long before you do.
Homebrew (macOS and Linux):
brew install rafi/tap/gitsGo (1.27 or newer):
go install github.com/rafi/gits/cmd/gits@latestBinaries for Linux and macOS (amd64/arm64) are attached to each release.
| Dependency | Needed for |
|---|---|
git |
everything; gits drives your own git binary |
fzf |
interactive selection only (browse, checkout, cd, and prompts when you omit a project) |
Run gits doctor at any time to see which of these were found and where.
1. Write a config file at ~/.gits.yaml (or
$XDG_CONFIG_HOME/gits/config.yaml). Running gits add <project> inside a
cloned repository writes one for you when you have none:
---
# ~/.gits.yaml
# Discovered from GitHub.
acme:
path: ~/code/github
source:
type: github
search: rafi # user or organization
# Discovered from a local directory, searched recursively.
vim:
path: ~/code/vim
desc: Vim plugins
# Listed by hand, with absolute directories.
dotfiles:
desc: My dotfiles
repos:
- dir: ~/.config
src: git@github.com:rafi/.config.git
- dir: ~/.config/nvim
src: git@github.com:rafi/vim-config.gitThat file is examples/simple.yaml. For every setting with its default value, see examples/config.yaml.
2. Check that it is sound:
gits doctor3. Use it:
gits list # every project
gits list acme # every repository in project 'acme'
gits clone acme # clone the ones not cloned yet
gits status acme # where does everything stand?
gits pull acme # bring them all up to date4. Or point it at a directory, with no config at all:
gits status ~/code # every repository under a path
gits status . # every repository under the current directoryUsage: gits [command] [project] [repo] [flags]
| Command | What it does |
|---|---|
add |
Add cloned repositories to a project's list |
browse |
Browse branches and tags |
cd |
Get repository path (see cdgit) |
checkout |
Traverse repositories and optionally checkout branch |
clone |
Clone all repositories |
completion |
Generate a shell completion script |
doctor |
Report configuration and environment problems |
exec |
Run a command in every repository |
fetch |
Fetch and prune from all remotes |
list |
List project repositories |
orphan |
Find repositories on disk that no project declares |
pull |
Pull repositories |
push |
Push current branch to its upstream |
status |
Show Git repositories short status |
sync |
Synchronize project caches |
version |
Show version |
Global flags:
| Flag | Meaning |
|---|---|
-c, --config <path> |
Use a specific config file |
-C, --color <auto|always|never> |
Control colored output |
-v, --verbose |
Trace what gits did: provider pages, cache hits, git's stderr |
Commands that produce a report also take -o/--output. list offers
table, wide, tree, name and json; status, doctor and the bulk
commands offer table and json.
Every command takes a project, and optionally one repository inside it:
gits status acme # the whole project
gits status acme api # one repository
gits status acme api/ # the sub-project 'api' - note the trailing slash
gits status ~/code # a path instead of a project name
gits list acme infra # several projects at onceOmit the project and gits asks you to pick one interactively.
gits status prints one line per repository, with branch, change counts,
ahead/behind, version and last commit. Narrow it down with:
gits status acme --dirty # only repositories with uncommitted changes
gits status acme --unsynced # only repositories ahead of or behind upstream
gits status acme --stat # add a column of uncommitted line countsgits add writes already-cloned repositories into a project's repos: list,
creating the project if needed. Each argument is a directory, a glob, or a clone
URL:
gits add acme # the current directory
gits add acme ./api ../shared/tools # directories
gits add acme 'backend*' # every repository matching a glob
gits add acme git@github.com:acme/api.git # cloned into the current directory firstThis is for hand-listed projects. For one backed by a source, use
gits orphan to find repositories on disk that no project declares.
gits push pushes each repository's current branch to its upstream:
gits push acme # push every repository in project 'acme'
gits push acme -n # git's own --dry-run, per repository
gits push acme --tags # push tags instead of the current branchA branch with no upstream, or one whose upstream is gone, is skipped rather than
failed. Also supported: --all, --branches, --follow-tags, --atomic and
--prune.
--force, --force-with-lease, -u/--set-upstream and --mirror are
deliberately unreachable: one mistyped flag across forty remotes is not
recoverable the way it is against one.
gits exec runs one command in every repository. Everything after -- is the
command:
gits exec acme -- git gc --quiet # in every repository of 'acme'
gits exec acme api -- git log -n 1 # in one repository
gits exec acme -- sh -c 'git log -1 --format=%s | tr a-z A-Z' # pipes need a shellCommands run directly rather than through a shell, each in its repository, with
GITS_PROJECT, GITS_REPO and GITS_REPO_PATH exported.
gits cd prints a repository's path; a child process cannot change your shell's
directory. Source the provided function from your ~/.bashrc or ~/.zshrc
(fish users want contrib/cdgit.fish):
source /path/to/gits/contrib/cdgit.shThen:
cdgit acme api # cd straight there
cdgit # pick a project and repository interactivelygits sync drops each project's cached repository list and refetches it from its
code forge:
$ gits sync
[1/2] acme [github:acme] flushed · ~/code/acme · 42 repositories
[2/2] infra [gitlab:mygroup] flushed · ~/work/infra · 17 repositories
Synchronized 2 projects, 59 repositories.Name projects (gits sync acme infra) to sync only those. Only forge-backed
projects have a cache to refresh.
gits doctor reports what is wrong with your configuration and environment, in
one place:
$ gits doctor
info config using ~/.gits.yaml
error config unknown config key "acme.pth" in ~/.gits.yaml, ignored
error acme.one no `path:` on the project and no `dir:` on the repository, so there is nowhere for it to live
warning vim `path: ~/code/vim` does not exist yet; `gits clone` will create it
info git git version 2.55.0 (/opt/homebrew/bin/git)
info finder 0.74.3 (Homebrew) (/opt/homebrew/bin/fzf)
info cache directory ~/.cache/gits
warning cache.github-acme written by gits v0.10, will be refetched (cached 9d ago)
info cache.github-rafi cached 1d ago, valid for 168h0m0sgits completion <bash|zsh|fish|powershell> prints a completion script; the
sub-command's help says where your shell expects it.
Tab completes project names, repositories, branches, and flag values. Names come from the cache, so completion never waits on the network.
The configuration file lives at ~/.gits.yaml or
$XDG_CONFIG_HOME/gits/config.yaml. JSON and TOML are supported too, so
.json, .yml and .toml extensions work at either location. Use
gits -c <path> to point at any other file.
See examples/config.yaml for a fully documented config file listing every setting with its default value, or examples/simple.yaml for a minimal one.
Warning
Each project in the config file can have either a source or a repos key,
not both.
The structure of the config file is as follows:
---
# ~/.gits.yaml
# Project definition
projectname: # Project name
desc: My projects # Optional
path: ~/code/github # Optional if 'repos' are specified and have absolute paths.
source: # Required if no 'repos' defined, default: filesystem
type: github # Required: github|gitlab|bitbucket|filesystem
search: rafi # Required search query (organization, user name, group id)
repos: # Required if no 'source' defined
- dir: foo # Optional, default: repository name
src: git@... # Optional, default: repository remote URL
- ...
include: [...] # Optional allowlist of repositories
exclude: [...] # Optional denylist of repositories
clone: true # Optional, set false to skip during `gits clone`
subprojects: [...] # Optional nested projects
anotherproject:
...Note
When dir is omitted, the local directory is derived from the last path
segment of src, with only a trailing .git suffix stripped. Earlier
versions stripped everything after the last dot, so a dotted repository name
without a .git suffix (e.g. rafi.github.io) previously mapped to
rafi.github and now maps to rafi.github.io. If you cloned such a repo with
an older version, rename the directory (or set dir: explicitly) to match.
include and exclude trim what a project contributes, which is handy when the
source isn't yours to curate. Both match a repository by name, namespace, or
namespace/name - exact strings, no globs:
work:
path: ~/code/work
source:
type: gitlab
search: "12345678"
exclude:
- acme/legacy-api # namespace/name
- deprecated-tool # name
# include: # when non-empty, only what it names is kept
# - acme/api
# - acme/webexclude always wins. A non-empty include is a guest list: anything unnamed
stays home. Sub-projects get filtered too, each by its own lists.
Real orgs don't keep their repositories in one tidy pile. GitLab groups nest as
deep as you like, and sub-projects are how gits follows them down. Point a
project at a GitLab group and the whole tree comes back with its shape
intact - no flattening, no listing each subgroup by hand.
You can also build that tree yourself. Nest projects under subprojects, as a
list, so each carries its own name:
org:
path: ~/code/org
repos:
- dir: platform
subprojects:
- name: frontend # path becomes ~/code/org/frontend
desc: Web clients
repos:
- dir: web
src: git@github.com:myorg/web.git
- name: backend
path: ~/code/services # an explicit path wins over the default
source: # discovered on its own, like a top-level project
type: github
search: myorg-backendA sub-project inherits what it doesn't declare: a path of
<parent path>/<name>, and its parent's source. No parent path means
nothing to inherit, so use absolute dir entries or give it a path. A project
with only subprojects is fine - it's a folder, not a freeloader.
An inherited source only says where repositories come from, so a
provider-backed one you haven't cloned reads as remote-only instead of an
error. It won't go discover anything twice. Want a sub-project fetched on its
own? Give it its own source, or just list repos.
Note
A project with a path: and no repos: is searched recursively, and that
search descends into its sub-projects' directories too. If a sub-project also
lists those repositories, they appear twice. Give such a parent an explicit
repos: list, or point the sub-projects at directories outside the parent's
path.
A trailing slash is how you say "sub-project, not repository":
gits status org frontend/ # the sub-project 'frontend'
gits status org web # the repository 'web'Built-in behavior is configured under the reserved settings: key:
settings:
cacheTTL: 168h # How long provider caches stay valid. Go duration
# syntax (e.g. "24h", "30m"). Default: 168h (7 days).
# Invalid or empty values fall back to the default.
workerCount: 8 # Concurrent git workers. Default: max(NumCPU, 2).
verbose: false # Trace what gits did on the way to an answer - provider
# pages, cache hits, git's stderr - to stderr. Same as -v.
includeArchived: false # Include archived repositories when listing
# from providers (GitHub, GitLab). Default: false.
# Bitbucket Cloud has no archived flag, so the
# setting does not apply there.
providerTimeout: 5m # HTTP timeout for provider API calls. Go
# duration syntax. Default: 5m.
gitTimeout: 5m # Timeout for network git operations
# (clone/fetch/pull). Go duration syntax.
# Default: 5m.
finder: # Interactive selector overrides. gits shells out
# to fzf; override to use a drop-in replacement or
# tune the invocation.
binary: fzf # Executable to run. Must be on PATH.
# args: [...] # Replaces the built-in default options wholesale.
# extra: [...] # Appended after args, adding options.Provider caches are written to $XDG_CACHE_HOME/gits (~/.cache/gits by
default); gits doctor prints the directory in use.
Remote providers need an API token. Configure it per provider under settings:,
either verbatim or as a command that prints it:
settings:
github:
tokenCommand: pass tokens/github # `token-cmd` is also accepted
gitlab:
tokenCommand: op read op://private/gitlab/token
bitbucket:
tokenCommand: pass tokens/bitbucketFor each provider the first of these wins:
token- used as-is. Keep in mind your config file is plain text.tokenCommand(aliastoken-cmd) - run through the shell, so pipes and quoting work; the first non-empty output line is the token. It runs at most once per command pergitsinvocation, so a passphrase prompt appears once even with several projects on the same provider. A failing command is an error - there is no silent fallback.- Environment:
GITHUB_TOKEN(orHOMEBREW_GITHUB_API_TOKEN),GITLAB_TOKEN,BITBUCKET_TOKEN.
Bitbucket takes an Atlassian API token in either of two forms:
settings:
bitbucket:
token: me@example.com:my-api-token # Atlassian account email and token
# token: my-api-token # the token aloneGive it the read:repository:bitbucket and read:workspace:bitbucket scopes,
which is all discovery reads. A legacy username:app-password still works
wherever Bitbucket still honors it, but Atlassian has deprecated app passwords
in favor of API tokens, so prefer a token for anything new.
Cached projects don't need a token until the cache expires or gits sync
refreshes it.
Each project in the following example is defined differently:
---
# ~/.gits.yaml
# Github source.
mygithub:
source:
type: github
search: rafi
# GitLab source, note that `path` and `desc` are optional.
work:
path: ~/code/work
desc: My work GitLab projects
source:
type: gitlab
search: "12345678" # Make sure GitLab group id is quoted
# Bitbucket source.
mybitbucket:
source:
type: bitbucket
search: rafi
# Filesystem source that will be searched recursively.
explore:
desc: Exploring projects
path: ~/code/explore
source:
type: filesystem
# Relative directory name and implicit remote source URL.
acme:
path: ~/code/acme
desc: Acme is a really cool app.
repos:
- dir: admin
- dir: ant-design-pro
- dir: api
- dir: infra
- dir: ios
- dir: react-native
- dir: web
- dir: webapp
# Relative directory name and explicit remote source URL.
myapp:
path: ~/code/myapp
repos:
- dir: api # Can be absolute or relative to path
src: https://github.com/app/api.git # Optional remote clone URL
- dir: ios
src: https://github.com/app/ios.git
- dir: android
src: https://github.com/app/android.git
# Absolute directories and explicit remote source URL. (No project path)
rafi:
desc: My dotfiles
repos:
- dir: ~/.config
src: git@github.com:rafi/.config.git
- dir: ~/.config/nvim
src: git@github.com:rafi/vim-config.gitgits is written in Go and needs no code generation or vendored tools to
build:
git clone https://github.com/rafi/gits.git && cd gits
go build ./cmd/gits
go test ./...A justfile wraps the common tasks (just
required):
just build # build the binary into bin/release
just test # run the test suite
just lint # golangci-lint
just release # cross-compile for linux/darwin, amd64/arm64Contributions are welcome - please open an issue or pull request on GitHub.
MIT © 2018-2026 Rafael Bodill. See LICENSE.txt.
