Safe, fast-forward pull every git repository under a directory.
Built for workspaces made of many independent repositories, where keeping
everything current means cd-ing into each one.
$ pullr ~/work
+ api (main, 3 new commits)
+ web (main, 1 new commit, local changes reapplied)
~ docs (main, 2 commits held back because local changes conflict with them)
- scratch (main has no upstream)
x infra (main)
diverged from upstream (behind 2, ahead 1): cannot fast-forward
Repos: 5 updated: 2 up to date: 0 dirty: 1 skipped: 1 failed: 1
Dirty: docs
Failed: infra# Homebrew
brew install kova1max/tap/pullr
# npm
npm install -g @kova1/pullrTo try it without installing anything:
npx @kova1/pullr --dry-run ~/workEvery release is also mirrored to
GitHub Packages as
@kova1max/pullr (GitHub requires the repo owner as scope). Installing from
there needs a GitHub token, so the npm registry above is the easier choice.
pullr is built to be safe to run across a whole workspace without looking first. It will never:
- Create a merge commit. Pulls are fast-forward only.
- Touch a branch that has diverged from its upstream. It is reported as
failed and left exactly as it was. Rebasing it is opt-in with
--rebase, and a rebase that hits a conflict is aborted, leaving the repository as it was. - Leave your changes half-merged, or lose them. When local edits or
untracked files are in the way of an update, pullr stashes them, updates,
and re-applies them on top. If they conflict with the incoming commits, it
puts the repository back exactly as it was - same commit, same files, same
staged and unstaged changes, no stash left behind - and reports it as
dirty. Edits that don't touch incoming files are never stashed at all.
--no-autostashskips the stash and just reports the repository as dirty. - Touch a repository on a detached
HEAD, or a branch with no upstream. Both are skipped. - Enter a nested repository. Repositories inside another repository are left to their parent.
- Move a submodule off its branch. With
--submodules, a submodule is only ever fast-forwarded on the branch it is on. (If you set git'ssubmodule.recurse, pullr does whatgit pulldoes with it: checks submodules out at the recorded commit.) - Follow symlinked directories.
pullr [options] [DIR]
Short options can be combined: -rs is -r -s, and -j8 or -rj 8 set
the number of jobs.
| Option | Default | Meaning |
|---|---|---|
DIR |
current directory | Where to look for repositories |
-j, --jobs N |
8 |
Work on up to N repositories in parallel. Output is buffered per repository and printed in discovery order, so it reads the same as a sequential run. With N > 1 git never prompts for credentials; use --jobs 1 for repositories that need an interactive login. |
--max-depth N |
2 |
How many directory levels below DIR to search. 0 = only DIR itself, 1 = DIR and its direct children, ... |
-n, --dry-run |
Show what a run would do without pulling, see below | |
-r, --rebase |
off | Rebase local commits onto the upstream instead of refusing a diverged branch (git pull --rebase). A rebase that hits a conflict is aborted and the repository is left as it was. |
--no-autostash |
Don't stash local changes that are in the way of an update; report the repository as dirty instead. Autostash is on by default, see "What it will never do". | |
-s, --submodules |
off | Also fast-forward each submodule that is checked out on a branch, see below |
--no-color |
Disable colored output. Also disabled when the NO_COLOR environment variable is set, or when output is not a terminal. |
|
-V, --version |
Print the version | |
-h, --help |
Show help |
Each repository gets one line: + updated, = already up to date, ~
dirty (local changes conflict with the update, which is held back), -
skipped, x failed (with the reason indented below it). Exits 1 if any
pull failed, 2 on invalid arguments, 0 otherwise; a dirty repository is
not a failure.
pullr --dry-run shows what a run would do, without pulling:
$ pullr --dry-run ~/work
Dry run: nothing will be pulled.
+ api (main, 3 new commits)
= web (main)
x infra (main)
diverged from upstream (behind 2, ahead 1): cannot fast-forward
- scratch (main has no upstream)
Repos: 4 would update: 1 up to date: 1 dirty: 0 skipped: 1 would fail: 1
Failed: infraIt runs git fetch in each repository first, so the answer reflects the
remote as it is now, then prints the same lines a real run would. An update
that would need to stash local changes shows as + ... , local changes to autostash, and with --rebase a diverged repository shows as
+ ... , 2 to rebase instead of x; whether re-applying or rebasing would
conflict is only known by running it. Fetching
only updates remote-tracking refs: the working tree and current branch are
never modified. Exit codes match a real run.
By default pullr updates a repository the way git pull does: submodules stay
where they are, unless you set git's submodule.recurse, in which case they
are checked out at the commits the parent now records, as git pull would.
--submodules is for working inside submodules. Each submodule that is
checked out on a branch is treated as a repository of its own - fetched,
fast-forwarded with the same rules, and listed under its parent:
$ pullr --submodules ~/work
+ app (main, 2 new commits)
+ app/lib/core (main, 5 new commits)
- app/vendor/sdk (detached HEAD)Submodules on a detached HEAD (git's default after git submodule update) are
skipped, and uninitialized ones are left out.
| pullr | gita | gitup | myrepos (mr) |
mani | |
|---|---|---|---|---|---|
| Needs | bash and git | Python 3.8+ | Python 3.10+ | Perl | Go binary |
| Finds repos | scans a directory | register first (gita add) |
scans a directory | register first (~/.mrconfig) |
register first (mani.yaml) |
| Pulls in parallel | 8 at a time by default | all at once, no limit | no | opt-in | opt-in |
| Update | fetch + fast‑forward | git pull |
fetch + fast‑forward | git pull |
no built-in pull |
| Merge commits | never | depends on your git config | never | depends on your git config | depends on your command |
| Local changes in the way | stashed and reapplied; rolled back on conflict | git's error | skipped | git's error | - |
| Dry run | yes | no | no | no | prints commands |
Why "8 at a time" and not "all at once". Many self-hosted git servers
limit how many SSH connections can be opening at the same time; OpenSSH's
default (MaxStartups 10:30:100) starts refusing them past 10. Fetching
every repository at once then fails for reasons that have nothing to do with
your repositories. On a self-hosted GitLab with 61 repositories, 16 parallel
fetches produced 6-9 of those spurious failures and 32 produced 10-14, while
8 produced none. GitHub itself handled 64 at once without errors, so raise
-j freely there.