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

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.

hot-files.tsBlame160 lines · 1 contributor
74d8c4dClaude1/**
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
9import { join } from "path";
10
11// ─── Public types ─────────────────────────────────────────────────────────────
12
13export 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
32const HIGH_RISK_PATTERNS = [
33 "auth",
34 "security",
35 "schema",
36 "db/",
37 "middleware",
38 "routes/git",
39 "crypto",
40];
41
42const MEDIUM_RISK_PATTERNS = ["route", "api", "lib/", ".sql"];
43
44function 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
51function 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 */
66export 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}