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
HEADare 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:
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:
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.tsThe 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 mainRepository 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/demoThe 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, notevent. An h3 event carries a URL, a method, and headers, soall("/**", handleGit)looks like it works — the advertisement even answers — but an event has nobody, so everyPOSTreads 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:
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:
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 fromGIT_PROTOCOL.serveReceivePackStdio(store, repo)handles pushes.serviceFromName(name)helps a wrapper choose betweengit-upload-packandgit-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:
keyslists repositories and refs. It is also used to reject conflicting branch names such asrefs/heads/topicandrefs/heads/topic/part.setIfMatchsafely creates and updates branches and tags without losing concurrent changes.deleteIfMatchsafely 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
--depthand--filterdo 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.