Photo of Joakim Hedlund

Joakim Hedlund

Web enthusiast

Git remote that automatically initializes any pushed repository

Last updated

The idea

I have a home server that I want to treat as a Git remote, but I don't want to have to log in somewhere and create repositories whenever I want to run a small experiment. Lets set it up so that any repository that doesn't already exist is automatically initialized.

At first I looked around for self-hosted Git... hubs? Applications like GitLab and Gitea that come with a lot of bells and whistles that are generally aimed at collaborative setups and very overkill for my usecase. While researching and comparing, I ran into a comment that made me audibly facepalm;

Why not use git over ssh?

Git is so simple it hurts sometimes.

It's not uncommon to be a little scared of Git, thinking it's enshrouded in CLI mystery and can only safely be channeled through a catalyst with a UI. If you dig into the deeper mechanics of how objects are stored this notion may very well be true in some aspects, but today we're focusing on the simplicity that makes it beautiful and the principles that makes it incredibly powerful.

The solution

The solution we'll set up in this article is based on a two conclusions:

  • A git remote does not have to be remote. You can clone and push to a repository on your local machine: git clone /projects/my-original-repo. Thus, git contains everything needed to act as a remote repository.
  • SSH is just a means to invoke git. By default, when you run git clone [email protected]:Username/RepoName.git you are logging in as the git user on github.com via SSH.

Creating the git user

  1. Create a git user that clients will be able to ssh into.
    sudo useradd -m git
    mkdir -p /home/git/.ssh
    touch /home/git/.ssh/authorized_keys
  2. Configure which shell is used for the git user. For the default Git experience you would use git-shell, but because we want to write a custom script to override what happens during an incoming push we'll run bash:
    sudo chsh -s /bin/bash git
  3. Add any public keys you're going to use as a line in /home/git/.ssh/authorized_keys, but disable features we don't expect we'll need. It should look something like this:
    no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAAA JOAKIM-PC

    where AAAAAA is your public key.

Setting up the custom push functionality

  1. Create a /home/git/git-receive-pack.sh script where we'll register the function Git calls after connecting via SSH:

    function git-receive-pack() {
      # Use /srv/git as our base directory
      if [ ! -d /srv/git ]; then
        mkdir -p /srv/git || exit $?
      fi
      cd /srv/git
    
      # Automatically create repository being pushed
      if [ ! -d $1 ]; then
        echo "Initializing repository" >&2
        mkdir -p $1 && cd $1 && git init --quiet && git config receive.denyCurrentBranch ignore && git config advice.detachedHead false && cd $OLDPWD
      fi
    
      # Let native git do its thing
      command git-receive-pack $@
    
      # Checkout the latest commit regardless of branch
      cd $1
      git checkout "$(git log --all -1 --format=%H)"
      # make sure HEAD and worktree are in sync
      git reset --hard
    }

    You may notice that the repository isn't initialized using git init --bare which is often the recommended approach if you just want to push and pull to your remote. I, however, want to be able to interact with the worktree. In the last section of the script you'll see it checks out the latest commit, regardless of branch, so whenever a push is received the server will checkout whatever branch was last updated.

  2. Finally, in /home/git/.bashrc, register our function somewhere at the top of the file:
    source /home/git/git-receive-pack.sh

Trying it out

On your local machine:

# set it up locally
mkdir potato-repo
cd potato-repo
git init
git remote add laboratory git@my-home-server:potato-repo

echo "Hello world" > README.md
git add README.md
git commit -m 'My first commit!'
git push laboratory

Your output should look something like this:

Initializing repository
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 233 bytes | 233.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
HEAD is now at 2d29073 My first commit!
To my-home-server:potato-repo
 * [new branch]      main -> main
branch 'main' set up to track 'laboratory/main'.

Related reading

I found these links incredibly helpful:

Git push to a new repository on remote, initializing on the fly

Code Your Own Multi-User Private Git Server in 5 Minutes