Git Hosting

Use any writable unfs store as a Git remote.

The unfs/git entry point speaks Git's smart HTTP and stdio protocols. That means you can use familiar commands such as git clone, git fetch, and git push without creating a bare Git repository on disk.

The useful difference is what happens after a push: the checked-in files remain ordinary unfs files, so your application can read them with createFs.

Note

Import this API from "unfs/git", not "unfs". It has no runtime dependencies at all, and no peer dependencies. The server below uses h3 only because it has to use something; any runtime that speaks Request and Response works the same way.

#How it works

Git and unfs both store immutable, content-addressed trees, so they fit together naturally:

  • A unfs file becomes a Git blob with the same contents.
  • A unfs directory becomes a Git tree.
  • Commits and annotated tags are stored exactly as Git sent them. This preserves IDs, authors, timestamps, messages, signatures, and history.
  • Branches, tags, and HEAD are stored as namespaced unfs refs.

Files do not need to be copied into a separate Git object store. A repository pushed to unfs is still a filesystem that your application can browse and read.

Git refs and filesystem refs are separate, however. For example, the filesystem ref "head" is not the same thing as the Git branch "refs/heads/main":

  • Editing files through createFs() does not automatically create a Git commit.
  • Moving a Git branch does not automatically move the filesystem's "head" ref.

The examples below show how to connect the two when you need to.

#Create an empty Git remote

You need three things:

A writable unfs store.
A default branch for the new repository.
An HTTP handler that Git can reach.

gitHttpHandler is that handler: give it a store, get back a (request: Request) => Promise<Response>. Here is a complete server using the built-in on-disk store and h3:

server.ts
import { H3 } from "h3";
import { serve } from "h3/node";
import { initStore } from "unfs";
import { fsStore } from "unfs/fs";
import { gitHttpHandler, writeGitSymref } from "unfs/git";

const store = await initStore(fsStore(".data/unfs"));

// Create an empty repository called "demo".
// Its default branch will be main.
await writeGitSymref(store, "demo", "refs/heads/main");

const handleGit = gitHttpHandler({
  store,
  receivePackOptions: {
    denyNonFastForwards: true,
  },
});

const app = new H3().all("/**", (event) => handleGit(event.req));

serve(app, { hostname: "127.0.0.1", port: 3000 });

Install h3 and start the server:

pnpm add h3
node server.ts

The repository name becomes part of its URL. The repository named demo is now available at http://127.0.0.1:3000/demo:

git clone http://127.0.0.1:3000/demo
cd demo

echo "hello from git" > README.md
git add README.md
git commit -m "Initial commit"
git push -u origin main

Repository names can contain /. For example, a repository named team/docs is served at http://127.0.0.1:3000/team/docs.

The handler answers every smart HTTP route for cloning, fetching, and pushing. It also:

  • supports Git protocol v0 and v2;
  • accepts gzipped request bodies; and
  • streams packfiles instead of building the whole response in memory.

#Mount it under a base path

Git routes are catch-all routes, because repository names can contain multiple path segments. The example above therefore serves Git and nothing else. If your application has other endpoints too, give the handler a base path of its own. h3 mounts any fetch-shaped handler and strips the base before calling it, so a repository under /git is named by the rest of the path:

const app = new H3().get("/", () => "hello").mount("/git", gitHttpHandler({ store }));

// git clone http://127.0.0.1:3000/git/demo

The handler never looks at the base it is mounted under. It matches on the end of the path (/info/refs, /git-upload-pack, /git-receive-pack) and treats everything before that as the repository name.

Note

Two h3 details worth knowing:

  • Pass event.req, not event. An h3 event carries a URL, a method, and headers, so all("/**", handleGit) looks like it works — the advertisement even answers — but an event has no body, so every POST reads an empty request and fetches and pushes quietly do nothing.
  • Use all("/**", …) for the root of an app, as in the first example. mount("/", …) is a sub-app prefix, not a catch-all route, and matches / only.

#Use it with anything else

gitHttpHandler returns a plain function from Request to Response, which is what most other servers want too:

// Deno, Bun, srvx, or a Nitro route
export default { fetch: gitHttpHandler({ store }) };
// Node, through any Request/Response adapter — for example srvx
import { serve } from "srvx";

serve({ fetch: gitHttpHandler({ store }), port: 3000 });

#Publish files that are already in unfs

If you already have a unfs tree, you can publish it as a Git repository. The process is:

Turn the unfs directory into a Git tree.
Create a Git commit for that tree.
Point a Git branch at the commit.
import { createFs, initStore, resolvePath } from "unfs";
import { fsStore } from "unfs/fs";
import { createGitRef, encodeCommit, indexGitTree, putGitCommit, writeGitSymref } from "unfs/git";

const store = await initStore(fsStore(".data/unfs"));
const fs = createFs(store);

await fs.mkdir("/src", { recursive: true });
await fs.writeFile("/README.md", "# My project\n");
await fs.writeFile("/src/index.ts", 'console.log("hello")\n');

// Find the root directory of the current filesystem snapshot.
const head = await fs.head();
if (!head) throw new Error("the filesystem has no root");

const root = await resolvePath(store, head, "");
if (root.t !== "d") throw new Error("the filesystem root is not a directory");

// Make the files and directories available as Git objects.
const tree = await indexGitTree(store, root.r);

const timestamp = Math.floor(Date.now() / 1000);
const identity = {
  name: "Example Publisher",
  email: "publisher@example.com",
  timestamp,
  timezone: "+0000",
};

// Create the first commit.
const commit = await putGitCommit(
  store,
  encodeCommit({
    tree: tree.tree,
    parents: [],
    author: identity,
    committer: identity,
    message: "Publish the initial tree\n",
  }),
);

// Create the main branch and make it the repository's default branch.
const created = await createGitRef(store, "demo", "refs/heads/main", commit.oid);
if (!created) throw new Error("refs/heads/main already exists");

await writeGitSymref(store, "demo", "refs/heads/main");

Serve the same store with gitHttpHandler. The repository can then be cloned normally.

For later commits, include the previous commit ID in parents. Move the branch with updateGitRef, passing its old ID so concurrent updates cannot silently overwrite one another.

Tip

If the repository started in Git, the easiest and safest option is to push it to an empty unfs/git remote. A push preserves commits and annotated tags byte for byte, including merge history, timestamps, and signatures.

#Read a pushed branch with createFs

After a push, a Git branch points to a commit, and that commit points to a tree. To browse that tree through the filesystem API:

Read the Git branch.
Find the unfs directory behind its tree.
Give that directory a filesystem ref.
Open it with createFs.
import { createFs, setRef } from "unfs";
import { decodeCommit, getGitTreeRoot, readGitRef } from "unfs/git";

const main = await readGitRef(store, "demo", "refs/heads/main");
if (!main || main.type !== "commit") {
  throw new Error("demo has no main commit");
}

const commit = decodeCommit(main.body);
const root = await getGitTreeRoot(store, commit.tree);
if (!root) throw new Error("the commit tree is not available in unfs");

await setRef(store, "checkout:demo:main", root);
const checkout = createFs(store, { ref: "checkout:demo:main" });

console.log(await checkout.readFile("/README.md", "utf8"));
console.log(await checkout.readdir("/src"));

"checkout:demo:main" is a snapshot. If someone pushes another commit, the Git branch moves but this filesystem ref does not. Resolve the branch again when you want the latest version.

The reverse is also true: editing checkout changes its filesystem tree, but it does not create a Git commit automatically.

#Control fetches and pushes

gitHttpHandler accepts GitHttpOptions:

  • store — the unfs store containing the repositories.
  • advertiseOptions — changes the capabilities announced to Git clients.
  • uploadPackOptions — controls protocol v0 fetches.
  • serveV2Options — controls protocol v2 fetches.
  • receivePackOptions — controls pushes, including ingestion limits and whether deletes or non-fast-forward updates are allowed.

Before sending a fetch, unfs finds all objects the client needs. A default limit of five million objects protects the process from using unbounded memory. You can set tighter object, byte, and time limits for your deployment:

const limits = {
  maxObjects: 500_000,
  maxBytes: 2 * 1024 * 1024 * 1024,
  maxMs: 30_000,
};

const handleGit = gitHttpHandler({
  store,
  uploadPackOptions: { closureLimits: limits },
  serveV2Options: { closureLimits: limits },
  receivePackOptions: {
    denyNonFastForwards: true,
    denyDeletes: true,
  },
});

Set limits for both uploadPackOptions and serveV2Options so they apply to both supported protocol versions.

#Delta compression

Fetch packs are delta compressed by default. A tree or a file is paired with the same path in the parent commit and shipped as a script that rebuilds it from that version, which is what makes a clone of a long history affordable — on this project's own repository it cuts the clone from 8.8 MB to 2.9 MB.

Large files are handled without reading either version: two versions of a file already share the content-addressed chunks unfs stores them in, so the shared runs are known from metadata and only changed chunks are read.

Clients that offer ofs-delta or thin-pack get those representations; clients that offer neither still get a valid pack. Nothing needs configuring, but the CPU cost can be traded back for bytes:

const handleGit = gitHttpHandler({
  store,
  // Ship full objects instead. Larger packs, less work per fetch.
  uploadPackOptions: { deltas: false },
  serveV2Options: { deltas: false },
});

deltas also accepts an object to tune base selection — maxWholeBodyBytes (the size above which a file uses its chunk lists instead of its bytes), maxReadBytes (the total body bytes one pack may read to build deltas), and maxDepth.

#Serve Git over SSH or ext::

Smart HTTP is not the only option. unfs/git can also run the Git protocol over stdin and stdout:

  • serveGitStdio(store, repo) handles clone and fetch. It selects protocol v0 or v2 from GIT_PROTOCOL.
  • serveReceivePackStdio(store, repo) handles pushes.
  • serviceFromName(name) helps a wrapper choose between git-upload-pack and git-receive-pack.

Use these functions to build an SSH forced command or a helper for Git's ext:: transport.

unfs does not provide a ready-made CLI because the host application needs to decide how stores are opened, how repositories are named, how users are authenticated, and how processes are managed.

#Choose a compatible store

A read-only Git remote can work with a fairly simple store. A writable remote needs a few additional capabilities:

  • keys lists repositories and refs. It is also used to reject conflicting branch names such as refs/heads/topic and refs/heads/topic/part.
  • setIfMatch safely creates and updates branches and tags without losing concurrent changes.
  • deleteIfMatch safely deletes refs. If it is missing, ref deletion is not advertised and delete requests are rejected.

The built-in on-disk and SQLite stores support these operations. Before using another adapter for writable Git hosting, check its integration page for the capabilities it provides.

#Storage and reclaiming space

A repository hosted in unfs is stored as its working tree, not as a packed object database. That is the point of the design — the files stay readable at their paths — and it is also what its size reflects.

  • The content is stored uncompressed. Git deflates every object; unfs stores blocks raw. For the same two commits, unfs holds roughly 3–5× the bytes a bare Git repository does. In exchange, nothing has to be inflated to read a file.
  • The index adds a few percent. Git object IDs, tree bindings and tree derivations are unfs refs. They are small — a few percent of the stored bytes — and they add no blocks.
  • On-disk size depends heavily on the store you choose. The built-in on-disk store writes one file per key, so every small ref occupies a full filesystem block. On a repository with many small objects this can be several times the data it holds. The SQLite and LMDB stores keep everything in a single file and do not pay that rounding. If you are hosting many repositories, prefer one of those.

Caution

Successfully indexed Git objects are garbage-collection roots, so gc() will not reclaim them. Running gc() after a push frees nothing and walks the whole store, and gc() additionally requires exclusive write access to the entire store, which a request handler does not have. Do not sweep on the push path. Plan storage on the basis that a hosted repository grows with everything it has ever accepted.

#Security

Caution

unfs/git does not include authentication or authorization. A writable handler allows pushes to every repository visible in its store.

Authenticate users and check repository permissions before requests reach the Git handler. Use prefixedStore when different tenants should have separate keyspaces.

#Current limitations

unfs/git intentionally supports a smaller set of features than a full Git hosting service:

  • Clone, fetch, and push work over smart HTTP and stdio.
  • Fetch supports Git protocol v0 and v2.
  • Shallow and partial clones are not supported, so --depth and --filter do not work.
  • Delta compression pairs an object only with the same path in a parent commit. Git also searches a window of similar objects, so a renamed or copied file is packed whole here where Git would delta it. Expect packs roughly 10% larger than Git's own.
  • Fetch does not report progress yet, so a large clone prints nothing while it waits.
  • Git submodules are rejected.
  • Empty directories disappear because Git has no way to represent them.
  • Commit and annotated-tag bodies must be valid UTF-8 when decoded.
  • Git object IDs use SHA-1. Hashing and pack compression require Node-compatible crypto and zlib builtins at runtime.
  • Successfully indexed Git objects remain garbage-collection roots, so there is no way to reclaim superseded objects yet. Plan how you will reclaim storage before running a long-lived public host; see Storage and reclaiming space.

Within those limits, unfs/git gives you a real Git remote while keeping the checked-in content available as a normal unfs filesystem.

unfs  A filesystem you can put in any key–value store.