I built a git host to see if I can

Needed to publish a couple of npm packages in the process, but turns out I can.

I’ve been reading a lot about GitHub downtime lately. One stat stuck with me: GitHub, across all its features, is up about 90% of the time. Read that again. That means 1 out of every 10 times you go to use some part of GitHub, something is down. I know that feeling from the inside — the push that hangs, the Actions run that never starts, the “please try again” at the worst possible moment. (If you want to go down that rabbit hole yourself, here’s the tracker.)

That sounds bad. So my brain did the thing it always does with a mildly interesting stat: what would it even take to make a git hosting site? Not “I’m going to build a better GitHub” — I had no illusions about that. Just: could I get a working origin server, one you can actually push to and pull from, running somewhere it wouldn’t go down because I forgot to restart a process at 2am.

What even is git

Before writing any code I had to actually answer that, because “build a git host” is not a real spec. So I walked backwards from the only things I actually knew, the ones that live in muscle memory:

Okay. If I could support the smart HTTP protocol well enough to act as an origin, that’s good enough to call it git hosting. I didn’t need to reimplement git — I needed to speak its language over HTTP.

I also wanted to dodge the obvious irony of building a downtime-motivated project on top of an always-on server I’d have to babysit myself. So: edge/serverless from day one.

Picking a stack

I went with TanStack Start — I like SSR, I like React, I like Vite, and it let me hook directly into the server to run my own git protocol handling instead of fighting a framework that assumes you’re just serving JSON to a SPA.

Once I sat with it, the project broke down into four pieces:

PieceJob
Frontend + APIthe actual app — repos, PRs, issues
Git smart-HTTP layerspeaks the protocol a real git client expects
The edgewhere that protocol handling actually runs
Object storagean S3-shaped place to put the git objects themselves

Before writing any of this myself, I went looking for prior art and found isomorphic-git — a pure JS implementation of git for Node and the browser. Close enough to build on. Over one weekend I hacked together a proof of concept that just wrote to a directory on the server’s own disk. It worked. Clone, push, pull, all fine. For about a day, I let myself believe the hard part was done.

Then I tried to move it to S3, and immediately hit the wall: isomorphic-git was never designed for the edge, and it was definitely never designed for S3. It wants a filesystem — reads, writes, stats, directory listings — and object storage doesn’t give you any of that for free. So I ended up writing an adapter that made an S3/R2 bucket look like a filesystem to isomorphic-git, plus a layer on top for the edge-specific gaps, like merging two branches without a real worktree, since there’s no durable disk to check anything out onto.

Then I loaded a repo

It broke. Everything. I’m fairly sure I saw every single error TanStack Start is capable of throwing that day, a few of them for the first time in my life.

A few days of fixing those one by one got me to a page that loaded end to end — tree view, commits, the works. It just took about 3 minutes.

Nobody is waiting three minutes to see a file tree.

I let that sit for a while, mostly because “working but slow” is an easy trap to stay in — it works, technically, and there’s always something else broken to chase. Later, just curious where all that time was actually going, I started timing things properly: every server function, every call out to R2, instead of guessing.

The first number I found was almost funny: adding basic caching around “does this directory exist, does this ref exist” dropped a warm blob read from 2.2 seconds to 45 milliseconds. Great, I thought, that’s most of it. Except a single-file commit was still taking 3.2 to 4.5 seconds, and digging into why turned up something dumber than I expected — one of my storage keys had a double slash in it that could never match anything real:

- repos/alice/blog//git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
+ repos/alice/blog/git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391

That extra slash meant the root of the git directory — the thing isomorphic-git restats before nearly every read and write — never matched a single cache key. It had been silently exempt from all the new caching since the day I added it. One character, months of misses.

Fixing that, along with four other bugs in the same family, brought that commit down to 1.8-2.2 seconds and nearly halved branch listing.

Next was the commit history walk behind the file tree. It was eating 36 of the 39 seconds a cold page load took, and once I actually looked, the reason was almost embarrassing: it was fetching one commit’s data at a time, waiting for each to finish before starting the next, even though most of that work didn’t depend on the step before it.

Then I found the one that actually made me laugh out loud, alone, at my desk. Every push was supposed to clean up old, now-redundant pack files in R2, but the cleanup step had a safety check that, once a repo’s history had any overlap between packs — which happens almost immediately — refused to ever run again. Every repo I’d been testing with had been quietly getting slower, forever, with no fix in sight until someone happened to notice. That someone was me, months later, wondering why my own test repo felt sluggish.

Fixing that took an incremental push from 3.4-3.8 seconds down to 1.5-1.8. Last was something smaller: isomorphic-git firing off several concurrent lookups for the same thing on a cold cache, so four callers asking the same question at once turned into four real round trips instead of one. None of these were exotic — they were just bugs, and I only found them once I actually started measuring instead of guessing. Here’s the whole chase, side by side:

WhatBeforeAfter
Warm blob read2.2s45ms
Single-file commit3.2-4.5s1.8-2.2s
Incremental push3.4-3.8s1.5-1.8s
Cold page load39s~3s

Then I got curious about the compute bill

Even after the R2 side got fast, my deployment dashboard still showed more CPU time than seemed reasonable for a project nobody but me was really using. That turned into its own rabbit hole, separate from R2 entirely — poking at which work actually belonged on the server versus the client:

None of it was one clean fix. It was a lot of small, individually-obvious-in-hindsight changes, each one found by poking at a request and asking, honestly, does this need to happen at all?

You can see more about the architecture here : https://www.nandan.fyi/project/pushstack

Where it landed

By the time the storage adapter and the edge-safe git operations had been through enough real clones, pushes, and merges that I actually trusted them, leaving that logic buried in a route handler started to feel like a waste — like keeping the most interesting part of a side project locked away where only I’d ever see it. So I pulled both pieces out and published them as their own packages:

pushstack now just imports both instead of carrying that logic inline. They don’t need each other, either — both just agree on isomorphic-git’s plain { fs, gitdir, cache } shape, no shared internals or version coupling, so either one is usable entirely on its own.

Which means every one of those “why is this slow” bugs I chased down for pushstack’s own load times is now fixed somewhere anyone else running isomorphic-git against object storage — S3, R2, whatever — might eventually hit too, instead of staying a one-off fix nobody else will ever see.

Pushstack is not faster or more reliable than GitHub, and honestly that was never really the point.

The point was to see if git hosting on edge is even possible. Turns out it is — just, apparently, one bug at a time.