Writing

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.

This is a cgit-inspired design, not a port of cgit. cgit reads repositories on the server through a CGI application; this viewer ships the repository's object database to a read-only Git client in the browser.

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 viewer is intentionally read-only. It can browse branches and tags, inspect history, render files, calculate diffs, and export a ZIP, but it never writes to the upstream repository.

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.

DesignWhat I would gainWhy I did not choose it
Link to GitHubNo viewer to build or operateThe repository still leaves the portfolio
Read through GitHub's APIStructured commits, trees, and blobsThe runtime would depend on GitHub's API model and limits
Generate repository-shaped JSONStatic delivery and predictable renderingI would create and maintain a second database derived from Git
Run cgit or another Git serviceA mature server-side repository browserIt needs a Git-aware server process and infrastructure around it
Read Git in the browserA thin backend and a locally cached repositoryThe 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.

A simplified Git object graph
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.

The complete data flow
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.

scripts/getPublicRepos.mjs
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.

The publishing stage does the only work that requires the native Git CLI. Once the bare repository reaches storage, the production Git transport only needs ordinary HTTP reads.

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.

server/routes/webhooks/repos-sync.post.ts
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:

.github/workflows/sync-repos.yml
- 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:

Repository transport URLs
/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.

server/api/git-proxy/[...path].ts
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.

server/api/git-proxy/[...path].ts
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:

Terminal
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:

app/composables/repo/createRepo.ts
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.

app/utils/useFs.ts
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.

app/utils/useFs.ts
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.

The current transport downloads complete packfiles. That is simple and fast for small and medium repositories, but it is not partial clone, shallow clone, or object-on-demand fetching. Repository size still matters.

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.

app/workers/git.worker.ts
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.

app/composables/repo/createRepo.ts
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.

BoundaryResponsibilityMain files
Transport and persistenceFetch Git files and maintain the IndexedDB-backed .git directoryapp/utils/useFs.ts
Git engineRead refs, commits, trees, blobs, and create ZIP archivesapp/workers/git.worker.ts
Derived Git cachesCache logs, parsed trees, folder metadata, and commit diffsapp/composables/repo/useGitRepoCache.ts
Tree historyResolve the latest commit touching each visible entryapp/utils/resolveTreeCommitsAsync.ts
Vue facadeTranslate worker results into reactive application stateapp/composables/repo/createRepo.ts, app/composables/repo/useGitRepo.ts
File interpretationClassify blobs, create image URLs, rewrite Markdown assets, and download filesapp/composables/repo/useFileContent.ts, app/composables/repo/useDownload.ts, app/composables/repo/useMarkdownImageResolver.ts
Diff derivationBuild unified and split rows, expand context, highlight source, and combine syntax tokens with changesapp/composables/repo/useDiffRows.ts, app/composables/repo/useFullFileDiff.ts, app/composables/repo/useSyntaxHighlighting.ts, app/composables/repo/useDiffRowsHighlighted.ts
NavigationConvert repository paths and changed files into sidebar linksapp/utils/useTreeLinks.ts, app/utils/useDiffTreeLinks.ts, app/utils/goBack.ts
PresentationCompose the overview, tree, blob, history, and diff interfacesapp/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.

app/workers/git.worker.ts
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.

app/utils/resolveTreeCommitsAsync.ts
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:

LayerStoresAvoids
Browser and intermediary HTTP cachesImmutable packs and Git objectsRepeated network reads when caches honor the headers
LightningFS / IndexedDBThe reconstructed .git directoryRe-downloading a repository
IndexedDB JSON cacheCommit logs and per-entry commit metadataRewalking history after reload
Worker memory mapsParsed trees, folder results, logs, and diffsRepeating work during one session
Vue repository cacheWorker-backed repository instancesReinitializing between Nuxt routes
Highlight cachesShiki HTML and merged diff tokensRe-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:

app/composables/repo/useGitRepoCache.ts
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.

app/composables/repo/useGitRepo.ts
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.

app/pages/repo/[...slug].vue
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:

Repository routes
/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>.

nuxt.config.ts
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.

app/workers/git.worker.ts
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.

app/composables/repo/useFileContent.ts
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.
app/components/repo/RepoFileViewer.vue
<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.

app/workers/git.worker.ts
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.

app/workers/git.worker.ts
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.

app/composables/repo/useDiffRowsHighlighted.ts
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 ArrayBuffer before 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 copy does 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.