Git push

10 Nov 26

How to redefine 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. Direct git 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.

Locally-defined git sub-commands

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.

Git and default branches and locations

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.

The final git push command

With those mechanisms in place, all that’s required is to define ~/.gitbin/git-push to implement the following tasks:

  • Behave like normal if any other parameters are passed, such as git push to a specified remote.
  • Error as it should if no branch or remote information can be found.
  • Obtain gitbin.defaultBranch and gitbin.upstream configuration parameters, and issue instructive messages how to set those if they can’t be found.
  • If current branch does not equal gitbin.defaultBranch, then push only to gitbin.upstream
  • Otherwise git 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.

That script enables me to 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