CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
hot-files.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.
| 74d8c4d | 1 | /** |
| 2 | * Hot Files analysis — git log --numstat based file churn. | |
| 3 | * | |
| 4 | * Spawns git to count how frequently each file has been modified within | |
| 5 | * a sliding time window. Returns the top 50 files ranked by churn | |
| 6 | * (total lines added + deleted), annotated with a risk level. | |
| 7 | */ | |
| 8 | ||
| 9 | import { join } from "path"; | |
| 10 | ||
| 11 | // ─── Public types ───────────────────────────────────────────────────────────── | |
| 12 | ||
| 13 | export interface HotFile { | |
| 14 | /** Repo-relative path */ | |
| 15 | path: string; | |
| 16 | /** Number of commits in which this file appears */ | |
| 17 | changes: number; | |
| 18 | /** Total lines added across all commits */ | |
| 19 | added: number; | |
| 20 | /** Total lines deleted across all commits */ | |
| 21 | deleted: number; | |
| 22 | /** added + deleted */ | |
| 23 | churn: number; | |
| 24 | /** Computed risk tier */ | |
| 25 | riskLevel: "high" | "medium" | "low"; | |
| 26 | /** File extension without leading dot (e.g. "ts", "py") */ | |
| 27 | ext: string; | |
| 28 | } | |
| 29 | ||
| 30 | // ─── Risk heuristic ─────────────────────────────────────────────────────────── | |
| 31 | ||
| 32 | const HIGH_RISK_PATTERNS = [ | |
| 33 | "auth", | |
| 34 | "security", | |
| 35 | "schema", | |
| 36 | "db/", | |
| 37 | "middleware", | |
| 38 | "routes/git", | |
| 39 | "crypto", | |
| 40 | ]; | |
| 41 | ||
| 42 | const MEDIUM_RISK_PATTERNS = ["route", "api", "lib/", ".sql"]; | |
| 43 | ||
| 44 | function classifyRisk(filePath: string): "high" | "medium" | "low" { | |
| 45 | const lower = filePath.toLowerCase(); | |
| 46 | if (HIGH_RISK_PATTERNS.some((p) => lower.includes(p))) return "high"; | |
| 47 | if (MEDIUM_RISK_PATTERNS.some((p) => lower.includes(p))) return "medium"; | |
| 48 | return "low"; | |
| 49 | } | |
| 50 | ||
| 51 | function extractExt(filePath: string): string { | |
| 52 | const dot = filePath.lastIndexOf("."); | |
| 53 | if (dot === -1 || dot === filePath.length - 1) return ""; | |
| 54 | return filePath.slice(dot + 1); | |
| 55 | } | |
| 56 | ||
| 57 | // ─── Core function ──────────────────────────────────────────────────────────── | |
| 58 | ||
| 59 | /** | |
| 60 | * Returns the top ≤50 most-churned files in the repo within the last | |
| 61 | * `windowDays` days, ordered by churn (lines added + deleted) descending. | |
| 62 | * | |
| 63 | * The result is empty when the git repo path does not exist, git fails, | |
| 64 | * or there are no commits in the window. | |
| 65 | */ | |
| 66 | export async function getHotFiles( | |
| 67 | ownerName: string, | |
| 68 | repoName: string, | |
| 69 | windowDays: number | |
| 70 | ): Promise<HotFile[]> { | |
| 71 | const repoBase = process.env.GIT_REPOS_PATH || "./repos"; | |
| 72 | const diskPath = join(repoBase, `${ownerName}/${repoName}.git`); | |
| 73 | ||
| 74 | // ─── Spawn git ────────────────────────────────────────────────────────── | |
| 75 | ||
| 76 | let raw = ""; | |
| 77 | try { | |
| 78 | const proc = Bun.spawn( | |
| 79 | [ | |
| 80 | "git", | |
| 81 | "--git-dir", | |
| 82 | diskPath, | |
| 83 | "log", | |
| 84 | "--numstat", | |
| 85 | `--since=${windowDays}.days.ago`, | |
| 86 | "--format=", | |
| 87 | ], | |
| 88 | { stdout: "pipe", stderr: "pipe" } | |
| 89 | ); | |
| 90 | raw = await new Response(proc.stdout as ReadableStream).text(); | |
| 91 | await proc.exited; | |
| 92 | } catch { | |
| 93 | return []; | |
| 94 | } | |
| 95 | ||
| 96 | if (!raw.trim()) return []; | |
| 97 | ||
| 98 | // ─── Parse --numstat lines ─────────────────────────────────────────────── | |
| 99 | // | |
| 100 | // git --numstat emits lines of the form: | |
| 101 | // <added>\t<deleted>\t<path> | |
| 102 | // | |
| 103 | // When --format= is used the commit header lines are blank, so we only | |
| 104 | // see the numstat data lines (non-blank lines starting with a digit or "-"). | |
| 105 | // Binary files show "-\t-\t<path>"; we treat those as 0/0. | |
| 106 | ||
| 107 | /** Per-file aggregation keyed by file path. */ | |
| 108 | const fileMap = new Map< | |
| 109 | string, | |
| 110 | { changes: number; added: number; deleted: number } | |
| 111 | >(); | |
| 112 | ||
| 113 | for (const line of raw.split("\n")) { | |
| 114 | const trimmed = line.trim(); | |
| 115 | if (!trimmed) continue; | |
| 116 | ||
| 117 | const parts = trimmed.split("\t"); | |
| 118 | if (parts.length < 3) continue; | |
| 119 | ||
| 120 | const [rawAdded, rawDeleted, ...pathParts] = parts; | |
| 121 | const filePath = pathParts.join("\t"); // guard against tabs in filenames | |
| 122 | if (!filePath) continue; | |
| 123 | ||
| 124 | const added = rawAdded === "-" ? 0 : parseInt(rawAdded, 10); | |
| 125 | const deleted = rawDeleted === "-" ? 0 : parseInt(rawDeleted, 10); | |
| 126 | ||
| 127 | if (isNaN(added) || isNaN(deleted)) continue; | |
| 128 | ||
| 129 | const existing = fileMap.get(filePath); | |
| 130 | if (existing) { | |
| 131 | existing.changes += 1; | |
| 132 | existing.added += added; | |
| 133 | existing.deleted += deleted; | |
| 134 | } else { | |
| 135 | fileMap.set(filePath, { changes: 1, added, deleted }); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | if (fileMap.size === 0) return []; | |
| 140 | ||
| 141 | // ─── Sort and cap ──────────────────────────────────────────────────────── | |
| 142 | ||
| 143 | const results: HotFile[] = []; | |
| 144 | for (const [path, agg] of fileMap) { | |
| 145 | const churn = agg.added + agg.deleted; | |
| 146 | results.push({ | |
| 147 | path, | |
| 148 | changes: agg.changes, | |
| 149 | added: agg.added, | |
| 150 | deleted: agg.deleted, | |
| 151 | churn, | |
| 152 | riskLevel: classifyRisk(path), | |
| 153 | ext: extractExt(path), | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | results.sort((a, b) => b.churn - a.churn); | |
| 158 | ||
| 159 | return results.slice(0, 50); | |
| 160 | } |