Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

repo-size.ts

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.tsBlame246 lines · 1 contributor
ce08e97Claude1/**
2 * Block J31 — Repository size audit.
3 *
4 * Pure helpers that answer "where are the bytes?" for a working-tree
5 * snapshot at a given ref: total size, largest files, distribution by
6 * top-level directory, and a size-class histogram.
7 *
8 * Consumes the same `{path, size}` shape produced by
9 * `git ls-tree -r -l -z` (via `listTreeRecursive`), so the route stays
10 * a thin shell over `buildSizeReport`.
11 */
12export interface RepoSizeEntry {
13 path: string;
14 size: number;
15}
16
17export const DEFAULT_TOP_N = 25;
18
19/** Five size classes, ranked tiny → xlarge. Boundaries are inclusive-below. */
20export const SIZE_CLASSES = [
21 { key: "tiny", label: "< 1 KB", max: 1024 },
22 { key: "small", label: "1 KB – 100 KB", max: 100 * 1024 },
23 { key: "medium", label: "100 KB – 1 MB", max: 1024 * 1024 },
24 { key: "large", label: "1 MB – 10 MB", max: 10 * 1024 * 1024 },
25 { key: "xlarge", label: "≥ 10 MB", max: Number.POSITIVE_INFINITY },
26] as const;
27
28export type SizeClassKey = (typeof SIZE_CLASSES)[number]["key"];
29
30export interface SizeSummary {
31 totalFiles: number;
32 countedFiles: number;
33 totalBytes: number;
34 averageBytes: number;
35 medianBytes: number;
36 largestBytes: number;
37 smallestBytes: number;
38}
39
40export interface SizeBucket {
41 key: SizeClassKey;
42 label: string;
43 fileCount: number;
44 bytes: number;
45}
46
47export interface DirectoryBucket {
48 /** Top-level segment. Root files live under the "." pseudo-bucket. */
49 name: string;
50 fileCount: number;
51 bytes: number;
52 percent: number;
53}
54
55export interface LargestFile {
56 path: string;
57 size: number;
58 percent: number;
59 /** First path segment (or "." for root files). */
60 topDir: string;
61}
62
63export interface RepoSizeReport {
64 summary: SizeSummary;
65 buckets: SizeBucket[];
66 directories: DirectoryBucket[];
67 largest: LargestFile[];
68}
69
70/** Path → top-level segment (`src/foo/bar.ts` → `src`, `README.md` → `.`). */
71export function topLevelDir(path: string): string {
72 if (typeof path !== "string" || path.length === 0) return ".";
73 const normal = path.startsWith("/") ? path.slice(1) : path;
74 const i = normal.indexOf("/");
75 return i === -1 ? "." : normal.slice(0, i);
76}
77
78/** Keep only sane, finite, non-negative-sized string paths. */
79function validEntries(
80 entries: readonly RepoSizeEntry[]
81): RepoSizeEntry[] {
82 const out: RepoSizeEntry[] = [];
83 for (const e of entries) {
84 if (!e || typeof e.path !== "string" || e.path.length === 0) continue;
85 const sz = Number(e.size);
86 if (!Number.isFinite(sz) || sz < 0) continue;
87 out.push({ path: e.path, size: sz });
88 }
89 return out;
90}
91
92function median(sortedAsc: readonly number[]): number {
93 const n = sortedAsc.length;
94 if (n === 0) return 0;
95 if (n % 2 === 1) return sortedAsc[(n - 1) / 2]!;
96 const a = sortedAsc[n / 2 - 1]!;
97 const b = sortedAsc[n / 2]!;
98 return Math.round((a + b) / 2);
99}
100
101export function summariseSize(entries: readonly RepoSizeEntry[]): SizeSummary {
102 const valid = validEntries(entries);
103 const sizes = valid.map((e) => e.size).sort((a, b) => a - b);
104 const total = sizes.reduce((acc, n) => acc + n, 0);
105 const n = sizes.length;
106 return {
107 totalFiles: entries.length,
108 countedFiles: n,
109 totalBytes: total,
110 averageBytes: n === 0 ? 0 : Math.round(total / n),
111 medianBytes: median(sizes),
112 largestBytes: n === 0 ? 0 : sizes[n - 1]!,
113 smallestBytes: n === 0 ? 0 : sizes[0]!,
114 };
115}
116
117export function classifyFileSize(size: number): SizeClassKey {
118 if (!Number.isFinite(size) || size < 0) return "tiny";
119 for (const c of SIZE_CLASSES) {
120 if (size < c.max) return c.key;
121 }
122 // Math says we can't get here (last class max = Infinity), but be defensive.
123 return "xlarge";
124}
125
126export function bucketBySize(
127 entries: readonly RepoSizeEntry[]
128): SizeBucket[] {
129 const valid = validEntries(entries);
130 const out: SizeBucket[] = SIZE_CLASSES.map((c) => ({
131 key: c.key,
132 label: c.label,
133 fileCount: 0,
134 bytes: 0,
135 }));
136 for (const e of valid) {
137 const key = classifyFileSize(e.size);
138 const b = out.find((x) => x.key === key)!;
139 b.fileCount++;
140 b.bytes += e.size;
141 }
142 return out;
143}
144
145export interface TopLargestOptions {
146 /** Max files to return. Default `DEFAULT_TOP_N`. */
147 limit?: number;
148 /** Minimum bytes for inclusion. Default 0 (= no floor). */
149 minBytes?: number;
150}
151
152export function topLargestFiles(
153 entries: readonly RepoSizeEntry[],
154 opts: TopLargestOptions = {}
155): LargestFile[] {
156 const limit =
157 opts.limit !== undefined && opts.limit > 0
158 ? Math.floor(opts.limit)
159 : DEFAULT_TOP_N;
160 const minBytes = opts.minBytes ?? 0;
161
162 const valid = validEntries(entries).filter((e) => e.size >= minBytes);
163 const total = valid.reduce((acc, e) => acc + e.size, 0);
164
165 const sorted = valid.slice().sort((a, b) => {
166 if (a.size !== b.size) return b.size - a.size;
167 return a.path.localeCompare(b.path);
168 });
169
170 return sorted.slice(0, limit).map((e) => ({
171 path: e.path,
172 size: e.size,
173 percent: total === 0 ? 0 : (e.size / total) * 100,
174 topDir: topLevelDir(e.path),
175 }));
176}
177
178export function summariseByTopDir(
179 entries: readonly RepoSizeEntry[]
180): DirectoryBucket[] {
181 const valid = validEntries(entries);
182 const total = valid.reduce((acc, e) => acc + e.size, 0);
183 const byDir = new Map<string, { fileCount: number; bytes: number }>();
184
185 for (const e of valid) {
186 const key = topLevelDir(e.path);
187 const agg = byDir.get(key) ?? { fileCount: 0, bytes: 0 };
188 agg.fileCount++;
189 agg.bytes += e.size;
190 byDir.set(key, agg);
191 }
192
193 const out: DirectoryBucket[] = [];
194 for (const [name, { fileCount, bytes }] of byDir) {
195 out.push({
196 name,
197 fileCount,
198 bytes,
199 percent: total === 0 ? 0 : (bytes / total) * 100,
200 });
201 }
202
203 out.sort((a, b) => {
204 if (a.bytes !== b.bytes) return b.bytes - a.bytes;
205 // Root bucket sorts last on ties so real directories surface first.
206 if (a.name === "." && b.name !== ".") return 1;
207 if (b.name === "." && a.name !== ".") return -1;
208 return a.name.localeCompare(b.name);
209 });
210
211 return out;
212}
213
214export interface BuildSizeReportOptions {
215 entries: readonly RepoSizeEntry[];
216 topN?: number;
217 minBytesForLargest?: number;
218}
219
220export function buildSizeReport(
221 opts: BuildSizeReportOptions
222): RepoSizeReport {
223 return {
224 summary: summariseSize(opts.entries),
225 buckets: bucketBySize(opts.entries),
226 directories: summariseByTopDir(opts.entries),
227 largest: topLargestFiles(opts.entries, {
228 limit: opts.topN,
229 minBytes: opts.minBytesForLargest,
230 }),
231 };
232}
233
234export const __internal = {
235 SIZE_CLASSES,
236 DEFAULT_TOP_N,
237 topLevelDir,
238 classifyFileSize,
239 summariseSize,
240 bucketBySize,
241 topLargestFiles,
242 summariseByTopDir,
243 buildSizeReport,
244 median,
245 validEntries,
246};