The Git Repo Explorer: Building a Repository Viewer in the Browser
15/07/2026
|
40 min read
|
Share

I wanted people to browse my repositories without leaving this website.
That was the whole idea. A project card could explain what I had built, but the moment someone wanted to read the README, inspect a commit, or open the source, the portfolio handed them to GitHub. The interesting part of the project lived somewhere else.
A lot of the inspiration came from the projects on zx2c4.com, which open in cgit. I love how direct that experience is. There is no attempt to turn a repository into a social feed or a project management suite. You open a repository, browse its tree, read its history, inspect a diff, and get on with your life.
I wanted that same directness here. I did not want to copy cgit's interface or port its C code to the browser. I wanted the repositories to feel like a native part of the portfolio instead of a collection of external links.
There was one hard constraint: this site already runs on Cloudflare for almost nothing, and I wanted to keep it that way. Adding a virtual machine, running a container, or introducing another stateful service to monitor would have been a ridiculous price to pay for a read-only portfolio feature.
That constraint changed the question. I was no longer asking, "How do I build a smaller GitHub?" I was asking, "How little repository infrastructure can I get away with?"
The answer turned out to be stranger than I expected. The server does not answer questions such as "which files are in this directory?" or "what changed in this commit?" It serves Git's native files. The browser reconstructs the repository in IndexedDB, a Web Worker interprets it, and Vue renders the result.
The part of cgit I wanted
cgit helped me separate repository browsing from everything that have been grown around it. Its feature list is almost exactly the vocabulary I cared about: logs, diffs, trees, refs, and cloneable URLs. For a personal portfolio, that is already a remarkably complete product.
I did not need pull requests, issues, permissions, merge queues, actions, or collaborative editing. I needed the part of Git that helps someone understand a project:
- Browse repository trees, files, branches, tags, and historical commits.
- Show the commit that last changed each visible file or directory.
- Render Markdown, highlighted source code, images, and unsupported-file states.
- Build split and unified diffs without asking GitHub to calculate them.
- Give every tree, file, history, and commit a URL that can be shared.
- Export a snapshot when someone wants the code outside the viewer.
The result should still feel like this site. Nuxt would own the routes, layouts, and application state. Nuxt UI would provide the repository shell, menus, sidebars, tabs, timelines, and loading states. The repository itself would be the unusual part.
The constraint that shaped the design
Keeping the feature cheap meant keeping the runtime small. More specifically, I wanted to:
- Keep the normal portfolio statically generated.
- Avoid doing heavy Git work on the main browser thread.
- Cache repository data so a second visit is significantly cheaper.
- Support Cloudflare R2 in production and a local repository directory when self-hosting.
- Avoid an always-on Git application server or repository database.
- Keep the server-side read path small, cacheable, and read-only.
There were several reasonable ways to build the feature. They simply put the cost, complexity, and ownership in different places.
| Design | What I would gain | Why I did not choose it |
|---|---|---|
| Link to GitHub | No viewer to build or operate | The repository still leaves the portfolio |
| Read through GitHub's API | Structured commits, trees, and blobs | The runtime would depend on GitHub's API model and limits |
| Generate repository-shaped JSON | Static delivery and predictable rendering | I would create and maintain a second database derived from Git |
| Run cgit or another Git service | A mature server-side repository browser | It needs a Git-aware server process and infrastructure around it |
| Read Git in the browser | A thin backend and a locally cached repository | The visitor pays the first-load bandwidth and computation cost |
The obvious custom design would expose endpoints such as
/api/repos/:name/tree and /api/repos/:name/blob. The server would open a
repository, turn Git objects into JSON, and repeat that interpretation for
every visitor.
That architecture is valid, and for very large repositories it is probably the better trade. It would also turn this portfolio into a Git application server. I wanted to find out how far I could get if the server only delivered the repository and the client understood Git itself.
Then I learned what a Git repository actually is
The first thing I learned was embarrassingly basic: Git is a fucking database.
Before building the viewer, I mostly interacted with Git through commands:
status, add, commit, log, and the occasional desperate reflog. I knew
Git stored history, obviously, but I still pictured a repository as a folder of
files with commits attached to it. That mental model is useful when writing
code and almost useless when building a repository browser.
The working tree is only one view. Underneath it, Git stores an object graph.
A ref such as main points to a commit ID. The commit stores metadata, parent
commit IDs, and a root tree ID. A tree stores names, modes, and IDs for blobs or
other trees, while a blob stores the raw bytes of one file.
refs/heads/main
|
v
commit 8f62... --parent--> commit 51ac...
|
v
tree a91e...
|
+-- app/ --------> tree 0b7c...
| |
| +-- app.vue -> blob 312d...
|
+-- README.md ---> blob f6e4...
Objects are addressed by hashes derived from their contents. Change a file and its blob ID changes; that change produces a new tree ID and, eventually, a new commit. Git can then compress many of those objects into packfiles without changing the logical graph.
That changed the whole design. Git already contained nearly everything the UI needed. I did not need a table of files, a table of directories, a table of commit relationships, and another table of file versions. I needed a way to publish Git's existing object database and a client capable of asking it the right questions.
The cost constraint and the object model suddenly pointed in the same direction. If a bare repository is already a database made from files, object storage can publish those files. If JavaScript can read the database, the browser can derive the application views.
The complete design
Once I stopped treating Git as something that had to live behind a Git server, the architecture became two separate paths. A publishing path turns my GitHub repositories into static, HTTP-readable Git data. A reading path rebuilds that data in the visitor's browser.
Publishing
----------
GitHub webhook
|
v
Nitro webhook route -- verifies HMAC signature
|
v
GitHub Actions workflow
|
+-- git clone --bare / git fetch --all
+-- git update-server-info
|
v
Cloudflare R2: repos/<repository>.git/**
Reading
-------
Nuxt repository route
|
v
/api/git-proxy/<repository>.git/**
|
+-- Cloudflare R2 in production
+-- local repos directory when self-hosted
|
v
HEAD + refs + pack indexes + packfiles
|
v
LightningFS -> IndexedDB
|
v
Web Worker -> isomorphic-git -> commits / trees / blobs / diffs
|
v
Reactive Vue state -> Nuxt UI repository interface
The finished diagram hides how gradually those boundaries appeared. The publishing pipeline landed on July 4, 2026, and the first main-thread explorer followed two days later. Caching, local diffs, incremental pack updates, and better failure states arrived before Git moved into a worker on July 11; the tree-OID shortcuts and larger showcase repositories came later still.
I did not design every box up front. I first validated that raw Git data could travel through static storage, then built the viewer, then discovered which parts of that viewer were too expensive to leave naïve.
When someone opens /repo/portfolio, the Nuxt page starts a
worker, asks the proxy for the repository's discovery files and packs, stores
them beneath a virtual .git directory, and resolves the selected branch. Only
then do commits, folders, files, and diffs become application data.
There is still server-side code in this architecture, but it does not understand Git objects. It understands file paths, storage, HTTP caching, webhook signatures, and little else. The repository model lives in the browser.
That distinction is important. The Git proxy is not a repository API; it is a transport layer for raw Git data.
Turning repositories into static files
The first practical problem was making a repository available without running a smart Git HTTP server. Git already had a beautifully simple publication model for this: the Dumb HTTP protocol.
"Dumb" is not an insult here. It means the HTTP server doesn't negotiate a
custom Git session or generate a pack on demand. It serves an existing bare
repository like an ordinary directory, and the client discovers what to fetch
through files such as info/refs and objects/info/packs.
My browser client does not implement every part of Dumb HTTP. It uses the static-pack subset produced for these repositories: discovery metadata plus every advertised pack index and packfile. It does not chase loose objects, alternates, or individual loose refs on demand.
That static layout maps unusually well to object storage. Cloudflare R2 can store and serve the files without knowing what a branch or commit is. It also fits the reason I started down this path: the repository data stays inside the Cloudflare setup I already use, without adding an always-on machine or an egress bill that grows with every packfile download.
Preparing a bare repository
The synchronization script fetches one page of my public GitHub repositories, ignores forks, and creates a bare clone for each selected repository. If a bare copy already exists, the script updates it instead.
const repoPath = path.join(targetDir, `${repo.name.toLowerCase()}.git`);
if (fs.existsSync(repoPath)) {
execSync('git fetch --all', {
cwd: repoPath,
stdio: 'inherit'
});
} else {
execSync(`git clone --bare ${repo.clone_url} ${repoPath}`, {
stdio: 'inherit'
});
}
execSync('git update-server-info', {
cwd: repoPath,
stdio: 'inherit'
});
A bare clone contains Git's database without a checked-out working tree. That is exactly what this viewer needs because files in Git are already stored as objects; creating a second copy on disk would add space without adding useful information.
The important command is
git update-server-info.
It generates the auxiliary files that a dumb client needs to discover refs and
available packs. Without it, object storage would contain the repository, but a
browser wouldn't know which objects exist or where to find them.
Automating synchronization
Manually rebuilding every repository after each push would produce a convincing demo that went stale immediately. I connected a GitHub App webhook to a Nitro route so a repository event can dispatch the sync workflow.
The route reads the raw request body, calculates an HMAC-SHA-256 signature with
the configured webhook secret, and compares it with the
x-hub-signature-256 header. Private repositories are ignored before the route
dispatches the workflow.
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(payload)
);
const expectedSignature = `sha256=${hashHex}`;
if (signature !== expectedSignature) {
setResponseStatus(event, 401, 'Unauthorized');
return;
}
if (pushPayload.repository.private) {
return { status: 'ignored', reason: 'private repo' };
}
The GitHub Actions workflow runs the same synchronization script and uploads
the resulting bare repository with rclone:
- name: Clone or update repositories
env:
SYNC_REPO: ${{ github.event.inputs.repo }}
run: node ./scripts/getPublicRepos.mjs
- name: Upload to R2
run: |
for repo_dir in repos/*; do
if [ -d "$repo_dir" ]; then
repo_name=$(basename "$repo_dir")
rclone copy "$repo_dir/" \
"r2:${CLOUDFLARE_R2_BUCKET}/repos/${repo_name}" \
--no-traverse
fi
done
The script can update a bare copy when its directory already exists, which is
useful locally or in a persistent runner. The current GitHub Actions job does
not persist repos/, however, so it ordinarily creates a fresh bare clone on
each run. That detail becomes important when I discuss pack updates in the
browser.
The workflow does not deploy a new application build. It can update the Git data behind an existing repository route independently from the portfolio. A brand-new repository still needs a matching project record and a Nuxt rebuild before the catch-all page will expose it.
Publishing is not atomic. R2 is
strongly consistent per object,
but a repository generation contains many objects, and rclone copy writes
them directly into the live prefix. A reader can observe new discovery metadata
before its pack finishes uploading, while overlapping workflow runs can race on
mutable files such as info/refs and objects/info/packs.
Keeping the proxy deliberately boring
The browser cannot access the private R2 binding directly, and local self-hosting needs the same public URL shape. A catch-all Nitro endpoint gives both storage modes one interface:
/api/git-proxy/portfolio.git/HEAD
/api/git-proxy/portfolio.git/info/refs
/api/git-proxy/portfolio.git/objects/info/packs
/api/git-proxy/portfolio.git/objects/pack/pack-<hash>.idx
/api/git-proxy/portfolio.git/objects/pack/pack-<hash>.pack
In production, the route maps that path to repos/<path> in R2. Without an R2
binding, it resolves the same path beneath NUXT_REPOS_DIR, falling back to a
local repos directory.
const bucket = event.context.cloudflare?.env?.R2_BUCKET;
if (!bucket) {
const baseDir = config.reposDir
? path.resolve(config.reposDir)
: path.resolve(process.cwd(), 'repos');
const localPath = path.resolve(baseDir, filePath);
if (!localPath.startsWith(baseDir)) {
throw createError({ statusCode: 403, message: 'Forbidden' });
}
return sendStream(event, fs.createReadStream(localPath));
}
const object = await bucket.get(`repos/${filePath}`);
if (!object) {
throw createError({ statusCode: 404, message: 'Not Found' });
}
return sendStream(event, object.body);
The production R2 branch also applies different cache policies to mutable discovery files and immutable objects. Packfiles, indexes, reverse indexes, and loose objects can be cached for a year. References get a short lifetime with stale revalidation. The local-filesystem branch currently returns before those headers are applied.
if (isPackFile || isLooseObject) {
setResponseHeader(
event,
'cache-control',
'public, max-age=31536000, immutable'
);
} else {
setResponseHeader(
event,
'cache-control',
'public, max-age=60, stale-while-revalidate=86400'
);
}
This works because a Git object's identity comes from its contents. Existing objects don't change when a branch advances; the ref starts pointing at a new commit, and new packfiles appear. Treating the two categories differently lets HTTP caches reuse an exact immutable object or pack URL while the viewer continues to revalidate mutable discovery files.
These are HTTP cache instructions, not a guarantee that R2 binding reads enter Cloudflare's CDN cache. The current route lets browsers and any participating intermediaries reuse responses. Explicit edge caching would require another layer, such as Cloudflare's Cache API.
The cleanest proof that this endpoint serves Git rather than viewer-specific JSON is that native Git can use the same URL:
git clone https://riavzon.com/api/git-proxy/portfolio.git
The browser takes a narrower route through the same published repository. It
downloads the advertised packs itself because its goal is to rebuild a local
.git directory in IndexedDB, not negotiate a new pack with the server.
Reconstructing Git inside the browser
This was the point where the design stopped being a storage experiment and had
to become an actual Git client. The browser could fetch a bare repository, but
isomorphic-git expected a filesystem-shaped repository, and browsers do not
have Node's fs.
I use
LightningFS, which exposes a
familiar filesystem API backed by IndexedDB.
Every repository receives its own persistent filesystem name and internal directory:
const fsName = `portfolio-${repoName}`;
const dir = `/${repoName}`;
const repoUrl = import.meta.client
? `${window.location.origin}/api/git-proxy/${repoName}.git`
: '';
The first visit creates a .git directory in that virtual filesystem. The
client fetches HEAD, configuration, packed refs, ref metadata, and the pack
manifest before downloading each index and pack.
const packsRaw = await fetchGitFile(repoUrl, 'objects/info/packs');
await pfs.writeFile(`${dir}/.git/objects/info/packs`, packsRaw.data);
const packsText = new TextDecoder().decode(packsRaw.data);
for (const line of packsText.split('\n')) {
if (!line.startsWith('P ')) continue;
const packName = line.substring(2).trim();
const idxName = packName.replace('.pack', '.idx');
const idxData = await fetchGitFile(
repoUrl,
`objects/pack/${idxName}`
);
if (!idxData.ok) {
return cleanupAndReturn(idxData.reason);
}
const packData = await fetchGitFile(
repoUrl,
`objects/pack/${packName}`
);
if (!packData.ok) {
return cleanupAndReturn(packData.reason);
}
await pfs.writeFile(
`${dir}/.git/objects/pack/${idxName}`,
idxData.data
);
await pfs.writeFile(
`${dir}/.git/objects/pack/${packName}`,
packData.data
);
}
The .idx file tells Git which object IDs are stored in a pack and where their
compressed data begins. The .pack file contains the objects themselves. Once
both exist in IndexedDB, isomorphic-git can resolve refs and walk the same
commit, tree, and blob graph that native Git reads.
The code writes a .cloned marker only after every required file downloads
successfully. A known fetch failure wipes the partial directory immediately. If
the tab closes or another unexpected interruption occurs, the missing marker
tells the next initialization to wipe the directory before trying again.
Updating an existing browser cache
Getting the first visit to work was only half the feature. Returning visitors
already have the packfiles in IndexedDB, so downloading the whole repository
again would defeat the point. The update path refreshes HEAD, refs, and
objects/info/packs, then downloads only packfiles that do not exist locally.
const packExists = await pfs
.stat(`${dir}/.git/objects/pack/${packName}`)
.catch(() => null);
if (!packExists) {
const idxName = packName.replace('.pack', '.idx');
const idxData = await fetchGitFile(
repoUrl,
`objects/pack/${idxName}`
);
const packData = await fetchGitFile(
repoUrl,
`objects/pack/${packName}`
);
if (idxData.ok) {
await pfs.writeFile(
`${dir}/.git/objects/pack/${idxName}`,
idxData.data
);
}
if (packData.ok) {
await pfs.writeFile(
`${dir}/.git/objects/pack/${packName}`,
packData.data
);
}
downloadedSomething = true;
}
Pack filenames are derived from pack contents. If the server still advertises a name that exists locally, that exact pack does not need to be downloaded again. A fresh clone or repack can produce a new pack name containing many objects the browser already has, so this is pack-level reuse rather than object-level incremental fetching.
When the initial metadata request fails, the worker usually continues with the existing IndexedDB copy. The update is not transactional, though: it writes refs and the pack manifest before all missing packs arrive, and individual pack failures are not rolled back. The local fallback is useful on a simple offline visit, but a connection failure halfway through an update can still leave an unresolvable ref until the next successful synchronization.
Moving Git off the main thread
The first complete version worked, which made it tempting to call it done. It ran isomorphic-git from a composable on the
browser's main thread. As history, local diffs, blob reads, and tree metadata
accumulated, all of that work competed with Vue for the thread responsible for
input, painting, and transitions.
The original query shape made the problem worse. It ran a separate
git.log({ filepath }) lookup for every visible row and loaded an uncapped
repository history. The number of Git walks grew with the number of rows before
the tree could render useful metadata.
Nuxt and cURL came later as deliberate stress tests. They did not cause the worker migration; they showed how much expensive work still remained after moving Git away from the main thread.
Caching removed repeated work, but it could not make the first walk non-blocking. Adding more loading skeletons does not fix a blocked event loop, so I moved the repository engine into a dedicated Web Worker.
The worker owns the virtual filesystem, Git cache, active ref, and most Git object parsing and repository traversal. The Vue application owns reactive presentation state, while later stages still perform diff-row shaping in the client and syntax highlighting through a Nitro route.
export class GitWorkerClass {
fs: InstanceType<typeof LightningFS>;
cache: ReturnType<typeof useGitRepoCache>;
currentBranch: string | undefined;
LOG_DEPTH_CAP = 1000;
constructor(
fsName: string,
dir: string,
repoUrl: string,
initialBranch?: string
) {
this.fs = new LightningFS(fsName);
this.cache = useGitRepoCache(dir, this.fs, this.LOG_DEPTH_CAP);
this.currentBranch = initialBranch;
}
// initRepo(), getFileBlob(), getAllCommits(), getCommitDiff(), ...
}
Comlink.expose(GitWorkerClass);
Raw postMessage calls become tedious when a worker has many operations and
callbacks. Comlink exposes the
worker class through an RPC-like proxy, so the composable can call async
methods while keeping TypeScript's method signatures.
const nativeWorker = new GitWorkerInstance();
const remoteWorker = Comlink.wrap<typeof GitWorkerClass>(nativeWorker);
gitRepoInstance = await new remoteWorker(
fsName,
dir,
repoUrl,
initialBranch
);
const response = await gitRepoInstance.initRepo(
Comlink.proxy((updatedFiles, count, capped) => {
files.value = updatedFiles;
commitCount.value = count;
commitCountCapped.value = capped;
isResolvingCommits.value = false;
})
);
The progress function travels in the opposite direction, from the worker back to Vue. That lets the worker return the initial repository view quickly and publish slower metadata later.
ZIP downloads use one more optimization. The worker creates a Uint8Array and
returns its underlying buffer with Comlink.transfer, transferring ownership
instead of cloning a potentially large byte array between threads.
How the client code is divided
Moving the engine into a worker created a new risk: one enormous composable on
the Vue side and one enormous class on the worker side. The first explorer had
already put the virtual filesystem, Git operations, and Vue state inside one
useGitRepo composable. I kept the new boundary useful by splitting the client
into transport, interpretation, reactive state, derived views, and
presentation.
| Boundary | Responsibility | Main files |
|---|---|---|
| Transport and persistence | Fetch Git files and maintain the IndexedDB-backed .git directory | app/utils/useFs.ts |
| Git engine | Read refs, commits, trees, blobs, and create ZIP archives | app/workers/git.worker.ts |
| Derived Git caches | Cache logs, parsed trees, folder metadata, and commit diffs | app/composables/repo/useGitRepoCache.ts |
| Tree history | Resolve the latest commit touching each visible entry | app/utils/resolveTreeCommitsAsync.ts |
| Vue facade | Translate worker results into reactive application state | app/composables/repo/createRepo.ts, app/composables/repo/useGitRepo.ts |
| File interpretation | Classify blobs, create image URLs, rewrite Markdown assets, and download files | app/composables/repo/useFileContent.ts, app/composables/repo/useDownload.ts, app/composables/repo/useMarkdownImageResolver.ts |
| Diff derivation | Build unified and split rows, expand context, highlight source, and combine syntax tokens with changes | app/composables/repo/useDiffRows.ts, app/composables/repo/useFullFileDiff.ts, app/composables/repo/useSyntaxHighlighting.ts, app/composables/repo/useDiffRowsHighlighted.ts |
| Navigation | Convert repository paths and changed files into sidebar links | app/utils/useTreeLinks.ts, app/utils/useDiffTreeLinks.ts, app/utils/goBack.ts |
| Presentation | Compose the overview, tree, blob, history, and diff interfaces | app/components/repo/, app/layouts/tree.vue, app/layouts/diff.vue |
The composables form the application-facing API. Components request operations
such as getFilesInFolder() or getCommitDiff() and react to refs; they don't
fetch packfiles or parse Git objects themselves. The utilities either handle
transport or perform focused transformations that don't belong in the view
layer.
Making the first render fast
The worker fixed the frozen interface, but it did not make wasteful Git queries
cheap. Initialization still runs git.listFiles() for the entire repository
and transfers that complete path list to Vue. Turning the list into root rows is
much cheaper than resolving the latest commit beside every row, but it is not
free for repositories with enormous trees.
Git stores a commit's root tree, and each tree points to files and more trees. It does not store a direct "last changed by commit X" property on each entry. To calculate that label, the viewer must walk backward through history and find the newest commit where an entry's object ID differs from its parent.
Waiting for that scan before rendering anything made the repository feel slow, even when the tree itself was already available. The worker now returns names and types first, then resolves commit metadata asynchronously.
const items = Array.from(fileMap.values());
void (async () => {
const allCommits = await this.cache.getCachedCommits(
this.currentBranch ?? ''
);
commitCount = allCommits.length;
commitCountCapped = allCommits.length >= this.LOG_DEPTH_CAP;
await resolveTreeCommitsAsync(
items,
allCommits,
this.fs,
this.dir,
this.cache.gitCache,
undefined,
undefined,
this.cache.parsedTreeCache
);
if (onProgress) {
onProgress(items, commitCount, commitCountCapped);
}
})();
return {
ok: true,
data: { files: items, branches, tags, lastCommit }
};
Folders use the same two-stage response. The UI receives the directory entries
immediately with isDone: false; commit labels arrive through another progress
callback with isDone: true.
A folder view narrows the later scan one more time. It first asks Git for the latest commit affecting that folder, finds the commit inside the cached log, and discards every newer candidate before resolving its child rows. That preliminary filepath query can still walk deep history, but it replaces one path query per child with one path query for the folder.
Resolving the last commit for a tree entry
The resolution algorithm walks commits from newest to oldest. For a nested folder, it opens the requested folder tree and the same folder in the first parent. If both tree IDs match, nothing inside that subtree changed, so the algorithm skips the commit without comparing every entry. At the repository root, the current implementation still compares the visible entry IDs for each visited commit.
When the trees differ, it builds maps from entry name to object ID. The first commit where the current and parent IDs differ is the newest commit touching that item. Git's trees form a Merkle-style graph, so a change to a child also changes the IDs of the trees above it.
for (const commit of allCommits) {
if (unresolvedKeys.size === 0) break;
const currentTreeData = await getFolderTree(commit.oid);
const parentOid = commit.commit.parent[0];
const parentTreeData = parentOid
? await getFolderTree(parentOid)
: null;
if (currentTreeData?.oid === parentTreeData?.oid) continue;
const currentMap = new Map(
currentTreeData?.tree.map(entry => [entry.path, entry.oid]) ?? []
);
const parentMap = new Map(
parentTreeData?.tree.map(entry => [entry.path, entry.oid]) ?? []
);
for (const key of unresolvedKeys) {
if (currentMap.get(key) !== parentMap.get(key)) {
const item = itemsByKey.get(key);
if (item) {
item.commit = {
hash: commit.oid,
message: commit.commit.message.trim(),
author: commit.commit.author.name,
email: commit.commit.author.email,
date: new Date(commit.commit.author.timestamp * 1000),
parentHash: commit.commit.parent[0]
};
}
unresolvedKeys.delete(key);
}
}
}
Tree parsing is cached by object ID, and folder lookups are cached per commit during a scan. The loop also yields periodically so the worker can process its event queue during large histories.
The initial scan stops at 1,000 commits. If an entry hasn't changed within that window, the tree shows a View History action instead of pretending the metadata is complete. Opening that path's history runs the targeted, uncapped query.
This is one of the most important compromises in the viewer. Exact metadata for every entry is possible, but making every visitor pay for it up front is not a good trade.
Caching at more than one layer
Moving the work to a worker keeps the interface responsive, but it doesn't make the work free. Without caching, every route change would reopen the same Git objects and every visit would download the same packs.
The viewer uses several caches because each one removes a different kind of repetition:
| Layer | Stores | Avoids |
|---|---|---|
| Browser and intermediary HTTP caches | Immutable packs and Git objects | Repeated network reads when caches honor the headers |
| LightningFS / IndexedDB | The reconstructed .git directory | Re-downloading a repository |
| IndexedDB JSON cache | Commit logs and per-entry commit metadata | Rewalking history after reload |
| Worker memory maps | Parsed trees, folder results, logs, and diffs | Repeating work during one session |
| Vue repository cache | Worker-backed repository instances | Reinitializing between Nuxt routes |
| Highlight caches | Shiki HTML and merged diff tokens | Re-highlighting identical source |
The persistent commit cache includes the branch head object ID. Cached metadata is reused only when the current head matches the stored head:
const cachePath = `${dir}/.commit-cache-${branch}.json`;
const cached = JSON.parse(await pfs.readFile(cachePath, 'utf8'));
if (cached.headOid === headOid) {
return cached;
}
return null;
At the Vue layer, useGitRepo() keeps up to ten repository controllers in a
small global cache. Moving from a tree to a blob, then to history, reuses the
same worker and reactive state instead of creating a second browser clone.
gitRepoCache ??= new MiniCache(10);
let repo = gitRepoCache.get(repoName);
if (!repo) {
repo = createGitRepo(repoName, initialBranch);
gitRepoCache.set(repoName, repo, Infinity);
}
return repo;
Caching also creates failure modes. IndexedDB can be interrupted or corrupted, and old metadata can outlive the objects it describes. During initialization, the worker performs a small log read; if it detects the known broken-object shape, it wipes that repository's virtual directory and retries once from the network.
That recovery is intentionally scoped to one repository. A broken cache for
curl should not delete the cached copy of portfolio.
Mapping Git concepts to Nuxt routes
The viewer still needs ordinary URLs that can be copied, refreshed, and opened directly. One catch-all Nuxt page parses the slug and selects the repository view.
const viewType = computed(() => {
if (slug.value.length === 1) return 'main';
if (slug.value[1] === 'tree') {
if (slug.value.length === 3) return 'main';
return 'tree';
}
if (slug.value[1] === 'commits') return 'commits';
if (slug.value[1] === 'commit' && slug.value[2]) return 'diff';
if (slug.value[1] === 'blob') return 'file';
return 'main';
});
The resulting URL model is close to what Git users already recognize:
/repo/<name>
/repo/<name>/tree/<ref>/<path>
/repo/<name>/blob/<ref>/<path>
/repo/<name>/commits/<ref>/<path?>
/repo/<name>/commit/<hash>
<ref> can be a single-segment branch name, a tag, or a commit hash anywhere
the underlying Git operation supports it. This is what makes Browse
Repository from a history entry useful: the viewer can open the tree exactly
as it existed at that commit. Refs containing /, such as feature/search, do
not round-trip through the current route grammar because the next segment is
interpreted as a file path.
Detailed tree, blob, history, and commit routes are client-only because
IndexedDB, Web Workers, and browser object URLs do not exist during server
rendering. The repository overview can still be prerendered, with its
Git-dependent interface wrapped in <ClientOnly>.
routeRules: {
'/': { prerender: true },
'/repo/*/tree/**': { ssr: false },
'/repo/*/blob/**': { ssr: false },
'/repo/*/commits/**': { ssr: false },
'/repo/*/commit/**': { ssr: false }
}
The page also wraps repository components in <ClientOnly> and supplies
skeletons for the fallback. This keeps the architectural boundary explicit
instead of scattering window and IndexedDB checks through every component.
Reading trees and blobs
Once the worker has a valid repository, most file operations become small
isomorphic-git calls. A directory uses readTree, while a file resolves the
selected ref to a commit and uses readBlob with a path.
const commitOid = await git.resolveRef({
fs: this.fs,
dir: this.dir,
ref
});
const { blob } = await git.readBlob({
fs: this.fs,
dir: this.dir,
oid: commitOid,
filepath,
cache: this.cache.gitCache
});
The worker returns bytes rather than assuming every blob is UTF-8 text. That keeps Git access independent from presentation: the file composable can decode text, preserve image bytes, or stop before an unsuitable blob reaches the code renderer.
Markdown receives special treatment. The viewer can show either rendered MDC or highlighted raw Markdown. Relative image paths are rewritten to the repository owner's jsDelivr URL so README badges, screenshots, and banners still work outside GitHub.
The root repository page uses the same pipeline to find and render README.md
and LICENSE. In other words, the README shown under the tree is read from the
browser's local Git object database, not copied into the portfolio's Nuxt
Content collection.
Unsupported content is still a valid view
The happy path made every blob look like source code. Real repositories quickly broke that illusion: they contain images, archives, generated files, Git LFS pointers, binaries, and text files large enough to punish a browser tab.
Forcing all of them through a text highlighter would produce slow or misleading results. I treat unsupported content as a deliberate presentation state rather than a failed request.
useFileContent() inspects the first 8,000 bytes for a null byte as a practical
binary heuristic. Text that starts with the Git LFS pointer signature also
enters the unsupported path because the repository stores only the pointer, not
the externally managed LFS object.
const isBinary = blob
.slice(0, 8000)
.some(byte => byte === 0);
if (isBinary) {
isBinaryFile.value = true;
fileContent.value = null;
} else {
const text = new TextDecoder().decode(blob);
const isLfsPointer = text.startsWith(
'version https://git-lfs.github.com/spec/v1'
);
isBinaryFile.value = isLfsPointer;
fileContent.value = isLfsPointer ? null : text;
}
The file view then chooses an explicit renderer:
- Known image extensions become object URLs and render as images.
- Regular text enters
BlobViewer, with wrapping and raw/rendered Markdown controls. - Binary files and Git LFS pointers enter
RepoUnsupportedFile. - Files larger than 5 MB skip normal code rendering and expose a download action.
- Direct image diffs render the old and new images instead of manufacturing a textual patch.
<RepoUnsupportedFile
v-else-if="isBinaryFile || (fileStats?.size ?? 0) > 5e6"
:repo-name="repoName"
:file-path="filePath || ''"
:branch="branch"
@download="downloadFile"
/>
Repository-level absence receives the same treatment. When a project has neither a README nor a license, the overview renders a designed empty state instead of an empty card or a parsing error. Loading skeletons and route-level error views cover temporary and unrecoverable failures separately.
These boundaries protect both the reader and the browser. Unsupported means the viewer can identify the object and offer a useful next action without adding expensive highlighting, diff rows, and DOM nodes for a representation it cannot render well.
Commit history without a history database
Commit history also comes directly from Git objects. The worker calls
git.log(), normalizes author and parent data, and sends serializable objects
back to Vue.
Repository and folder tree metadata use the capped cached log, while the dedicated history page asks for the full log. A filepath narrows the Git walk to commits that affect that path.
The UI then performs author and date filtering locally. Pagination only changes which ten already-loaded commits are rendered; it doesn't create another API contract or server query.
This design makes the history page simple, but it also makes the browser pay the cost of a full history when the user explicitly asks for one. That is acceptable for the repositories this portfolio is meant to showcase, but it is another reason this architecture isn't intended for something the size of the Linux kernel.
Building the diff pipeline
The first repository browser still cheated. Its commit page showed the author and message, then offered a View Full Diff on GitHub button. I had rebuilt the door and left one of the most interesting rooms somewhere else.
Browsing a tree made the repository useful, but diffs were the feature that made it feel like a repository viewer instead of a file explorer. They were also the point where Git traversal, text comparison, syntax highlighting, and rendering all tried to become one problem.
I split a commit diff across three representations: Git objects in the worker, structured hunks in the client, and highlighted rows in the interface. Keeping those stages separate prevents the whole pipeline from collapsing into one large component.
For a normal commit, the diff layout requests two log entries beginning at the selected hash. It treats the first as the selected commit and the second as the comparison base. The worker walks both trees together, ignores directories and unchanged objects, and classifies each remaining path as added, removed, or modified.
const fileStates = await git.walk({
fs: this.fs,
dir: this.dir,
trees: [
git.TREE({ ref: oldCommitHash }),
git.TREE({ ref: newCommitHash })
],
map: async (filepath, [oldEntry, newEntry]) => {
if (filepath === '.') return;
if (
(await oldEntry?.type()) === 'tree' ||
(await newEntry?.type()) === 'tree'
) return;
const oldOid = await oldEntry?.oid();
const newOid = await newEntry?.oid();
if (oldOid === newOid) return;
if (oldOid === undefined) return { filepath, type: 'add', newOid };
if (newOid === undefined) return { filepath, type: 'remove', oldOid };
return { filepath, type: 'modify', oldOid, newOid };
}
});
For each changed text file, the worker reads the old and new blobs and uses the
diff package to create a patch, structured
hunks, and line statistics. Binary files and blobs larger than 5 MB get metadata
only; trying to build a giant text patch would waste memory and produce an
unusable interface anyway.
const fileIsBinary = isBinary(oldBlob) || isBinary(newBlob);
const isTooLarge =
(oldBlob?.byteLength ?? 0) > 5e6 ||
(newBlob?.byteLength ?? 0) > 5e6;
if (!fileIsBinary && !isTooLarge) {
const structured = Diff.structuredPatch(
file.filepath,
file.filepath,
oldText,
newText
);
const diffLines = Diff.diffLines(oldText, newText);
}
The worker processes changed files sequentially and yields to its event loop
after every ten files. The earlier implementation launched the whole batch with
Promise.all, which traded a shorter-looking function for an uncontrolled burst
of blob reads and diff work.
The finished result is cached by the old and new commit hashes. Reopening the same commit can reuse its file classification, hunks, and aggregate additions and deletions without walking both trees again.
The UI derives both unified and split rows from the same hunks. Consecutive
removed and added lines are paired, then Diff.diffChars() adds the darker
character-level highlights inside the normal red and green lines.
Combining syntax highlighting with diff highlighting
A plain diff is easy to color. A syntax-highlighted diff with character-level changes is more awkward because two systems want to control the same text.
Shiki knows that a token is a keyword, string, or comment. The diff algorithm knows that characters 14 through 22 changed. If I let either renderer replace the other, I lose useful information.
The solution is to highlight the complete old and new source files with Shiki, extract the HTML for each line, and weave the diff ranges through those tokens. The merge function consumes the syntax token text and diff segments together, wrapping changed chunks while preserving Shiki's token attributes.
const charsToConsume = Math.min(tokenCharsLeft, wordCharsLeft);
const chunk = token.text.substring(
tokenOffset,
tokenOffset + charsToConsume
);
let chunkHtml = escapeHtml(chunk);
if (token.attrs) {
chunkHtml = `<span ${token.attrs}>${chunkHtml}</span>`;
}
if (isTargetChange) {
chunkHtml = `<span class="${bgClass}">${chunkHtml}</span>`;
}
Shiki runs behind /api/highlight. That endpoint lazily creates one highlighter
with the site's supported languages, keeps up to 500 generated documents in a
per-instance memory cache, and returns an empty result instead of breaking the
whole diff if highlighting fails. Because the client sends a POST, the
response's immutable cache header is not a dependable browser or CDN cache.
This creates a deliberately hybrid boundary: Git object parsing and diff calculation happen in the browser worker, while syntax highlighting happens in a small cached server route. The source is already public repository data, so the request doesn't cross a new privacy boundary for this use case.
Rendering only what the reader can use
A commit may change hundreds of files. Creating a Vue component, two highlighted documents, and thousands of line rows for every file at once would undo the worker's performance gains.
The diff viewer initially renders six files. An intersection observer increases the visible slice as the reader approaches the bottom, while URL hashes can expand the slice far enough to reach a selected file from the sidebar.
This optimization controls DOM size and highlighting work. The worker still calculates the changed-file data for the complete commit before the first diff card appears.
Mobile devices default to the unified view because a split diff needs horizontal space. Readers can switch layouts, wrap long lines, collapse files, or request the full-file context only when a diff card is expanded.
The theme here is the same as the tree metadata: calculate and render expensive details when they become useful, not merely because they exist.
Nuxt is still the application framework
For all the unusual machinery in the middle, I did not want the repository viewer to become a separate application hidden inside the portfolio. The surrounding application is normal Nuxt: typed Nuxt Content collections, file-based routing, layouts, composables, and Nuxt UI.
The repository route selects one of three layout styles:
- The default portfolio layout for repository overviews and history.
- A resizable tree layout for directories and files.
- A diff layout whose sidebar lists files changed by the selected commit.
The tree and diff layouts provide the shared repository controller to child components. File viewers, branch selectors, breadcrumbs, and action menus all consume the same reactive state instead of creating their own worker.
A reader can select a ref, search the complete file tree, open a file, inspect its last commit, toggle raw Markdown, or download a snapshot. The history view filters locally by author and date, and a commit can reopen either the exact repository snapshot or its changed-file sidebar and split or unified diff.
Nuxt UI supplies the shell and responsive controls, while Nuxt Content's
<MDC> renderer gives repository Markdown the same prose styling as the rest of
the site. The framework pieces make the browser Git engine feel like part of
the portfolio instead of a second application mounted beside it.
This split also keeps the regular site cheap. Someone reading an article never initializes LightningFS, downloads a packfile, or starts a Git worker. The heavy client architecture only exists under repository routes.
Why this boundary keeps the feature cheap
This design fits my portfolio because the repositories are curated, public, and read-only. More importantly, each layer owns one narrow responsibility instead of sharing a second repository model across the stack.
- GitHub Actions prepares bare copies. It runs native Git and publishes the static metadata used by the transport.
- R2 or local storage owns bytes. Neither storage mode needs to interpret a commit, tree, or blob.
- Nitro owns transport. The proxy provides one same-origin URL and applies mutability-aware cache rules to production R2 responses.
- The worker owns Git. It reconstructs the repository and derives the data structures required by the interface.
- Nuxt and Vue own interaction. Routes, layouts, composables, and components turn worker results into a navigable application.
No synchronization job converts every file and commit into database rows, and no read endpoint invents a second tree or history schema. Publishing updates the native Git data; the browser interprets that data when a reader needs it.
The work that requires native Git runs during synchronization, after a repository changes, rather than inside an always-on service. Runtime reads are object storage requests through a thin edge route, while the visitor's worker performs the repository queries. At this portfolio's current traffic and storage footprint, the design can remain inside the Cloudflare allowances I was already using.
That is not the same as claiming the architecture is free in every context. R2 does not charge for egress, but storage, operations, and function invocations are still metered beyond their allowances. Browser CPU and development time also exist. The point is that I do not operate a dedicated Git-aware server between visits.
Repeat visits also benefit from where the state lives. IndexedDB preserves the repository, in-memory caches preserve derived objects, and the Vue facade survives navigation between overview, tree, blob, history, and diff routes.
Read-only does not mean private
The viewer's security depends on every published repository being public. Read-only prevents browser writes, but it does not turn raw Git objects into an access-control system.
The browser never receives R2 or GitHub credentials, and the bucket remains
behind its Cloudflare binding. The public Nitro proxy can still expose any
uploaded file or object key beneath its fixed repos/ prefix; it does not apply
a repository allowlist. A self-hosted bare repository must therefore avoid
credential-bearing remote URLs in its configuration.
The project collection is a catalog, not authorization. It decides which
repositories receive a polished route, but it does not protect the storage
prefix. The webhook also ignores a repository after it becomes private instead
of retracting its old copy, and rclone copy does not remove destination-only
files. A previously public or deleted repository remains downloadable until its
published prefix is explicitly purged.
The webhook signature protects the automation path from arbitrary workflow dispatches. It does not change the read path, which is intentionally public and cacheable.
Limitations
This architecture moves server work to the client; it does not make that work disappear. The visitor pays in bandwidth, IndexedDB storage, memory, and CPU.
The main limitations are:
- A first visit downloads complete packfiles, even if the reader opens one file.
- Each response becomes a complete
ArrayBufferbefore its IndexedDB write, so peak first-load memory grows with the largest pack as well as the parsed Git data. - Very large histories make uncapped history and path searches expensive.
- Very large file counts also matter: initialization transfers the complete path list and Vue builds an in-memory navigation and search model from it.
- The 1,000-commit tree scan can leave old entries without an immediate commit label.
rclone copydoes not delete obsolete pack objects or removed repositories from R2, so published storage can accumulate too.- Browser storage can be cleared, evicted, or corrupted.
- It has no server-side search index, blame engine, or cross-repository query system.
Some Git semantics are simplified too. Rename detection is not implemented, so a rename appears as one removed path and one added path, and Git LFS objects are not fetched from their external storage.
The separately published Nuxt and cURL showcase copies are useful stress tests. They show that the design can handle repositories much larger than my own, but they also make the compromises visible. The automation shown above excludes forks, so those repositories are prepared outside that discovery path. Something enormous like QEMU or the Linux kernel belongs behind cgit, or a proper Git service.
Finally
I did not begin this project with a grand theory about browser-native Git. I saw how naturally cgit made repositories part of zx2c4.com, looked at the outbound GitHub links in my own portfolio, and wanted that experience here without adding infrastructure I would have to keep paying for and maintaining.
I did not recreate cgit either. cgit keeps Git beside the server process and renders repository views there. This viewer keeps Git in object storage, moves the database into IndexedDB, and asks a worker to render those same concepts in the browser. The shared idea is simpler than either implementation: the repository should be the center of the experience.
The biggest architectural simplification came from the most basic discovery. Once I understood that files, folders, histories, and diffs were already views over Git's refs, commits, trees, and blobs, a custom repository API stopped looking necessary. I could publish the original data model instead of inventing another one.
That realization removed an application API; it did not remove the implementation work. The worker was not enough on its own. I still had to stop running one history query per tree row, compare tree object IDs, cap broad scans, publish metadata progressively, cache at several layers, reject files the browser cannot render well, and delay diff DOM work until someone can see it. The object model made the design possible; the less glamorous boundaries made it usable.
Most importantly, the project now does the thing I genuinely wanted. A project card no longer has to eject a curious reader at the most interesting moment. The README, source tree, branches, history, and diffs can all remain part of the story this portfolio is telling.
It is not a replacement for GitHub, and it is definitely not the right viewer for every repository. It is my small, read-only answer to the question that started the project: how much Git infrastructure does a portfolio actually need?
You can inspect the portfolio with the viewer itself, then compare it with the larger Nuxt and cURL showcase copies to see both the design and its limits in practice.