Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

compare.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

compare.tsxBlame178 lines · 1 contributor
79136bbClaude1/**
2 * Compare view — diff between two branches or commits.
3 * URL: /:owner/:repo/compare/:base...:head
4 */
5
6import { Hono } from "hono";
7import { join } from "path";
8import { Layout } from "../views/layout";
9import { RepoHeader, DiffView } from "../views/components";
10import { IssueNav } from "./issues";
11import {
12 listBranches,
13 listCommits,
14 repoExists,
15 getRepoPath,
16} from "../git/repository";
17import { softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import type { GitDiffFile } from "../git/repository";
20
21const compare = new Hono<AuthEnv>();
22
23compare.use("*", softAuth);
24
25compare.get("/:owner/:repo/compare/:spec?", async (c) => {
26 const { owner, repo } = c.req.param();
27 const user = c.get("user");
28 const spec = c.req.param("spec");
29
30 if (!(await repoExists(owner, repo))) {
31 return c.html(
32 <Layout title="Not Found" user={user}>
33 <div class="empty-state">
34 <h2>Repository not found</h2>
35 </div>
36 </Layout>,
37 404
38 );
39 }
40
41 const branches = await listBranches(owner, repo);
42
43 if (!spec || !spec.includes("...")) {
44 // Show compare picker
45 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
46 return c.html(
47 <Layout title={`Compare — ${owner}/${repo}`} user={user}>
48 <RepoHeader owner={owner} repo={repo} />
49 <IssueNav owner={owner} repo={repo} active="code" />
50 <h2 style="margin-bottom: 16px">Compare changes</h2>
51 <form
52 method="GET"
53 action={`/${owner}/${repo}/compare`}
54 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px"
55 >
56 <select name="base" class="branch-selector" style="cursor: pointer">
57 {branches.map((b) => (
58 <option value={b} selected={b === defaultBase}>
59 {b}
60 </option>
61 ))}
62 </select>
63 <span style="color: var(--text-muted)">...</span>
64 <select name="head" class="branch-selector" style="cursor: pointer">
65 {branches.map((b) => (
66 <option value={b} selected={b !== defaultBase}>
67 {b}
68 </option>
69 ))}
70 </select>
71 <button
72 type="submit"
73 class="btn btn-primary"
74 onclick={`this.form.action='/${owner}/${repo}/compare/'+this.form.base.value+'...'+this.form.head.value; return true;`}
75 >
76 Compare
77 </button>
78 </form>
79 </Layout>
80 );
81 }
82
83 const [base, head] = spec.split("...");
84
85 // Get diff
86 const repoDir = getRepoPath(owner, repo);
87 const proc = Bun.spawn(
88 ["git", "diff", `${base}...${head}`],
89 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
90 );
91 const raw = await new Response(proc.stdout).text();
92 await proc.exited;
93
94 // Get numstat
95 const statProc = Bun.spawn(
96 ["git", "diff", "--numstat", `${base}...${head}`],
97 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
98 );
99 const stat = await new Response(statProc.stdout).text();
100 await statProc.exited;
101
102 const files: GitDiffFile[] = stat
103 .trim()
104 .split("\n")
105 .filter(Boolean)
106 .map((line) => {
107 const [add, del, filePath] = line.split("\t");
108 return {
109 path: filePath,
110 status: "modified",
111 additions: add === "-" ? 0 : parseInt(add, 10),
112 deletions: del === "-" ? 0 : parseInt(del, 10),
113 patch: "",
114 };
115 });
116
117 // Get commits between
118 const logProc = Bun.spawn(
119 [
120 "git",
121 "log",
122 "--format=%H%x00%s%x00%an%x00%aI",
123 `${base}...${head}`,
124 ],
125 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
126 );
127 const logOutput = await new Response(logProc.stdout).text();
128 await logProc.exited;
129
130 const commitsBetween = logOutput
131 .trim()
132 .split("\n")
133 .filter(Boolean)
134 .map((line) => {
135 const [sha, msg, author, date] = line.split("\0");
136 return { sha, message: msg, author, date };
137 });
138
139 return c.html(
140 <Layout title={`${base}...${head} — ${owner}/${repo}`} user={user}>
141 <RepoHeader owner={owner} repo={repo} />
142 <IssueNav owner={owner} repo={repo} active="code" />
143 <h2 style="margin-bottom: 8px">
144 Comparing {base}...{head}
145 </h2>
146 <div style="margin-bottom: 20px; font-size: 14px; color: var(--text-muted)">
147 {commitsBetween.length} commit{commitsBetween.length !== 1 ? "s" : ""}
148 </div>
149
150 {commitsBetween.length > 0 && (
151 <div class="commit-list" style="margin-bottom: 24px">
152 {commitsBetween.map((cm) => (
153 <div class="commit-item">
154 <div>
155 <div class="commit-message">
156 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
157 {cm.message}
158 </a>
159 </div>
160 <div class="commit-meta">{cm.author}</div>
161 </div>
162 <a
163 href={`/${owner}/${repo}/commit/${cm.sha}`}
164 class="commit-sha"
165 >
166 {cm.sha.slice(0, 7)}
167 </a>
168 </div>
169 ))}
170 </div>
171 )}
172
173 <DiffView raw={raw} files={files} />
174 </Layout>
175 );
176});
177
178export default compare;