Featured image of post Live Remote Git Repo

Live Remote Git Repo

How to create a Git repo you can push to, with a real copy of HEAD in the work tree.

For this blog, I’m using the static site generator Hugo, but manually deploying a new build is too boring for me. Since I don’t use Github (and don’t want to bother with the alternatives - I do most of my Git operations from the command-line anyway), I can’t simply use some CI/CD service. On the other hand, since even my public Git repos are just “real” Git repos on a server I can SSH to, I can just use normal Git commands to do it!

The ultimate goal was to have what looks like a normal copy of a Git repo that you would do development on: a blog/ with all of the project files, and a .git subfolder.

There are a few different ways to achieve this, but mine has the advantage of looking like a “normal” Git repo on both sides.

Creating the remote repository

Normally, to create a remote Git repository (one that can be pushed into), you want to make a “bare” repository: git init --bare blog.git.

For this, the only change is dropping --bare and running: git init blog. Simple!

But, if you try to push to it now, you’ll see an error like this:

remote: error: refusing to update checked out branch: refs/heads/main
remote: error: By default, updating the current branch in a non-bare repository
is denied [...]
You can set the 'receive.denyCurrentBranch' configuration variable
to 'ignore' or 'warn' in the remote repository to allow pushing [...]

Allow pushes (some of the time)

Well, that was sure nice of it to tell me what to do! Ok, so we run git config receive.denyCurrentBranch ignore, and… it seems like it works, or at least it accepts the push.

But, if you check the documentation (or just check the files on the remote side), you’ll notice this doesn’t actually update the working tree. You have to go read the docs to find out that you needed to set it to updateInstead, instead.

Deploy those pushes!

I made a symlink set up in my web server’s document root, pointing https://jfr.im/blog/ to Hugo’s public folder, so all that’s left to do is run Hugo whenever a push is received.

This is actually really easy: even though Git is essentially just copying files over SSH, it can run hooks at various stages of the process! One of those is the update hooks, which does just the thing I want:

echo '#!/bin/sh' >.git/hooks/update
echo 'cd ..' >>.git/hooks/update # We start out in `.git` so we need to go up
echo 'hugo' >>.git/hooks/update # Run hugo to make the new HTML!
chmod u+x .git/hooks/update

And that’s job done! (Actually, I end the file with cd .git; exec git update-server-info - this allows me to serve the repo over HTTP if I wanted.)

Allow pushes (all of the time)

Of course, now you tell yourself you’ve got it all working, you find a new error:

 ! [remote rejected] main -> main (Working directory has unstaged changes)
error: failed to push some refs to '../bar'

What happened? Well, you made some changes on the remote side and didn’t pull them down, you silly goose!

One solution to this is to go commit the changes on the remote side; then you can fetch them and merge them into your local copy. That would probably be fine, but I don’t really want to deal with merges, and I don’t make commits remotely (I can’t sign them).

So, you think, surely you can find a hook to let you fix this. You can!

push-to-checkout

This hook is invoked by git-receive-pack(1) when it reacts to git push and updates reference(s) in its repository, and when the push tries to update the branch that is currently checked out and the receive.denyCurrentBranch configuration variable is set to updateInstead. Such a push by default is refused if the working tree and the index of the remote repository has any difference from the currently checked out commit … [This hook] can make any necessary changes to the working tree and to the index to bring them to the desired state when the tip of the current branch is updated to the new commit, and exit with a zero status.

That sounds a bit daunting, and it goes on to give an example which doesn’t help a whole lot. But there’s also a sample file, .git/hooks/push-to-checkout, and that was helpful. All you have to do is change a few errors into… calls to git stash push, and now the push keeps working, and you can recover the remote changes if you need to.

if ! git update-index -q --ignore-submodules --refresh
then
    git stash push -m "push-to-checkout: Up-to-date check failed"
fi

# ...

if ! git read-tree -u -m "$commit"
then
    die "Could not update working tree to new HEAD"
fi

And now you’re done for real! (Or for now?)