10 Nov 26
git push for an era of distributed code. No canonical source;
no platform lock-in. A re-implementation of git push that sends your code
anywhere and everywhere. Nice features like branch protection are still
possible, but able to defined locally, remotely, or both. Single locations
can be defined as “canonical” upstream sources. Or code can have no
canonical source, and just spread out and be used and interacted with
wherever people like.
I’ve previously written about the why’s and how’s of hosting code across multiple locations, both here (way back in 2019) and in rOpenSci’s blog (Dec 2025). This is a short technical update on my current approach. Most of my code now lives nowhere in particular, and each repository has several remote sites. The source code for this site, for example, has these remotes:
> git remote -v
codeberg git@codeberg.org:mpadge/mpadge.eu (fetch)
codeberg git@codeberg.org:mpadge/mpadge.eu (push)
codefloe git@codefloe.com:mpadge/mpadge.eu (fetch)
codefloe git@codefloe.com:mpadge/mpadge.eu (push)
github git@github.com:mpadge/mpadge.eu (fetch)
github git@github.com:mpadge/mpadge.eu (push)
tangled git@tangled.org:mpadge.eu/mpadge.eu (fetch)
tangled git@tangled.org:mpadge.eu/mpadge.eu (push)
The repository lives on Codeberg,
Codefloe,
GitHub, and
Tangled. This post describes how I
redefined git push so that one single command simultaneously pushes to all
listed remotes, while still enabling features like branch protection.
Protected branches ensure GitHub remains in control
GitHub has long had the aim of being the single “source of truth” for software, and has in many ways succeeded. GitHub implements “protection rules”, designed among other things to protect specific branches and so allow only merge commits. Directgit push actions to branches protected
on GitHub will generally fail. This is good and wise. But it has the major
drawback of making you entirely reliant on GitHub to control your workflow.
The approach I describe here also allows you to implement the same branch
protection ideas on any remote, or even entirely locally.
This workflow requires re-defining the standard git push command. New Git
commands can be defined
anywhere; I put
them in a local ~/.gitbin directory. Any file named git-<something> within
that directory will be magically converted into a git command minus the
intervening -. So git dothis will execute the contents of
~/.gitbin/git-dothis.
That process is, however, only automatic for commands that are not pre-defined
within Git itself. git push is, of course, one of the most commonly used of
all standard Git commands. Redefining that thus requires intercepting the
command itself and re-directing git push to ~/.gitbin/git-push. I do that
with this simple re-definition within my ~/.bashrc file:
git() {
if [ "$1" = push ]; then
shift
git-push "$@"
else
command git "$@"
fi
}
A little bit of extra magic below ensures that any extra parameters sent with
git push behave exactly as expected. Only default git push behaviour with
no parameters is re-defined.
As said above, I also wanted a way of defining any arbitrary remote as the default or primary source, and of implementing some kind of branch protection rules. Branch protection is offered on any Foregejo-compatible platform, including Codeberg and Codefloe. Branch protection can also be implemented entirely locally through pre-commit hooks. Any script that errors when it is run on a specified branch will then prevent local commits to that branch. Many of my repositories have both local and remote branch protection.
One general problem is, however, that Git itself has no notion of “primary” or “default” branches. But Git allows arbitrary configuration parameters. You can easily run this:
git config my.specialsetting true
That value will be recorded in your local .git folder only, and git config
my.specialsetting will then show true. Your can easily use git config
parameters to define both a primary branch and a default remote. I prefix
parameters related to ~/.gitbin/ definitions with gitbin, and so define
these like this:
git config --global gitbin.defaultBranch main
git config gitbin.upstream codeberg
I use -global for the default branch, but that can easily be overwritten for
any repository. With those settings, the git push command can be defined so
that any commits to any branches other than defaultBranch are only pushed to
upstream. Merging of protected branches can then be handled remotely at that
upstream if desired. Once merged, git push from the defaultBranch will have
no effect on the default upstream, but will then push the merged result to
all other remotes.
With those mechanisms in place, all that’s required is to define
~/.gitbin/git-push to implement the following tasks:
git push to
a specified remote.gitbin.defaultBranch and gitbin.upstream configuration parameters,
and issue instructive messages how to set those if they can’t be found.gitbin.defaultBranch, then push only to
gitbin.upstreamgit push to each remote source.This script can also be defined in any scripting language at all. Because it
must execute many git commands, a sensible option is bash. And this is it:
#!/usr/bin/bash
# Usage: git-push [<remote>|all] [<branch>] [<further git push args>]
#
# - Any named remote is pushed to like a normal `git push`, with all args
# passed straight through.
# - "all" pushes the branch (default: current) to every remote.
# - No args: pushes to every remote if the branch is the "default" branch,
# otherwise only to the single upstream remote.
#
# "Default branch" and "upstream remote" are forge-agnostic and must be set
# with git config, either locally (per repo) or globally:
#
# git config gitbin.defaultBranch main # branch that fans out to all
# git config gitbin.upstream origin # remote that gets other branches
#
# (add --global to set for all repos). Not needed for `git-push <remote>`
# or `git-push all`.
# --- Default behaviour if any additional parameters passed:
if [ -n "$1" ] && [ "$1" != "all" ]; then
exec git push "$@"
fi
FORCE_ALL=""
if [ "$1" == "all" ]; then FORCE_ALL=1; shift; fi
# --- Check that both branch and at least one remote are defined:
BRANCH=${1:-$(git branch --show-current)}
if [ -z "$BRANCH" ]; then
echo "No branch given and not on a branch" >&2
exit 1
fi
[ $# -gt 0 ] && shift
REMOTES=$(git remote)
if [ -z "$REMOTES" ]; then
echo "No git remotes found" >&2
exit 1
fi
# --- Check for required `gitbin` configuration parameters
if [ -z "$FORCE_ALL" ]; then
UPSTREAM=$(git config gitbin.upstream)
DEFBRANCH=$(git config gitbin.defaultBranch)
if [ -z "$UPSTREAM" ] || [ -z "$DEFBRANCH" ]; then
{
echo "git-push: required git config not set:"
[ -z "$DEFBRANCH" ] && echo " gitbin.defaultBranch ('default' branch pushed to ALL remotes)"
[ -z "$UPSTREAM" ] && echo " gitbin.upstream (single remote for all other branches)"
echo ""
echo "Set them for this repository only:"
echo " git config gitbin.defaultBranch main"
echo " git config gitbin.upstream origin"
echo ""
echo "or for all repositories (add --global):"
echo " git config --global gitbin.defaultBranch main"
echo " git config --global gitbin.upstream origin"
echo ""
echo "A local setting overrides a global one."
echo "Or bypass with: git-push all | git-push <remote>"
} >&2
exit 1
fi
if ! grep -qx "$UPSTREAM" <<< "$REMOTES"; then
echo "git-push: gitbin.upstream '$UPSTREAM' is not a remote of this repo" >&2
exit 1
fi
fi
# --- If `git push` to `gitbin.defaultBranch`, then re-define `REMOTES` as
# --- single `UPSTREAM` remote only.
if [ -z "$FORCE_ALL" ] && [ "$BRANCH" != "$DEFBRANCH" ]; then
echo "Branch '$BRANCH' is not default ('$DEFBRANCH'); pushing to $UPSTREAM only"
REMOTES=$UPSTREAM
fi
# --- Loop over all `REMOTES` and `git push` to each:
STATUS=0
for name in $REMOTES; do
echo ""
echo "--------Pushing to $name"
# Explicit refspec so the branch need not track this remote; stdin is
# detached so git cannot consume anything from the caller.
git push "$name" "refs/heads/$BRANCH:refs/heads/$BRANCH" "$@" < /dev/null \
|| { echo "Push to $name failed; continuing"; STATUS=1; }
done
echo ""
exit $STATUS
The current version of this git-push script can be seen in my dotfiles
repository.
git push my code to all of its remote
locations, to protect “default” branches both locally and remotely,
and to choose any location as the canonical “upstream” source of my code.
Or just as easily, to have no single canonical source, and simply let my
code spread out and be used and interacted with wherever people like.
Copyright © 2019--26 mark padgham