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

timetravel.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.

timetravel.tsBlame532 lines · 1 contributor
16b325cClaude1/**
2 * Time-Travel Code Explorer
3 *
4 * GitHub shows you blame (who changed each line).
5 * gluecron shows you the STORY of your code:
6 * - How did this function evolve?
7 * - When was this behavior introduced?
8 * - What was the context of each change?
9 *
10 * This answers the question developers actually ask:
11 * "WHY is this code like this?"
12 */
13
14import { getRepoPath, getDefaultBranch } from "../git/repository";
15
16export interface FileTimeline {
17 path: string;
18 totalRevisions: number;
19 firstSeen: { sha: string; date: string; author: string; message: string };
20 lastModified: { sha: string; date: string; author: string; message: string };
21 revisions: FileRevision[];
22}
23
24export interface FileRevision {
25 sha: string;
26 date: string;
27 author: string;
28 message: string;
29 linesAdded: number;
30 linesRemoved: number;
31 sizeAfter: number;
32}
33
34export interface FunctionTimeline {
35 name: string;
36 file: string;
37 firstSeen: { sha: string; date: string; author: string };
38 revisions: FunctionRevision[];
39 currentSignature: string;
40}
41
42export interface FunctionRevision {
43 sha: string;
44 date: string;
45 author: string;
46 message: string;
47 changeType: "created" | "modified" | "renamed" | "signature-changed";
48 snippet: string;
49}
50
51async function exec(
52 cmd: string[],
53 cwd: string
54): Promise<{ stdout: string; exitCode: number }> {
55 const proc = Bun.spawn(cmd, {
56 cwd,
57 stdout: "pipe",
58 stderr: "pipe",
59 });
60 const stdout = await new Response(proc.stdout).text();
61 const exitCode = await proc.exited;
62 return { stdout: stdout.trim(), exitCode };
63}
64
65/**
66 * Get the complete evolution history of a file.
67 * Every commit that touched it, with stats.
68 */
69export async function getFileTimeline(
70 owner: string,
71 repo: string,
72 ref: string,
73 filePath: string
74): Promise<FileTimeline | null> {
75 const repoDir = getRepoPath(owner, repo);
76
77 // Get all commits that touched this file
78 const { stdout, exitCode } = await exec(
79 [
80 "git",
81 "log",
82 "--follow",
83 "--format=%H%x00%aI%x00%an%x00%s",
84 "--numstat",
85 ref,
86 "--",
87 filePath,
88 ],
89 repoDir
90 );
91
92 if (exitCode !== 0 || !stdout) return null;
93
94 const revisions: FileRevision[] = [];
95 const lines = stdout.split("\n");
96 let i = 0;
97
98 while (i < lines.length) {
99 const line = lines[i];
100 if (!line) {
101 i++;
102 continue;
103 }
104
105 const parts = line.split("\0");
106 if (parts.length < 4) {
107 i++;
108 continue;
109 }
110
111 const [sha, date, author, message] = parts;
112 let linesAdded = 0;
113 let linesRemoved = 0;
114 i++;
115
116 // Read numstat line (may be on next non-empty line)
117 while (i < lines.length && lines[i] === "") i++;
118 if (i < lines.length) {
119 const statLine = lines[i];
120 const statMatch = statLine.match(/^(\d+|-)\t(\d+|-)\t/);
121 if (statMatch) {
122 linesAdded = statMatch[1] === "-" ? 0 : parseInt(statMatch[1], 10);
123 linesRemoved = statMatch[2] === "-" ? 0 : parseInt(statMatch[2], 10);
124 i++;
125 }
126 }
127
128 // Get file size at this commit
129 const { stdout: sizeStr } = await exec(
130 ["git", "cat-file", "-s", `${sha}:${filePath}`],
131 repoDir
132 );
133 const sizeAfter = parseInt(sizeStr, 10) || 0;
134
135 revisions.push({
136 sha,
137 date,
138 author,
139 message,
140 linesAdded,
141 linesRemoved,
142 sizeAfter,
143 });
144 }
145
146 if (revisions.length === 0) return null;
147
148 return {
149 path: filePath,
150 totalRevisions: revisions.length,
151 firstSeen: {
152 sha: revisions[revisions.length - 1].sha,
153 date: revisions[revisions.length - 1].date,
154 author: revisions[revisions.length - 1].author,
155 message: revisions[revisions.length - 1].message,
156 },
157 lastModified: {
158 sha: revisions[0].sha,
159 date: revisions[0].date,
160 author: revisions[0].author,
161 message: revisions[0].message,
162 },
163 revisions,
164 };
165}
166
167/**
168 * Track the evolution of a specific function/symbol in a file.
169 * Uses git log -L to trace function history.
170 */
171export async function getFunctionTimeline(
172 owner: string,
173 repo: string,
174 ref: string,
175 filePath: string,
176 functionName: string
177): Promise<FunctionTimeline | null> {
178 const repoDir = getRepoPath(owner, repo);
179
180 // Use git log -L to trace function evolution
181 // -L :functionName:filePath traces the function
182 const { stdout, exitCode } = await exec(
183 [
184 "git",
185 "log",
186 `-L:${functionName}:${filePath}`,
187 "--format=%H%x00%aI%x00%an%x00%s",
188 "--no-patch",
189 ref,
190 ],
191 repoDir
192 );
193
194 if (exitCode !== 0 || !stdout) {
195 // Fallback: search for the function name in git log
196 return getFunctionTimelineFallback(
197 owner,
198 repo,
199 ref,
200 filePath,
201 functionName
202 );
203 }
204
205 const revisions: FunctionRevision[] = [];
206 for (const line of stdout.split("\n").filter(Boolean)) {
207 const parts = line.split("\0");
208 if (parts.length < 4) continue;
209 const [sha, date, author, message] = parts;
210
211 // Get the function snippet at this commit
212 const { stdout: content } = await exec(
213 ["git", "show", `${sha}:${filePath}`],
214 repoDir
215 );
216 const snippet = extractFunctionSnippet(content, functionName);
217
218 revisions.push({
219 sha,
220 date,
221 author,
222 message,
223 changeType:
224 revisions.length === 0 ? "created" : "modified",
225 snippet: snippet.slice(0, 500),
226 });
227 }
228
229 if (revisions.length === 0) return null;
230
231 // Get current signature
232 const { stdout: currentContent } = await exec(
233 ["git", "show", `${ref}:${filePath}`],
234 repoDir
235 );
236 const currentSignature = extractFunctionSignature(
237 currentContent,
238 functionName
239 );
240
241 return {
242 name: functionName,
243 file: filePath,
244 firstSeen: {
245 sha: revisions[revisions.length - 1].sha,
246 date: revisions[revisions.length - 1].date,
247 author: revisions[revisions.length - 1].author,
248 },
249 revisions,
250 currentSignature,
251 };
252}
253
254async function getFunctionTimelineFallback(
255 owner: string,
256 repo: string,
257 ref: string,
258 filePath: string,
259 functionName: string
260): Promise<FunctionTimeline | null> {
261 const repoDir = getRepoPath(owner, repo);
262
263 // Get commits where this function name appears in the diff
264 const { stdout } = await exec(
265 [
266 "git",
267 "log",
268 "--format=%H%x00%aI%x00%an%x00%s",
269 `-S${functionName}`,
270 ref,
271 "--",
272 filePath,
273 ],
274 repoDir
275 );
276
277 if (!stdout) return null;
278
279 const revisions: FunctionRevision[] = [];
280 for (const line of stdout.split("\n").filter(Boolean)) {
281 const parts = line.split("\0");
282 if (parts.length < 4) continue;
283 const [sha, date, author, message] = parts;
284
285 revisions.push({
286 sha,
287 date,
288 author,
289 message,
290 changeType: revisions.length === 0 ? "created" : "modified",
291 snippet: "",
292 });
293 }
294
295 if (revisions.length === 0) return null;
296
297 const { stdout: currentContent } = await exec(
298 ["git", "show", `${ref}:${filePath}`],
299 repoDir
300 );
301
302 return {
303 name: functionName,
304 file: filePath,
305 firstSeen: {
306 sha: revisions[revisions.length - 1].sha,
307 date: revisions[revisions.length - 1].date,
308 author: revisions[revisions.length - 1].author,
309 },
310 revisions,
311 currentSignature: extractFunctionSignature(currentContent, functionName),
312 };
313}
314
315/**
316 * Detect hotspots — files that change together frequently.
317 * If file A and file B always change in the same commit,
318 * they're coupled. This catches architectural issues.
319 */
320export async function detectCoupledFiles(
321 owner: string,
322 repo: string,
323 ref: string,
324 limit = 20
325): Promise<Array<{ files: [string, string]; cochanges: number; percentage: number }>> {
326 const repoDir = getRepoPath(owner, repo);
327
328 // Get recent commits with their changed files
329 const { stdout } = await exec(
330 [
331 "git",
332 "log",
333 "--format=%H",
334 "--name-only",
335 "-100",
336 ref,
337 ],
338 repoDir
339 );
340
341 const commits: string[][] = [];
342 let current: string[] = [];
343
344 for (const line of stdout.split("\n")) {
345 if (line.match(/^[0-9a-f]{40}$/)) {
346 if (current.length > 0) commits.push(current);
347 current = [];
348 } else if (line.trim()) {
349 current.push(line.trim());
350 }
351 }
352 if (current.length > 0) commits.push(current);
353
354 // Count co-changes
355 const pairCounts: Record<string, number> = {};
356 const fileCounts: Record<string, number> = {};
357
358 for (const files of commits) {
359 for (const f of files) {
360 fileCounts[f] = (fileCounts[f] || 0) + 1;
361 }
362 // Count pairs
363 for (let i = 0; i < files.length; i++) {
364 for (let j = i + 1; j < files.length; j++) {
365 const pair = [files[i], files[j]].sort().join("|||");
366 pairCounts[pair] = (pairCounts[pair] || 0) + 1;
367 }
368 }
369 }
370
371 return Object.entries(pairCounts)
372 .filter(([, count]) => count >= 3) // At least 3 co-changes
373 .sort((a, b) => b[1] - a[1])
374 .slice(0, limit)
375 .map(([pair, count]) => {
376 const [f1, f2] = pair.split("|||");
377 const maxChanges = Math.max(fileCounts[f1] || 0, fileCounts[f2] || 0);
378 return {
379 files: [f1, f2] as [string, string],
380 cochanges: count,
381 percentage: maxChanges > 0 ? Math.round((count / maxChanges) * 100) : 0,
382 };
383 });
384}
385
386/**
387 * Get the "story" of a repository — key milestones,
388 * major changes, turning points.
389 */
390export async function getRepoStory(
391 owner: string,
392 repo: string,
393 ref: string
394): Promise<Array<{
395 sha: string;
396 date: string;
397 author: string;
398 message: string;
399 significance: "milestone" | "major" | "normal";
400 stats: { files: number; additions: number; deletions: number };
401}>> {
402 const repoDir = getRepoPath(owner, repo);
403
404 const { stdout } = await exec(
405 [
406 "git",
407 "log",
408 "--format=%H%x00%aI%x00%an%x00%s",
409 "--shortstat",
410 ref,
411 ],
412 repoDir
413 );
414
415 const entries: Array<{
416 sha: string;
417 date: string;
418 author: string;
419 message: string;
420 significance: "milestone" | "major" | "normal";
421 stats: { files: number; additions: number; deletions: number };
422 }> = [];
423
424 const lines = stdout.split("\n");
425 let i = 0;
426
427 while (i < lines.length) {
428 const line = lines[i];
429 if (!line) {
430 i++;
431 continue;
432 }
433
434 const parts = line.split("\0");
435 if (parts.length < 4) {
436 i++;
437 continue;
438 }
439
440 const [sha, date, author, message] = parts;
441 let files = 0;
442 let additions = 0;
443 let deletions = 0;
444 i++;
445
446 // Read stat line
447 while (i < lines.length && lines[i] === "") i++;
448 if (i < lines.length) {
449 const statMatch = lines[i].match(
450 /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/
451 );
452 if (statMatch) {
453 files = parseInt(statMatch[1], 10) || 0;
454 additions = parseInt(statMatch[2], 10) || 0;
455 deletions = parseInt(statMatch[3], 10) || 0;
456 i++;
457 }
458 }
459
460 // Determine significance
461 let significance: "milestone" | "major" | "normal" = "normal";
462 const lowerMsg = message.toLowerCase();
463
464 if (
465 lowerMsg.includes("v1") ||
466 lowerMsg.includes("v2") ||
467 lowerMsg.includes("release") ||
468 lowerMsg.includes("launch") ||
469 lowerMsg.includes("initial commit") ||
470 lowerMsg.match(/v\d+\.\d+/)
471 ) {
472 significance = "milestone";
473 } else if (
474 files > 20 ||
475 additions + deletions > 1000 ||
476 lowerMsg.includes("refactor") ||
477 lowerMsg.includes("breaking") ||
478 lowerMsg.includes("migration") ||
479 lowerMsg.includes("major")
480 ) {
481 significance = "major";
482 }
483
484 entries.push({
485 sha,
486 date,
487 author,
488 message,
489 significance,
490 stats: { files, additions, deletions },
491 });
492 }
493
494 return entries;
495}
496
497// ─── Helpers ─────────────────────────────────────────────────
498
499function extractFunctionSnippet(
500 content: string,
501 functionName: string
502): string {
503 const lines = content.split("\n");
504 const regex = new RegExp(
505 `(?:export\\s+)?(?:async\\s+)?(?:function\\s+${functionName}|const\\s+${functionName}\\s*=|${functionName}\\s*[:(])`,
506 );
507
508 for (let i = 0; i < lines.length; i++) {
509 if (regex.test(lines[i])) {
510 // Get function body (up to 20 lines)
511 return lines.slice(i, i + 20).join("\n");
512 }
513 }
514 return "";
515}
516
517function extractFunctionSignature(
518 content: string,
519 functionName: string
520): string {
521 const lines = content.split("\n");
522 const regex = new RegExp(
523 `(?:export\\s+)?(?:async\\s+)?(?:function\\s+${functionName}|const\\s+${functionName}\\s*=)`,
524 );
525
526 for (const line of lines) {
527 if (regex.test(line)) {
528 return line.trim();
529 }
530 }
531 return `${functionName}(...)`;
532}