Three Forges, One Man

There are three places my code lives: github.com/zarlo, gitlab.com/zarlo, and git.punksky.com running on hardware I control.

Two of them are dead weight and one is doing all the work, which took me a while to accept.

GitHub has 95 repositories and none of them are new

The numbers first, because they're the whole argument.

Ninety-five repositories, four hundred and forty-two stars, twenty-nine followers. Of those ninety-five, fifty-nine are forks. Of the thirty-six that are mine, the most recently touched is fastapi_session, in March 2024.

There is exactly one repository on the account that's seen a commit in the last four months, and it's zarlo/zarlo an auto-generated profile README containing seventy-odd theme variants of a summary card. I didn't write it. A workflow did.

The forks are more interesting. Cosmos and nativeaot-patcher have both been pushed to recently, and both have recent commits, and none of them are mine. They're valentinbreiz's, from upstream, landing in my fork because that's what happens when you track a moving target. illacceptanything has commits from strangers arguing about the internet.

That's the honest shape of a decade on a public forge: a large archive of other people's code that I was allowed to keep, plus a small amount of my own that stopped moving a long time ago.

GitLab has 21 projects and I stopped shipping to it

Every one of the twenty-one is genuinely mine. Zero forks. I've had this account since August 2017, which is longer than most of my actual projects.

The most recent activity on any of them is May 2026, roughly five months ago. Seven projects were created in 2026 and not one has a commit beyond the template README GitLab hands you at creation: verticalcore, vypkg, generatecbindings-dotnet, bastion-ssh, system-setup, yarp-composite, zarlo-openapi-client.

Seven scaffolds, zero shipped. That's the actual failure mode of a hosted platform for me, and it's not that it was taken away or that it was bad. It's that starting a repository on GitLab is free, fast and completely frictionless, so I started seven of them and finished none.

The box

git.punksky.com is gitolite behind cgit, serving plain HTTP over dumb protocol. Three namespaces, a hundred and thirty repositories:

Namespace Repos What's in it
zarlo/ 50 Personal archive. Most of it is ten years old.
punksky/ 30 Current work.
mirror/ 50 dotnet/* and proxmox/*.

Everything I've shipped in the last four months is in punksky/: auth-proxy at four weeks, utilitybelt at four weeks, utilitybelt-net at five, pksky-dotnet-base at eight, punkproxy at two months. Plus my own branches on Cosmos, the newest three days old.

The zarlo/ namespace is the same decade of work GitHub has, and it's the useful measurement: the age histogram runs 2 months, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10, 11. A median somewhere around eight years. That's what a career's worth of infrastructure code actually looks like when you keep it. Most of it was finished on the day I wrote it and hasn't needed touching since.

The namespace split is the part I'd tell anyone to copy. zarlo/ is mine, punksky/ is the company's, mirror/ is other people's. Three namespaces on one machine costs nothing gitolite doesn't charge by namespace, it's just a directory and it means vendored code can never be confused with code I wrote. GitHub has no way to express that. You get a flat list of 95 repositories and a .git suffix if you're lucky.

The mirror/ namespace is mirrored by forge-mirror on a four-hour cadence. mirror/dotnet/runtime and mirror/dotnet/dotnet were four hours behind when I checked. roslyn fourteen, aspnetcore sixteen, msbuild twenty-two, efcore five days.

How the fan-out works

Because the box is the primary and not the mirror, it pushes. punksky/forge-mirror is a Python CLI that does that: pip install ., then forge-mirror.

The backend interface is two methods:

class BackendInterface(ABC):
    @abstractmethod
    def create_repo(self, path: str) -> bool: ...
    @abstractmethod
    def remote_url(self, path: str) -> str: ...

With GitHubBackend, GitLabBackend and a plain GitBackend, and a config file at /home/git/mirror.json that decides which is which:

{
  "github-mirror": {
    "host": "github.com",
    "auto_create": "yes",
    "default_namespace": "my-org",
    "username": "mirror-bot",
    "password": "ghp_..."
  }
}

"auto_create": "yes" creates the repository on the far side and then pushes to it. default_namespace is a GitHub owner or a GitLab group path. The other values are push for push-only and no for best-effort against something that already exists.

The part I like most is that mirror policy lives in gitolite.conf, next to the repo:

repo foo
    option smartmirror.pull       = https:// upstream /foo.git
    option smartmirror.pull.tags  = 1
    option smartmirror.remotes    = github-mirror, gitlab-mirror

Read back with gitolite git-config <repo> <key>. Most people keep a separate inventory file, and then the inventory and the gitolite config drift apart and you spend an afternoon wondering which one is lying. This way the policy is a property of the repository definition and travels with it.

The scheduling is the other thing I'd keep. Each repository's name is SHA-256 hashed to a stable second offset within a 3,600-second hour, and it's due when now mod 3600 falls within WINDOW_SECONDS/2 of that offset. Pulls run two at a time, and only one at a time against any single upstream host, with a deliberate exception that relaxes the per-host cap to two when every repository in the batch comes from the same host. One host with a hundred mirrors shouldn't take a hundred times as long as a host with one.

The mirror you don't push

forge-mirror is also what pulls the mirror/ namespace in. So the obvious next move is to add a github-mirror backend and push all fifty of those repositories back out to GitHub.

Don't.

Every repository in mirror/ is already on GitHub or GitLab. Pushing it back would copy the same bytes from GitHub to the box and then straight back out to GitHub. That doesn't add redundancy. It adds a hop, and it puts your builds back on the centralised platform you were mirroring in order to be independent of.

Those mirrors exist for exactly the opposite reason. They're there so that building against dotnet/runtime doesn't care whether GitHub is up, isn't rate-limiting me, or hasn't been acquired by someone who decides to delete a repository. The .NET stack and Proxmox are dependencies, not my code. Copying them to a third host is an availability strategy; copying them back to the place they came from isn't.

So the rule that falls out of all this: a pull-mirror of code that already lives on a public forge stays local, and smartmirror.remotes is only for the namespaces that are actually mine. zarlo/ and punksky/ fan out. mirror/ never does.

The honest counterargument is that pushing local mirrors back out does buy you something availability, and discoverability if the original ever disappears. But that's a backup problem, and the answer to a backup problem is backups, not donating a third copy of someone else's repository to the platform you're trying to depend on less of.

One primary, two replicas

The part I got wrong for a long time was thinking this was three forges to juggle. It isn't.

GitHub and GitLab aren't independent archives of my work. They're downstream copies of a primary that lives on a box I control, created automatically and pushed to on a schedule. When something's missing from either of them, the box has it. When the box is down, that's a problem, but it's a problem I own and can fix, rather than one where an account gets locked and a decade of branches goes with it.

cgit being read-only costs nothing, because writes never travel that way. It's dumb HTTP, which means the box is cheap, boring and has no database to lose.

Two replications of your own code is a reasonable amount. The discipline is in being clear about which copy is the source and which are just copies and in not creating the third copy of somebody else's code that you never needed in the first place.