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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
hot-files.ts5.1 KB · 160 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/**
 * Hot Files analysis — git log --numstat based file churn.
 *
 * Spawns git to count how frequently each file has been modified within
 * a sliding time window.  Returns the top 50 files ranked by churn
 * (total lines added + deleted), annotated with a risk level.
 */

import { join } from "path";

// ─── Public types ─────────────────────────────────────────────────────────────

export interface HotFile {
  /** Repo-relative path */
  path: string;
  /** Number of commits in which this file appears */
  changes: number;
  /** Total lines added across all commits */
  added: number;
  /** Total lines deleted across all commits */
  deleted: number;
  /** added + deleted */
  churn: number;
  /** Computed risk tier */
  riskLevel: "high" | "medium" | "low";
  /** File extension without leading dot (e.g. "ts", "py") */
  ext: string;
}

// ─── Risk heuristic ───────────────────────────────────────────────────────────

const HIGH_RISK_PATTERNS = [
  "auth",
  "security",
  "schema",
  "db/",
  "middleware",
  "routes/git",
  "crypto",
];

const MEDIUM_RISK_PATTERNS = ["route", "api", "lib/", ".sql"];

function classifyRisk(filePath: string): "high" | "medium" | "low" {
  const lower = filePath.toLowerCase();
  if (HIGH_RISK_PATTERNS.some((p) => lower.includes(p))) return "high";
  if (MEDIUM_RISK_PATTERNS.some((p) => lower.includes(p))) return "medium";
  return "low";
}

function extractExt(filePath: string): string {
  const dot = filePath.lastIndexOf(".");
  if (dot === -1 || dot === filePath.length - 1) return "";
  return filePath.slice(dot + 1);
}

// ─── Core function ────────────────────────────────────────────────────────────

/**
 * Returns the top ≤50 most-churned files in the repo within the last
 * `windowDays` days, ordered by churn (lines added + deleted) descending.
 *
 * The result is empty when the git repo path does not exist, git fails,
 * or there are no commits in the window.
 */
export async function getHotFiles(
  ownerName: string,
  repoName: string,
  windowDays: number
): Promise<HotFile[]> {
  const repoBase = process.env.GIT_REPOS_PATH || "./repos";
  const diskPath = join(repoBase, `${ownerName}/${repoName}.git`);

  // ─── Spawn git ──────────────────────────────────────────────────────────

  let raw = "";
  try {
    const proc = Bun.spawn(
      [
        "git",
        "--git-dir",
        diskPath,
        "log",
        "--numstat",
        `--since=${windowDays}.days.ago`,
        "--format=",
      ],
      { stdout: "pipe", stderr: "pipe" }
    );
    raw = await new Response(proc.stdout as ReadableStream).text();
    await proc.exited;
  } catch {
    return [];
  }

  if (!raw.trim()) return [];

  // ─── Parse --numstat lines ───────────────────────────────────────────────
  //
  // git --numstat emits lines of the form:
  //   <added>\t<deleted>\t<path>
  //
  // When --format= is used the commit header lines are blank, so we only
  // see the numstat data lines (non-blank lines starting with a digit or "-").
  // Binary files show "-\t-\t<path>"; we treat those as 0/0.

  /** Per-file aggregation keyed by file path. */
  const fileMap = new Map<
    string,
    { changes: number; added: number; deleted: number }
  >();

  for (const line of raw.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed) continue;

    const parts = trimmed.split("\t");
    if (parts.length < 3) continue;

    const [rawAdded, rawDeleted, ...pathParts] = parts;
    const filePath = pathParts.join("\t"); // guard against tabs in filenames
    if (!filePath) continue;

    const added = rawAdded === "-" ? 0 : parseInt(rawAdded, 10);
    const deleted = rawDeleted === "-" ? 0 : parseInt(rawDeleted, 10);

    if (isNaN(added) || isNaN(deleted)) continue;

    const existing = fileMap.get(filePath);
    if (existing) {
      existing.changes += 1;
      existing.added += added;
      existing.deleted += deleted;
    } else {
      fileMap.set(filePath, { changes: 1, added, deleted });
    }
  }

  if (fileMap.size === 0) return [];

  // ─── Sort and cap ────────────────────────────────────────────────────────

  const results: HotFile[] = [];
  for (const [path, agg] of fileMap) {
    const churn = agg.added + agg.deleted;
    results.push({
      path,
      changes: agg.changes,
      added: agg.added,
      deleted: agg.deleted,
      churn,
      riskLevel: classifyRisk(path),
      ext: extractExt(path),
    });
  }

  results.sort((a, b) => b.churn - a.churn);

  return results.slice(0, 50);
}