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.
| ce08e97 | 1 | /** |
| 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 | */ | |
| 12 | export interface RepoSizeEntry { | |
| 13 | path: string; | |
| 14 | size: number; | |
| 15 | } | |
| 16 | ||
| 17 | export const DEFAULT_TOP_N = 25; | |
| 18 | ||
| 19 | /** Five size classes, ranked tiny → xlarge. Boundaries are inclusive-below. */ | |
| 20 | export 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 | ||
| 28 | export type SizeClassKey = (typeof SIZE_CLASSES)[number]["key"]; | |
| 29 | ||
| 30 | export 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 | ||
| 40 | export interface SizeBucket { | |
| 41 | key: SizeClassKey; | |
| 42 | label: string; | |
| 43 | fileCount: number; | |
| 44 | bytes: number; | |
| 45 | } | |
| 46 | ||
| 47 | export 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 | ||
| 55 | export interface LargestFile { | |
| 56 | path: string; | |
| 57 | size: number; | |
| 58 | percent: number; | |
| 59 | /** First path segment (or "." for root files). */ | |
| 60 | topDir: string; | |
| 61 | } | |
| 62 | ||
| 63 | export 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` → `.`). */ | |
| 71 | export 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. */ | |
| 79 | function 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 | ||
| 92 | function 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 | ||
| 101 | export 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 | ||
| 117 | export 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 | ||
| 126 | export 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 | ||
| 145 | export 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 | ||
| 152 | export 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 | ||
| 178 | export 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 | ||
| 214 | export interface BuildSizeReportOptions { | |
| 215 | entries: readonly RepoSizeEntry[]; | |
| 216 | topN?: number; | |
| 217 | minBytesForLargest?: number; | |
| 218 | } | |
| 219 | ||
| 220 | export 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 | ||
| 234 | export 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 | }; |