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

repo-size.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.

repo-size.tsxBlame320 lines · 1 contributor
ce08e97Claude1/**
2 * Block J31 — Repository size audit.
3 *
4 * GET /:owner/:repo/insights/size[?top=N&min=B&ref=<branch>]
5 *
6 * Renders "where are the bytes?" for the given ref (default branch by
7 * default): summary stats, a size-class histogram, a top-level directory
8 * breakdown, and the largest N files.
9 *
10 * softAuth, read-only. Reuses `listTreeRecursive` from J30. Git failures
11 * degrade to an empty report — never 500.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { getDefaultBranch, listTreeRecursive } from "../git/repository";
23import {
24 buildSizeReport,
25 DEFAULT_TOP_N,
26 type RepoSizeEntry,
27} from "../lib/repo-size";
28import { formatBytes, formatPercent } from "../lib/language-stats";
29
30const MAX_TOP_N = 200;
31const ABS_MAX_MIN_BYTES = 1024 * 1024 * 1024; // 1 GiB cap on user-supplied floor
32
33const repoSizeRoutes = new Hono<AuthEnv>();
34
35repoSizeRoutes.use("*", softAuth);
36
37async function resolveRepo(ownerName: string, repoName: string) {
38 try {
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44 if (!owner) return null;
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(
50 eq(repositories.ownerId, owner.id),
51 eq(repositories.name, repoName)
52 )
53 )
54 .limit(1);
55 if (!repo) return null;
56 return { owner, repo };
57 } catch {
58 return null;
59 }
60}
61
62function parsePositiveInt(
63 raw: string | undefined,
64 fallback: number,
65 max: number
66): number {
67 if (!raw) return fallback;
68 const n = Number(raw);
69 if (!Number.isFinite(n) || n < 0) return fallback;
70 return Math.min(Math.floor(n), max);
71}
72
73function sanitiseRef(raw: string | undefined): string | null {
74 if (!raw) return null;
75 const trimmed = raw.trim();
76 if (!trimmed || trimmed.length > 200) return null;
77 if (/\.\./.test(trimmed)) return null;
78 if (/[\s~^:?*[\\]/.test(trimmed)) return null;
79 return trimmed;
80}
81
82repoSizeRoutes.get("/:owner/:repo/insights/size", async (c) => {
83 const { owner: ownerName, repo: repoName } = c.req.param();
84 const user = c.get("user");
85
86 const topN = parsePositiveInt(c.req.query("top"), DEFAULT_TOP_N, MAX_TOP_N);
87 const minBytes = parsePositiveInt(c.req.query("min"), 0, ABS_MAX_MIN_BYTES);
88 const refParam = sanitiseRef(c.req.query("ref"));
89
90 const resolved = await resolveRepo(ownerName, repoName);
91 if (!resolved) {
92 return c.html(
93 <Layout title="Not Found" user={user}>
94 <div class="empty-state">
95 <h2>Repository not found</h2>
96 </div>
97 </Layout>,
98 404
99 );
100 }
101
102 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
103 return c.html(
104 <Layout title="Not Found" user={user}>
105 <div class="empty-state">
106 <h2>Repository not found</h2>
107 </div>
108 </Layout>,
109 404
110 );
111 }
112
113 let ref: string | null = refParam;
114 if (!ref) {
115 try {
116 ref = await getDefaultBranch(ownerName, repoName);
117 } catch {
118 ref = null;
119 }
120 }
121
122 let entries: RepoSizeEntry[] = [];
123 if (ref) {
124 try {
125 entries = await listTreeRecursive(ownerName, repoName, ref);
126 } catch {
127 entries = [];
128 }
129 }
130
131 const report = buildSizeReport({
132 entries,
133 topN,
134 minBytesForLargest: minBytes > 0 ? minBytes : undefined,
135 });
136
137 const empty = report.summary.countedFiles === 0;
138
139 const kpi = (label: string, value: string) => (
140 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
141 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
142 {label}
143 </div>
144 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
145 {value}
146 </div>
147 </div>
148 );
149
150 return c.html(
151 <Layout title={`Size audit — ${ownerName}/${repoName}`} user={user}>
152 <RepoHeader owner={ownerName} repo={repoName} />
153 <div style="max-width: 920px">
154 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
155 <h2 style="margin: 0">Size audit</h2>
156 <form
157 method="GET"
158 action={`/${ownerName}/${repoName}/insights/size`}
159 style="display: flex; gap: 10px; align-items: center; font-size: 12px"
160 >
161 <label style="display: flex; align-items: center; gap: 4px">
162 Top
163 <select
164 name="top"
165 onchange="this.form.submit()"
166 style="padding: 2px 6px; font-size: 12px"
167 >
168 {[10, 25, 50, 100].map((n) => (
169 <option value={String(n)} selected={n === topN}>
170 {n}
171 </option>
172 ))}
173 </select>
174 </label>
175 {refParam ? (
176 <input type="hidden" name="ref" value={refParam} />
177 ) : null}
178 </form>
179 </div>
180 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
181 {ref ? (
182 <>
183 Analyzed <code>{ref}</code>. Includes everything in the working
184 tree, vendored files and all — this is a raw disk-footprint view.
185 </>
186 ) : (
187 <>No default branch detected — repository may be empty.</>
188 )}
189 </p>
190
191 {empty ? (
192 <div class="empty-state">
193 <h3>No files to audit</h3>
194 <p>The repository appears to be empty.</p>
195 </div>
196 ) : (
197 <>
198 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 24px">
199 {kpi("Files", report.summary.countedFiles.toLocaleString())}
200 {kpi("Total size", formatBytes(report.summary.totalBytes))}
201 {kpi("Largest", formatBytes(report.summary.largestBytes))}
202 {kpi("Median", formatBytes(report.summary.medianBytes))}
203 {kpi("Mean", formatBytes(report.summary.averageBytes))}
204 </div>
205
206 <h3 style="margin-bottom: 10px">Size-class distribution</h3>
207 <div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 24px">
208 {report.buckets.map((b) => (
209 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center">
210 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
211 {b.label}
212 </div>
213 <div style="font-size: 18px; font-weight: 600">
214 {b.fileCount}
215 </div>
216 <div style="font-size: 11px; color: var(--text-muted); margin-top: 2px; font-family: var(--font-mono)">
217 {formatBytes(b.bytes)}
218 </div>
219 </div>
220 ))}
221 </div>
222
223 <h3 style="margin-bottom: 10px">Top-level directories</h3>
224 <table style="width: 100%; border-collapse: collapse; margin-bottom: 24px">
225 <thead>
226 <tr>
227 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
228 Path
229 </th>
230 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
231 Files
232 </th>
233 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
234 Size
235 </th>
236 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
237 Share
238 </th>
239 </tr>
240 </thead>
241 <tbody>
242 {report.directories.map((d) => (
243 <tr>
244 <td style="padding: 8px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 13px">
245 {d.name === "." ? "(root)" : `${d.name}/`}
246 </td>
247 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
248 {d.fileCount.toLocaleString()}
249 </td>
250 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
251 {formatBytes(d.bytes)}
252 </td>
253 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
254 {formatPercent(d.percent)}
255 </td>
256 </tr>
257 ))}
258 </tbody>
259 </table>
260
261 <h3 style="margin-bottom: 10px">
262 Largest files ({report.largest.length})
263 </h3>
264 {report.largest.length === 0 ? (
265 <div class="empty-state">
266 <p>No files match the filter.</p>
267 </div>
268 ) : (
269 <table style="width: 100%; border-collapse: collapse">
270 <thead>
271 <tr>
272 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
273 Path
274 </th>
275 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
276 Size
277 </th>
278 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
279 Share
280 </th>
281 </tr>
282 </thead>
283 <tbody>
284 {report.largest.map((f) => (
285 <tr>
286 <td style="padding: 8px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 12px; word-break: break-all">
287 {ref ? (
288 <a
289 href={`/${ownerName}/${repoName}/blob/${encodeURIComponent(
290 ref
291 )}/${f.path
292 .split("/")
293 .map((s) => encodeURIComponent(s))
294 .join("/")}`}
295 >
296 {f.path}
297 </a>
298 ) : (
299 f.path
300 )}
301 </td>
302 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
303 {formatBytes(f.size)}
304 </td>
305 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
306 {formatPercent(f.percent)}
307 </td>
308 </tr>
309 ))}
310 </tbody>
311 </table>
312 )}
313 </>
314 )}
315 </div>
316 </Layout>
317 );
318});
319
320export default repoSizeRoutes;