Replacing Gitea with Gitolite

Posted November 14, 2025

Today, I decided to update the services I host under this domain, and ideally clean the server up a little.

Most updates went smoothly (openwebui, docker-mailserver, ntfy, etc.), except for one: Gitea. If you don’t know what Gitea is, it’s a free & open-source alternative to Github, that you can self-host. I use it to store all of my personal code on my server, so I don’t have any chance of e.g. Github restricting my access to my own code.

The issue with the Gitea update was that, at some point since I last updated, they switched from PostgreSQL (the database they use) version something-less-than-18 to version 18, which apparently changes how it expects it’s own files to be laid out. And, for some reason, the transition from the old layout to the new wasn’t automatically done, at least not on my installation.

While I’m sure there is a reasonable and simple-enough fix for this, and while I did actually try to fix it for a while, one advantage of managing everything myself is that I can just give up on things. Which is what I did!

Out with gitea, In with gitolite!

Gitolite

Gitolite is software that lets you manage a git server, with one of the main features being advanced authorisation (per-repo, per-branch, per-whatever you want), all without needing a complicated setup.

At it’s core, gitolite a bunch of perl scripts that get called by the sshd daemon, and which directly handles the git files/repos on your server. This is very in-line with the File-over-App philosophy, and I think it’s “boring” software, in the sense that it’s not full of shiny features and many fragile parts that break easily, e.g. when you update. It just works.

I won’t go in full details on how to install gitolite, mainly because the installation instructions are pretty clear, and you can look for one of the hundreds of blog posts that walk you through installing it.

Installation was mostly painless for me, except for two things:

My config file, for reference:

repo gitolite-admin
    RW+     =   <my user>

repo <my user>/[a-zA-Z0-9].*
	RW+     =   <my user>

repo <xxx>/[a-zA-Z0-9].*
	RW+     =   <my user>

It’s a pretty barebones/basic configuration. <something> is used to mean that something else is written there, e.g. <my user> is replaced in the real config by my username. I used <my user>/<repo name> because I “imported” everything from gitea, and that reflects the layout that gitea used (and github, gitlab, sourcehut, codeberg, and most git forges, use).

Fixing my CGI scripts

Now that I had successfully replaced Gitea with Gitolite, I had to update the two scripts whose output you’re greeted with when you open this site: the ‘activity graph’, and the ‘total lines of code written/removed’ stats.

What I'm talking about.
What I'm talking about.

These scripts used to directly go read all the repositories under ~/gitea/data/git/repositories and count the commits I made.

With Gitolite, the repos were under the home of another user, using stricter permissions (I’m not actually sure why I was able to read the files in the home directory of the user under which gitea was ran), so I couldn’t just go read everything like a monkey.

The solution was obvious, underlined by the simple design of gitolite: hooks ! Git has hooks, which you can use locally on your computer (e.g. to run a linter on your code before you push), or on the server (e.g. to disallow pushing if the code doesn’t compile). They’re quite literally just scripts/executables.

Gitolite, being a very thin wrapper around git, lets you use hooks the same way that you’d normally use them with git. It does improve the experience in one aspect: when you place a hook in ~/.gitolite/hooks/common and run gitolite setup --hooks-only, the hooks you placed are symlinked to each repo you have, instead of copied on init like how git does it. Which is kind of great.

My post-receive hook, for reference:

#!/bin/env bash

set -e
umask 022

##############  CONFIG  ##############
AUTHORS='xxx'
OUT_DIR='/var/www/git-stats'
mkdir -p "$OUT_DIR"

# Repos to ignore
EXCLUDE=(
        "xxx/yyy"
)
######################################################################

FULL_NAME=$(pwd | sed 's|.*/repositories/||; s|/hooks.*||')  # foo/bar.git
SAFE_NAME=${FULL_NAME//\//_}                                 # / -> _

# --------  skip excluded repos  ------------------------------------
for pat in "${EXCLUDE[@]}"; do
    case ${FULL_NAME%.git} in
        $pat)  exit 0 ;;
    esac
done

COMMIT_FILE="$OUT_DIR/${SAFE_NAME}.commits"
LOC_FILE="$OUT_DIR/${SAFE_NAME}.loc"

# 1. commits per day -------------------------------------------------
git log --all --pretty=format:'%ad %an' --date=short |
awk -v PAT="$AUTHORS" '$0~PAT {print $1}' |
sort > "$COMMIT_FILE.tmp" &&
mv "$COMMIT_FILE.tmp" "$COMMIT_FILE"

# 2. total +/- lines -----------------
git log --all --pretty= --numstat |
awk 'NF==3 && $1+0>=0 && $2+0>=0 {add+=$1; del+=$2}
     END {printf "%d\t%d\n", add, del}' > "$LOC_FILE.tmp" &&
mv "$LOC_FILE.tmp" "$LOC_FILE"

chmod 644 "$COMMIT_FILE" "$LOC_FILE"

I then updated both scripts to read from respectively /var/www/git-stats/*.commits and /var/www/git-stats/*.loc.

Since computers are extremely fast, and I trust that git is fast enough, I’m not sure how much of a performance difference this makes. Both scripts could be replaced with a cron job that runs every 15 minutes and regenerates static files containing the same thing, to avoid having the user wait for the regeneration when the cache is expired, but I can’t bring myself to take out the scripts fully from the source code of the site, it feels like scattering the source code of my site.