summaryrefslogtreecommitdiff
path: root/cgithub
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-09-03 01:37:35 -0400
committerJoe Mou <dev@mou.fo>2026-09-03 22:16:44 -0400
commita61a96c6d72d4297b2539f4033fbfbd0a3d178ab (patch)
tree7aa850c87bee9dbffd6032e160abfd14098c447a /cgithub
parent501587e256a065816f7eb6007030e728955a32dc (diff)
Link blobs to their history and back
Nothing in a commits payload says whether the path it lists is a file or a directory: currentBlobPath, despite the name, is only the path filter, and is set for a directory just the same. GitHub's own UI cannot tell either, and points its browse button at /blob/<oid>/<path> so that the redirect resolves a file to its blob. We do the same, and the blob route already turns that redirect into one of our own tree URLs. Breadcrumbs now stay in the view they were clicked from, so walking up from a history lands in the history of each parent directory rather than in its listing. The last crumb remains the page you are already on. A path with no commits now says so rather than rendering an empty page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7pRKdmTvNDgT4ZXrwkbH8
Diffstat (limited to 'cgithub')
-rw-r--r--cgithub/CLAUDE.md1
-rw-r--r--cgithub/src/app.test.ts56
-rw-r--r--cgithub/src/scraper.test.ts33
-rw-r--r--cgithub/views/_nav.eta7
-rw-r--r--cgithub/views/blob.eta3
-rw-r--r--cgithub/views/commits.eta8
6 files changed, 106 insertions, 2 deletions
diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md
index c0c211d..3f0cd61 100644
--- a/cgithub/CLAUDE.md
+++ b/cgithub/CLAUDE.md
@@ -33,6 +33,7 @@ pnpm test
- `GET /:owner/:repo` - Repository info with directory listing
- `GET /:owner/:repo/tree/:branch/:path*` - Directory listing
- `GET /:owner/:repo/blob/:branch/:path` - File content view
+- `GET /:owner/:repo/commits/:branch/:path*` - Commit history of the repository, a directory, or a file
- `GET /:owner/:repo/wiki` - Wiki home page
- `GET /:owner/:repo/wiki/:page` - Wiki page (also `/:page/:oid` for a revision)
- `GET /:owner/:repo/wiki/_pages` - Wiki page index
diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts
index 265465e..8519d91 100644
--- a/cgithub/src/app.test.ts
+++ b/cgithub/src/app.test.ts
@@ -155,6 +155,62 @@ describe("blob view", () => {
});
});
+describe("commit history", () => {
+ const repo = { owner: "actions", name: "deploy-pages" };
+ const oid = "b39c421b98f49d83ae50ec502c3ddfc3bf28f2c6";
+ const commitGroups = [
+ {
+ title: "Apr 18, 2023",
+ commits: [
+ { oid, shortMessage: "Remove deprecated input parameters", authors: [{ login: "jmg" }] },
+ ],
+ },
+ ];
+ const history = { repo, branch: "main", path: "src/index.js", commitGroups };
+
+ // A commits payload never says whether its path is a file or a directory, so
+ // every link goes to the tree and GitHub redirects a file's to its blob.
+ it("should browse a file's history at each of its commits", () => {
+ const html = eta.render("commits.eta", history);
+
+ assert.match(html, new RegExp(`href="/actions/deploy-pages/blob/${oid}/src/index.js">Browse`));
+ });
+
+ it("should browse a directory's history the same way", () => {
+ const html = eta.render("commits.eta", { ...history, path: "src" });
+
+ assert.match(html, new RegExp(`href="/actions/deploy-pages/blob/${oid}/src">Browse`));
+ });
+
+ it("should browse the whole repository when the history has no path", () => {
+ const html = eta.render("commits.eta", { ...history, path: "" });
+
+ assert.match(html, new RegExp(`href="/actions/deploy-pages/blob/${oid}">Browse`));
+ });
+
+ // Walking up from a history stays in the history, and its last crumb is the
+ // page you are already on, just as a blob or a tree ends on itself.
+ it("should walk up through the history of each parent directory", () => {
+ const html = eta.render("commits.eta", { ...history, path: "src/lib/index.js" });
+
+ assert.match(html, /href="\/actions\/deploy-pages\/commits\/main">\(top\)</);
+ assert.match(html, /href="\/actions\/deploy-pages\/commits\/main\/src">src</);
+ assert.match(html, /href="\/actions\/deploy-pages\/commits\/main\/src\/lib">lib<\/a>\/index\.js/);
+ });
+
+ it("should say so when a path has no history", () => {
+ const html = eta.render("commits.eta", { ...history, commitGroups: [] });
+
+ assert.match(html, /No commits found\./);
+ });
+
+ it("should link a blob to its own history", () => {
+ const html = eta.render("blob.eta", { ...history, textLines: ["x"] });
+
+ assert.match(html, /href="\/actions\/deploy-pages\/commits\/main\/src\/index.js">History/);
+ });
+});
+
describe("embedded files", () => {
function stubGitHub(t: TestContext, body: string, contentType: string, status = 200) {
t.mock.method(
diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts
index 2690f71..f6be2b6 100644
--- a/cgithub/src/scraper.test.ts
+++ b/cgithub/src/scraper.test.ts
@@ -195,6 +195,39 @@ describe("GitHub scraper", () => {
assert.ok(data.commitGroups.length > 0);
});
+ // GitHub serves a file's history from the same route as a directory's.
+ it("should fetch the history of a blob", async () => {
+ const data = await getGitHubCommits("actions", "deploy-pages", "main", "src/index.js");
+
+ assert.strictEqual(data.path, "src/index.js");
+
+ assert.ok(data.commitGroups.length > 0);
+ const commit = data.commitGroups[0].commits[0];
+ assert.strictEqual(commit.oid.length, 40);
+ assert.ok(commit.shortMessage.length > 0);
+ });
+
+ // What a history page's "Browse" link resolves to: the file as it stood at
+ // one of its commits. Nothing in a commits payload says whether the path is
+ // a file or a directory, so those links all point at the tree and rely on
+ // GitHub redirecting a file's to its blob.
+ it("should redirect a blob browsed at a commit to its blob view", async () => {
+ const oid = "b39c421b98f49d83ae50ec502c3ddfc3bf28f2c6";
+
+ await assert.rejects(getGitHubTree("actions", "deploy-pages", oid, "src/index.js"), (err) => {
+ assert(err instanceof RedirectError, "error should be a RedirectError");
+ assert.strictEqual(err.location, `/actions/deploy-pages/blob/${oid}/src/index.js`);
+ return true;
+ });
+
+ const data = await getGitHubBlob("actions", "deploy-pages", oid, "src/index.js");
+
+ assert.strictEqual(data.branch, oid);
+ assert.strictEqual(data.path, "src/index.js");
+ assert.strictEqual(data.size, "1.37 KB / 51 lines / 40 loc");
+ assert.strictEqual(data.textLines?.length, 51);
+ });
+
it("should fetch releases", async () => {
const data = await getGitHubReleases("actions", "deploy-pages");
diff --git a/cgithub/views/_nav.eta b/cgithub/views/_nav.eta
index a20fbbc..a20741b 100644
--- a/cgithub/views/_nav.eta
+++ b/cgithub/views/_nav.eta
@@ -14,7 +14,10 @@
<div class="ref-selector-list">Loading…</div>
</dialog>
<% if (it.path && it.path !== "/") { %>
- <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/tree/<%= it.branch %>">(top)</a><%
+ <%
+ const crumbHrefBase = `/${it.repo.owner}/${it.repo.name}/${it.view === "blob" ? "tree" : it.view}/${it.branch}`;
+ %>
+ <a href="<%= crumbHrefBase %>">(top)</a><%
const pathParts = it.path.split('/').filter(p => p);
let currentPath = '';
for (let i = 0; i < pathParts.length; i++) {
@@ -24,7 +27,7 @@
if (isLast) {
%>/<%= part %><%= it.view === "tree" ? "/" : "" %><%
} else {
- %>/<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/tree/<%= it.branch %>/<%= currentPath %>"><%= part %></a><%
+ %>/<a href="<%= crumbHrefBase %>/<%= currentPath %>"><%= part %></a><%
}
} %>
<% } %>
diff --git a/cgithub/views/blob.eta b/cgithub/views/blob.eta
index be9a57e..2014b14 100644
--- a/cgithub/views/blob.eta
+++ b/cgithub/views/blob.eta
@@ -8,6 +8,9 @@
<% } %>
<div><%= it.size %></div>
<div>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/commits/<%= it.branch %>/<%= it.path %>">History</a>
+ </div>
+ <div>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/raw/<%= it.branch %>/<%= it.path %>">View raw</a>
</div>
</div>
diff --git a/cgithub/views/commits.eta b/cgithub/views/commits.eta
index 5cf0beb..c29693d 100644
--- a/cgithub/views/commits.eta
+++ b/cgithub/views/commits.eta
@@ -2,6 +2,12 @@
<%~ include("./_nav.eta", {...it, view: "commits"}) %>
+<% const pathSuffix = it.path && it.path !== "/" ? "/" + it.path : "" %>
+
+<% if (it.commitGroups.length === 0) { %>
+ <div class="box">No commits found.</div>
+<% } %>
+
<% for (const group of it.commitGroups) { %>
<h3><%= group.title %></h3>
<% for (const commit of group.commits) { %>
@@ -19,6 +25,8 @@
</div>
<div class="commit-sha">
<code><%= commit.oid.substring(0, 7) %></code>
+ <% /* Like GitHub, blindly link to blob and redirect for trees */ %>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/blob/<%= commit.oid %><%= pathSuffix %>">Browse</a>
</div>
</div>
<% } %>