usecontextlayer/pggit

Generic git remote backed by Postgres: a Hono (TypeScript) server that speaks the git wire protocol (smart-HTTP, protocol v2 fetch / v0 push) and stores objects and refs in Postgres.

TypeScript

0

178 commits

updated Aug 30, 2026

See the code

See what people are saying (1)

SourceMessageScoreDate

You can run Git on object storage if you re-make packfiles

You can also run git on postgres - https://github.com/usecontextlayer/pggit

0

Sep 19, 2026

README

pggit

A battle-tested git server that runs on Postgres.

License: MIT

# Point it at a Postgres database and start it. No bare repo on disk, no git binary.
PGGIT_DATABASE_URL=postgres://localhost/pggit pnpm run dev
# [pggit] listening on http://localhost:8080

# Push with stock git. The repo is created on first push, HEAD -> refs/heads/main.
git push http://localhost:8080/myrepo main

# Clone it back. Every object came out of Postgres. (git >= 2.26)
git clone http://localhost:8080/myrepo
-- A branch's working tree is plain SQL — no clone, no checkout, no git.
select f.path, f.mode, o.content
from repo_file f
join repos r      on r.id = f.repo_id
join git_object o on o.repo_id = f.repo_id and o.oid = f.blob_oid
where r.name = 'myrepo' and f.ref_name = 'refs/heads/main'
order by f.path;
  • It's just Postgres — every git object and ref is a row in your database, sitting next to the rest of your data. No bare repos on disk, no git host to run.
  • Compatible with any modern git client, nothing special neededclone, fetch, and push work over standard smart-HTTP with the git you already have (>= 2.26, the default since 2020).
  • Queryable — an optional per-branch index maps each branch tip's files to their blobs, so a SELECT returns a repo's working tree with no clone and no checkout.
  • Thoroughly tested — every operation round-trips against the canonical git binary (clone or push, then fsck --full), plus property-based differential tests over thousands of random commit graphs.
  • Pure TypeScript — a git smart-HTTP server built from scratch; no native addons and no git binary on the request path.
  • Embed into your app — it's a Hono sub-app: host.route("/git", createGitApp(deps)) runs the git server in-process, inside your own service.

Why?

A git server normally needs a filesystem: bare repos on disk, backed up and replicated out of band, reached through a git process. pggit drops that. Objects and refs live in Postgres, so the database you already run, back up, and replicate holds your git data too — right next to everything else. Point any git remote at it; it doesn't care what you store: generated files, per-user workspaces, agent output, application content.

Installation

npm install @usecontextlayer/pggit

Or build from source to hack on it (Node >= 20, pnpm, and a Postgres you can reach):

git clone https://github.com/usecontextlayer/pggit
cd pggit
pnpm install
pnpm run build

Create the schema. Migrations use DATABASE_URL (note: the server uses PGGIT_DATABASE_URL):

DATABASE_URL=postgres://localhost/pggit pnpm run db.manage latest

Usage

As a standalone server

PGGIT_DATABASE_URL=postgres://localhost/pggit pnpm run dev
# [pggit] listening on http://localhost:8080

Then treat it as an ordinary remote:

git push  http://localhost:8080/myrepo main                  # first push creates the repo
git clone http://localhost:8080/myrepo                       # git >= 2.26 (negotiates v2)
git clone --filter=blob:none http://localhost:8080/myrepo    # blobless / partial clone

The standalone server wires the queryable view on by default, so the repo_file SQL above works against any repo you push.

As a Hono sub-app

createGitApp(deps) returns a Hono app you mount into your own server, keeping one Postgres connection for the whole host:

import { createGitApp } from "@usecontextlayer/pggit"

// `deps` supplies the Postgres-backed object and ref stores, plus an optional
// snapshot store that maintains the queryable repo_file index. Omit `snapshots`
// and pggit is a plain git remote.
host.route("/git", createGitApp({ objects, refs, snapshots }))

The app exposes the smart-HTTP endpoints under the mount point: GET /:repo/info/refs, POST /:repo/git-upload-pack (fetch), POST /:repo/git-receive-pack (push), and GET /health.

How It Works

git's core data is immutable, content-addressed objects, so pggit stores them that way rather than as packfiles:

  • git_object holds one row per object — the raw inflated body (no loose <type> <size>\0 header, no zlib), LZ4-compressed Postgres-side, keyed (repo_id, oid) and hash-partitioned by repo. Append-only on the push path; unreachable rows are later reclaimed by the background GC drain (below).
  • git_ref is git's only mutable surface. Each row is either a direct ref or a symref; ref updates are compare-and-swap against the advertised old OID.
  • git_commit and git_tag are derived rows carrying the graph's queryable shape — a commit's root tree, ORDERED parents, committer time, and generation number; a tag's target and type — written transactionally at ingest and backfilled by migration. Reachability walks read these rows plus tree content directly (batched primary-key point-reads), so fetch negotiation and the push connectivity check never re-parse commit bodies and never depend on a statistics-sensitive recursive join.
  • repo_file is the optional projection: on each push it rebuilds a branch tip's path -> (mode, blob_oid) index. It stores no duplicate bytes; content is read by joining git_object.

Pushes ingest via binary COPY (no per-row bind-parameter ceiling, so a single push can carry huge blobs and tens of thousands of files). Thin-pack delta bases are resolved against objects already in the store. Fetches serve undeltified packs built straight from the closure. OIDs are SHA-1 throughout; correctness is pinned by a suite that round-trips every operation against the real git binary and diffs generated commit graphs with fast-check.

Because objects are append-only, cleanup is deletion, not rewriting. A background GC drain keeps storage bounded and the derived pack-encoding tier covered: it selects any repo owing GC or repack work, reclaims objects unreachable from every ref and older than a grace window, then repacks the survivors when that phase is enabled. GC uses the same reachability engine as the serve path, so a reachable object is never deleted. The standalone server runs the drain by default; a mounted host opts in with the exported createGcDrain (DSN in — it brings its own dedicated, correctly-sized pool), while createGcScheduler remains the bring-your-own-pool composition for callers that already hold a connection and take responsibility for its isolation and sizing.

Configuration

VariableUsed byDefaultNotes
PGGIT_DATABASE_URLthe server (pnpm run dev, startServer)Required to serve; throws on boot if unset.
PGGIT_PORTthe server8080Listen port.
PGGIT_GC_ENABLEDthe servertrueRuns the background GC-then-repack drain.
PGGIT_GC_REPACK_ENABLEDthe servertrueRuns the drain's repack phase; disabling it leaves a GC-only drain and enabling it later backfills coverage.
PGGIT_GC_GRACE_SECONDSthe server60Reclaim only objects unreachable for longer than this.
PGGIT_GC_INTERVAL_MSthe server30000How often the drain polls for repos to sweep.
PGGIT_GC_CONCURRENCYthe server4Max repos drained concurrently; also bounds concurrent repack memory.
DATABASE_URLthe migration CLI (pnpm run db.manage)Required for latest/up/down/reset/drop.

Scope

pggit is deliberately narrow:

  • No authentication. It serves every request; put it behind your own auth/network boundary.
  • SHA-1 only. A SHA-256 client is rejected at the wire boundary.
  • Fetch is protocol v2 only, push is v0. A v0/v1 fetch client fails loudly rather than silently cloning nothing. Shallow clones are rejected; blob:none is the only partial-clone filter that's honored — other filters are accepted but ignored, so you get a full clone.

MIT License

Contributors

alizain

178 commits

usecontextlayer/pggit

Generic git remote backed by Postgres: a Hono (TypeScript) server that speaks the git wire protocol (smart-HTTP, protocol v2 fetch / v0 push) and stores objects and refs in Postgres.

TypeScript

0

178 commits

updated Aug 30, 2026

See the code

See what people are saying (1)

SourceMessageScoreDate

You can run Git on object storage if you re-make packfiles

You can also run git on postgres - https://github.com/usecontextlayer/pggit

0

Sep 19, 2026

README

pggit

A battle-tested git server that runs on Postgres.

License: MIT

# Point it at a Postgres database and start it. No bare repo on disk, no git binary.
PGGIT_DATABASE_URL=postgres://localhost/pggit pnpm run dev
# [pggit] listening on http://localhost:8080

# Push with stock git. The repo is created on first push, HEAD -> refs/heads/main.
git push http://localhost:8080/myrepo main

# Clone it back. Every object came out of Postgres. (git >= 2.26)
git clone http://localhost:8080/myrepo
-- A branch's working tree is plain SQL — no clone, no checkout, no git.
select f.path, f.mode, o.content
from repo_file f
join repos r      on r.id = f.repo_id
join git_object o on o.repo_id = f.repo_id and o.oid = f.blob_oid
where r.name = 'myrepo' and f.ref_name = 'refs/heads/main'
order by f.path;
  • It's just Postgres — every git object and ref is a row in your database, sitting next to the rest of your data. No bare repos on disk, no git host to run.
  • Compatible with any modern git client, nothing special neededclone, fetch, and push work over standard smart-HTTP with the git you already have (>= 2.26, the default since 2020).
  • Queryable — an optional per-branch index maps each branch tip's files to their blobs, so a SELECT returns a repo's working tree with no clone and no checkout.
  • Thoroughly tested — every operation round-trips against the canonical git binary (clone or push, then fsck --full), plus property-based differential tests over thousands of random commit graphs.
  • Pure TypeScript — a git smart-HTTP server built from scratch; no native addons and no git binary on the request path.
  • Embed into your app — it's a Hono sub-app: host.route("/git", createGitApp(deps)) runs the git server in-process, inside your own service.

Why?

A git server normally needs a filesystem: bare repos on disk, backed up and replicated out of band, reached through a git process. pggit drops that. Objects and refs live in Postgres, so the database you already run, back up, and replicate holds your git data too — right next to everything else. Point any git remote at it; it doesn't care what you store: generated files, per-user workspaces, agent output, application content.

Installation

npm install @usecontextlayer/pggit

Or build from source to hack on it (Node >= 20, pnpm, and a Postgres you can reach):

git clone https://github.com/usecontextlayer/pggit
cd pggit
pnpm install
pnpm run build

Create the schema. Migrations use DATABASE_URL (note: the server uses PGGIT_DATABASE_URL):

DATABASE_URL=postgres://localhost/pggit pnpm run db.manage latest

Usage

As a standalone server

PGGIT_DATABASE_URL=postgres://localhost/pggit pnpm run dev
# [pggit] listening on http://localhost:8080

Then treat it as an ordinary remote:

git push  http://localhost:8080/myrepo main                  # first push creates the repo
git clone http://localhost:8080/myrepo                       # git >= 2.26 (negotiates v2)
git clone --filter=blob:none http://localhost:8080/myrepo    # blobless / partial clone

The standalone server wires the queryable view on by default, so the repo_file SQL above works against any repo you push.

As a Hono sub-app

createGitApp(deps) returns a Hono app you mount into your own server, keeping one Postgres connection for the whole host:

import { createGitApp } from "@usecontextlayer/pggit"

// `deps` supplies the Postgres-backed object and ref stores, plus an optional
// snapshot store that maintains the queryable repo_file index. Omit `snapshots`
// and pggit is a plain git remote.
host.route("/git", createGitApp({ objects, refs, snapshots }))

The app exposes the smart-HTTP endpoints under the mount point: GET /:repo/info/refs, POST /:repo/git-upload-pack (fetch), POST /:repo/git-receive-pack (push), and GET /health.

How It Works

git's core data is immutable, content-addressed objects, so pggit stores them that way rather than as packfiles:

  • git_object holds one row per object — the raw inflated body (no loose <type> <size>\0 header, no zlib), LZ4-compressed Postgres-side, keyed (repo_id, oid) and hash-partitioned by repo. Append-only on the push path; unreachable rows are later reclaimed by the background GC drain (below).
  • git_ref is git's only mutable surface. Each row is either a direct ref or a symref; ref updates are compare-and-swap against the advertised old OID.
  • git_commit and git_tag are derived rows carrying the graph's queryable shape — a commit's root tree, ORDERED parents, committer time, and generation number; a tag's target and type — written transactionally at ingest and backfilled by migration. Reachability walks read these rows plus tree content directly (batched primary-key point-reads), so fetch negotiation and the push connectivity check never re-parse commit bodies and never depend on a statistics-sensitive recursive join.
  • repo_file is the optional projection: on each push it rebuilds a branch tip's path -> (mode, blob_oid) index. It stores no duplicate bytes; content is read by joining git_object.

Pushes ingest via binary COPY (no per-row bind-parameter ceiling, so a single push can carry huge blobs and tens of thousands of files). Thin-pack delta bases are resolved against objects already in the store. Fetches serve undeltified packs built straight from the closure. OIDs are SHA-1 throughout; correctness is pinned by a suite that round-trips every operation against the real git binary and diffs generated commit graphs with fast-check.

Because objects are append-only, cleanup is deletion, not rewriting. A background GC drain keeps storage bounded and the derived pack-encoding tier covered: it selects any repo owing GC or repack work, reclaims objects unreachable from every ref and older than a grace window, then repacks the survivors when that phase is enabled. GC uses the same reachability engine as the serve path, so a reachable object is never deleted. The standalone server runs the drain by default; a mounted host opts in with the exported createGcDrain (DSN in — it brings its own dedicated, correctly-sized pool), while createGcScheduler remains the bring-your-own-pool composition for callers that already hold a connection and take responsibility for its isolation and sizing.

Configuration

VariableUsed byDefaultNotes
PGGIT_DATABASE_URLthe server (pnpm run dev, startServer)Required to serve; throws on boot if unset.
PGGIT_PORTthe server8080Listen port.
PGGIT_GC_ENABLEDthe servertrueRuns the background GC-then-repack drain.
PGGIT_GC_REPACK_ENABLEDthe servertrueRuns the drain's repack phase; disabling it leaves a GC-only drain and enabling it later backfills coverage.
PGGIT_GC_GRACE_SECONDSthe server60Reclaim only objects unreachable for longer than this.
PGGIT_GC_INTERVAL_MSthe server30000How often the drain polls for repos to sweep.
PGGIT_GC_CONCURRENCYthe server4Max repos drained concurrently; also bounds concurrent repack memory.
DATABASE_URLthe migration CLI (pnpm run db.manage)Required for latest/up/down/reset/drop.

Scope

pggit is deliberately narrow:

  • No authentication. It serves every request; put it behind your own auth/network boundary.
  • SHA-1 only. A SHA-256 client is rejected at the wire boundary.
  • Fetch is protocol v2 only, push is v0. A v0/v1 fetch client fails loudly rather than silently cloning nothing. Shallow clones are rejected; blob:none is the only partial-clone filter that's honored — other filters are accepted but ignored, so you get a full clone.

MIT License

Contributors

alizain

178 commits

Languages

TypeScript

99.9%