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.tsxBlame183 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";
bb0f894Claude20import {
21 EmptyState,
22 Flex,
23 Select,
24 Button,
25 Text,
26} from "../views/ui";
79136bbClaude27
28const compare = new Hono<AuthEnv>();
29
30compare.use("*", softAuth);
31
32compare.get("/:owner/:repo/compare/:spec?", async (c) => {
33 const { owner, repo } = c.req.param();
34 const user = c.get("user");
35 const spec = c.req.param("spec");
36
37 if (!(await repoExists(owner, repo))) {
38 return c.html(
39 <Layout title="Not Found" user={user}>
bb0f894Claude40 <EmptyState title="Repository not found" />
79136bbClaude41 </Layout>,
42 404
43 );
44 }
45
46 const branches = await listBranches(owner, repo);
47
48 if (!spec || !spec.includes("...")) {
49 // Show compare picker
50 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
51 return c.html(
52 <Layout title={`Compare — ${owner}/${repo}`} user={user}>
53 <RepoHeader owner={owner} repo={repo} />
54 <IssueNav owner={owner} repo={repo} active="code" />
55 <h2 style="margin-bottom: 16px">Compare changes</h2>
56 <form
45e31d0Claude57 method="get"
79136bbClaude58 action={`/${owner}/${repo}/compare`}
59 >
bb0f894Claude60 <Flex gap={12} align="center" style="margin-bottom: 20px">
61 <Select name="base">
62 {branches.map((b) => (
63 <option value={b} selected={b === defaultBase}>
64 {b}
65 </option>
66 ))}
67 </Select>
68 <Text muted>...</Text>
69 <Select name="head">
70 {branches.map((b) => (
71 <option value={b} selected={b !== defaultBase}>
72 {b}
73 </option>
74 ))}
75 </Select>
76 <Button
77 type="submit"
78 variant="primary"
79 >
80 Compare
81 </Button>
82 </Flex>
79136bbClaude83 </form>
84 </Layout>
85 );
86 }
87
88 const [base, head] = spec.split("...");
89
90 // Get diff
91 const repoDir = getRepoPath(owner, repo);
92 const proc = Bun.spawn(
93 ["git", "diff", `${base}...${head}`],
94 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
95 );
96 const raw = await new Response(proc.stdout).text();
97 await proc.exited;
98
99 // Get numstat
100 const statProc = Bun.spawn(
101 ["git", "diff", "--numstat", `${base}...${head}`],
102 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
103 );
104 const stat = await new Response(statProc.stdout).text();
105 await statProc.exited;
106
107 const files: GitDiffFile[] = stat
108 .trim()
109 .split("\n")
110 .filter(Boolean)
111 .map((line) => {
112 const [add, del, filePath] = line.split("\t");
113 return {
114 path: filePath,
115 status: "modified",
116 additions: add === "-" ? 0 : parseInt(add, 10),
117 deletions: del === "-" ? 0 : parseInt(del, 10),
118 patch: "",
119 };
120 });
121
122 // Get commits between
123 const logProc = Bun.spawn(
124 [
125 "git",
126 "log",
127 "--format=%H%x00%s%x00%an%x00%aI",
128 `${base}...${head}`,
129 ],
130 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
131 );
132 const logOutput = await new Response(logProc.stdout).text();
133 await logProc.exited;
134
135 const commitsBetween = logOutput
136 .trim()
137 .split("\n")
138 .filter(Boolean)
139 .map((line) => {
140 const [sha, msg, author, date] = line.split("\0");
141 return { sha, message: msg, author, date };
142 });
143
144 return c.html(
145 <Layout title={`${base}...${head} — ${owner}/${repo}`} user={user}>
146 <RepoHeader owner={owner} repo={repo} />
147 <IssueNav owner={owner} repo={repo} active="code" />
148 <h2 style="margin-bottom: 8px">
149 Comparing {base}...{head}
150 </h2>
bb0f894Claude151 <Text size={14} muted style="display:block;margin-bottom:20px">
79136bbClaude152 {commitsBetween.length} commit{commitsBetween.length !== 1 ? "s" : ""}
bb0f894Claude153 </Text>
79136bbClaude154
155 {commitsBetween.length > 0 && (
156 <div class="commit-list" style="margin-bottom: 24px">
157 {commitsBetween.map((cm) => (
158 <div class="commit-item">
159 <div>
160 <div class="commit-message">
161 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
162 {cm.message}
163 </a>
164 </div>
165 <div class="commit-meta">{cm.author}</div>
166 </div>
167 <a
168 href={`/${owner}/${repo}/commit/${cm.sha}`}
169 class="commit-sha"
170 >
171 {cm.sha.slice(0, 7)}
172 </a>
173 </div>
174 ))}
175 </div>
176 )}
177
178 <DiffView raw={raw} files={files} />
179 </Layout>
180 );
181});
182
183export default compare;