git-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.tracerc.example +38 -0
  2. package/README.md +136 -0
  3. package/bun.lock +511 -0
  4. package/bunchee.config.ts +11 -0
  5. package/cli/index.ts +251 -0
  6. package/cli/parser.ts +76 -0
  7. package/cli/tsconfig.json +6 -0
  8. package/dist/cli/index.d.ts +1 -0
  9. package/dist/cli/index.js +858 -0
  10. package/dist/config.cjs +66 -0
  11. package/dist/config.d.ts +15 -0
  12. package/dist/config.js +63 -0
  13. package/dist/highlight/index.cjs +770 -0
  14. package/dist/highlight/index.d.ts +26 -0
  15. package/dist/highlight/index.js +766 -0
  16. package/dist/index.cjs +849 -0
  17. package/dist/index.d.ts +52 -0
  18. package/dist/index.js +845 -0
  19. package/examples/demo/App.tsx +78 -0
  20. package/examples/demo/index.html +12 -0
  21. package/examples/demo/main.tsx +10 -0
  22. package/examples/demo/mockData.ts +170 -0
  23. package/examples/demo/styles.css +103 -0
  24. package/examples/demo/tsconfig.json +21 -0
  25. package/examples/demo/tsconfig.node.json +10 -0
  26. package/examples/demo/vite.config.ts +20 -0
  27. package/package.json +58 -0
  28. package/src/Trace.tsx +717 -0
  29. package/src/cache.ts +118 -0
  30. package/src/config.ts +51 -0
  31. package/src/entries/config.ts +7 -0
  32. package/src/entries/gitea.ts +4 -0
  33. package/src/entries/github.ts +5 -0
  34. package/src/entries/gitlab.ts +4 -0
  35. package/src/gitea.ts +58 -0
  36. package/src/github.ts +100 -0
  37. package/src/gitlab.ts +65 -0
  38. package/src/highlight/highlight.ts +119 -0
  39. package/src/highlight/index.ts +4 -0
  40. package/src/host.ts +32 -0
  41. package/src/index.ts +6 -0
  42. package/src/patterns.ts +6 -0
  43. package/src/shared.ts +108 -0
  44. package/src/themes.ts +98 -0
  45. package/src/types.ts +72 -0
  46. package/test/e2e.html +424 -0
  47. package/tsconfig.json +18 -0
  48. package/vercel.json +4 -0
@@ -0,0 +1,858 @@
1
+ #!/usr/bin/env node
2
+ import { exec } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import { rmSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+
8
+ // Default AI detection patterns — browser-safe, no Node.js dependencies
9
+ const DEFAULT_PATTERNS = {
10
+ emails: [
11
+ 'noreply@cursor.sh',
12
+ 'claude@anthropic.com',
13
+ 'bot@github.com',
14
+ 'copilot',
15
+ 'cursor'
16
+ ],
17
+ messages: [
18
+ 'Co-Authored-By: Claude',
19
+ 'Co-Authored-By: Cursor',
20
+ 'Generated-by:',
21
+ '[skip-human-review]',
22
+ 'AI-generated'
23
+ ]
24
+ };
25
+
26
+ // Shared utilities for git adapters — parse diffs, detect AI authors, format dates
27
+ // Parse unified diff format into lines
28
+ function parseDiff(diffText) {
29
+ const lines = [];
30
+ for (const line of diffText.split('\n')){
31
+ // Skip diff headers
32
+ if (line.startsWith('@@')) continue;
33
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
34
+ if (line.startsWith('index') || line.startsWith('diff')) continue;
35
+ if (line.startsWith('new file') || line.startsWith('deleted file')) continue;
36
+ if (line.startsWith('+')) {
37
+ lines.push({
38
+ type: 'add',
39
+ content: line.slice(1)
40
+ });
41
+ } else if (line.startsWith('-')) {
42
+ lines.push({
43
+ type: 'remove',
44
+ content: line.slice(1)
45
+ });
46
+ } else {
47
+ lines.push({
48
+ type: 'ctx',
49
+ content: line.slice(1) || ''
50
+ });
51
+ }
52
+ }
53
+ return lines;
54
+ }
55
+ // Compile string arrays to lowercase Sets for fast O(1) lookup
56
+ function compilePatterns(patterns) {
57
+ return {
58
+ emails: new Set(patterns.emails.map((p)=>p.toLowerCase())),
59
+ messages: new Set(patterns.messages.map((p)=>p.toLowerCase()))
60
+ };
61
+ }
62
+ // Cached compiled defaults
63
+ const CACHED_DEFAULTS = compilePatterns(DEFAULT_PATTERNS);
64
+ // Detect if author is AI based on patterns
65
+ function detectAI(login, email, message, patterns) {
66
+ const compiled = patterns ? compilePatterns(patterns) : CACHED_DEFAULTS;
67
+ const toCheck = [
68
+ login,
69
+ email,
70
+ message
71
+ ].filter(Boolean);
72
+ for (const str of toCheck){
73
+ const lower = str.toLowerCase();
74
+ for (const pattern of compiled.emails){
75
+ if (lower === pattern || lower.includes(pattern)) return 'ai';
76
+ }
77
+ for (const pattern of compiled.messages){
78
+ if (lower.includes(pattern)) return 'ai';
79
+ }
80
+ }
81
+ return 'human';
82
+ }
83
+ // Format date to relative time string
84
+ function formatRelativeTime(dateStr) {
85
+ const date = new Date(dateStr);
86
+ const diff = Date.now() - date.getTime();
87
+ const days = Math.floor(diff / 86400000);
88
+ if (days > 30) {
89
+ return date.toLocaleDateString('en-US', {
90
+ month: 'short',
91
+ day: 'numeric'
92
+ });
93
+ }
94
+ if (days > 0) return `${days}d ago`;
95
+ const hours = Math.floor(diff / 3600000);
96
+ if (hours > 0) return `${hours}h ago`;
97
+ const minutes = Math.floor(diff / 60000);
98
+ if (minutes > 0) return `${minutes}m ago`;
99
+ return 'just now';
100
+ }
101
+ // Extract short hash from full SHA (7 characters)
102
+ function shortHash(sha) {
103
+ return sha.slice(0, 7);
104
+ }
105
+ // Get first line of commit message
106
+ function firstLine(message) {
107
+ return message.split('\n')[0];
108
+ }
109
+
110
+ function asyncGeneratorStep$4(gen, resolve, reject, _next, _throw, key, arg) {
111
+ try {
112
+ var info = gen[key](arg);
113
+ var value = info.value;
114
+ } catch (error) {
115
+ reject(error);
116
+ return;
117
+ }
118
+ if (info.done) {
119
+ resolve(value);
120
+ } else {
121
+ Promise.resolve(value).then(_next, _throw);
122
+ }
123
+ }
124
+ function _async_to_generator$4(fn) {
125
+ return function() {
126
+ var self = this, args = arguments;
127
+ return new Promise(function(resolve, reject) {
128
+ var gen = fn.apply(self, args);
129
+ function _next(value) {
130
+ asyncGeneratorStep$4(gen, resolve, reject, _next, _throw, "next", value);
131
+ }
132
+ function _throw(err) {
133
+ asyncGeneratorStep$4(gen, resolve, reject, _next, _throw, "throw", err);
134
+ }
135
+ _next(undefined);
136
+ });
137
+ };
138
+ }
139
+ const execAsync = promisify(exec);
140
+ /**
141
+ * Validate file path to prevent command injection
142
+ * Rejects paths with shell metacharacters or absolute paths
143
+ */ function validatePath(filePath) {
144
+ // Reject shell metacharacters that could escape git quotes
145
+ const dangerous = /[\n\r`$\\;&|<>]/;
146
+ if (dangerous.test(filePath)) {
147
+ throw new Error(`Invalid file path: contains potentially dangerous characters`);
148
+ }
149
+ // Reject absolute paths (should work relative to cwd)
150
+ if (filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)) {
151
+ throw new Error(`Invalid file path: use relative paths only`);
152
+ }
153
+ }
154
+ function parseGitLog(filePath, last = 10) {
155
+ return _async_to_generator$4(function*() {
156
+ validatePath(filePath);
157
+ const format = '%H|%an|%ae|%s|%cr';
158
+ try {
159
+ const { stdout } = yield execAsync(`git log -${last} --follow -p "--format=${format}" -- "${filePath}"`, {
160
+ cwd: process.cwd(),
161
+ shell: true,
162
+ windowsHide: true
163
+ });
164
+ return parseGitLogOutput(stdout);
165
+ } catch (error) {
166
+ throw new Error(`Failed to parse git log: ${error.message}`);
167
+ }
168
+ })();
169
+ }
170
+ function parseGitLogOutput(output) {
171
+ const commits = [];
172
+ const lines = output.split('\n');
173
+ let currentCommit = null;
174
+ let inDiff = false;
175
+ for (const line of lines){
176
+ if (/^[a-f0-9]{40}\|/.test(line)) {
177
+ if (currentCommit == null ? void 0 : currentCommit.lines) commits.push(currentCommit);
178
+ const [hash, author, email, message, time] = line.split('|');
179
+ currentCommit = {
180
+ hash: hash.slice(0, 7),
181
+ author,
182
+ message,
183
+ authorType: detectAI(undefined, email, message),
184
+ time,
185
+ lines: []
186
+ };
187
+ inDiff = false;
188
+ continue;
189
+ }
190
+ if (line.startsWith('diff --git')) {
191
+ inDiff = true;
192
+ continue;
193
+ }
194
+ if (inDiff && currentCommit) {
195
+ if (line.startsWith('@@') || line.startsWith('index') || line.startsWith('---') || line.startsWith('+++')) {
196
+ continue;
197
+ }
198
+ currentCommit.lines.push(...parseDiff(line));
199
+ }
200
+ }
201
+ if (currentCommit == null ? void 0 : currentCommit.lines) commits.push(currentCommit);
202
+ return commits;
203
+ }
204
+
205
+ // Local cache for GitHub API responses to avoid rate limits
206
+ const CACHE_DIR = join(homedir(), '.trace-cache');
207
+ const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
208
+ ;
209
+ function sanitizePath(path) {
210
+ // Remove ../, ./, and replace slashes with dashes
211
+ return path.replace(/\.\.\//g, '').replace(/\.\//g, '').replace(/[\/\\]/g, '-').replace(/[^a-zA-Z0-9._-]/g, '_');
212
+ }
213
+ function getCacheKey(repo, file, last) {
214
+ const key = `${sanitizePath(repo)}-${sanitizePath(file)}-${last}.json`;
215
+ return join(CACHE_DIR, key);
216
+ }
217
+ function getCached(repo, file, last) {
218
+ const cachePath = getCacheKey(repo, file, last);
219
+ if (!existsSync(cachePath)) {
220
+ return null;
221
+ }
222
+ try {
223
+ const content = readFileSync(cachePath, 'utf-8');
224
+ const entry = JSON.parse(content);
225
+ // Check if cache is expired
226
+ const now = Date.now();
227
+ if (now - entry.timestamp > CACHE_TTL) {
228
+ return null;
229
+ }
230
+ return entry.data;
231
+ } catch (unused) {
232
+ return null;
233
+ }
234
+ }
235
+ function setCached(repo, file, last, data) {
236
+ const cachePath = getCacheKey(repo, file, last);
237
+ try {
238
+ // Ensure cache directory exists
239
+ if (!existsSync(CACHE_DIR)) {
240
+ mkdirSync(CACHE_DIR, {
241
+ recursive: true
242
+ });
243
+ }
244
+ const entry = {
245
+ data,
246
+ timestamp: Date.now(),
247
+ repo,
248
+ file,
249
+ last
250
+ };
251
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2));
252
+ } catch (unused) {
253
+ // Silently fail - caching is optional
254
+ }
255
+ }
256
+ function clearCache() {
257
+ try {
258
+ rmSync(CACHE_DIR, {
259
+ recursive: true,
260
+ force: true
261
+ });
262
+ } catch (unused) {
263
+ // Ignore
264
+ }
265
+ }
266
+ function getCacheInfo() {
267
+ try {
268
+ if (!existsSync(CACHE_DIR)) return [];
269
+ const files = readdirSync(CACHE_DIR);
270
+ const entries = [];
271
+ for (const file of files){
272
+ try {
273
+ const content = readFileSync(join(CACHE_DIR, file), 'utf-8');
274
+ const entry = JSON.parse(content);
275
+ entries.push(entry);
276
+ } catch (unused) {
277
+ // Skip invalid files
278
+ }
279
+ }
280
+ return entries;
281
+ } catch (unused) {
282
+ return [];
283
+ }
284
+ }
285
+
286
+ function _extends() {
287
+ _extends = Object.assign || function(target) {
288
+ for(var i = 1; i < arguments.length; i++){
289
+ var source = arguments[i];
290
+ for(var key in source){
291
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
292
+ target[key] = source[key];
293
+ }
294
+ }
295
+ }
296
+ return target;
297
+ };
298
+ return _extends.apply(this, arguments);
299
+ }
300
+ const DEFAULT = {
301
+ aiPatterns: DEFAULT_PATTERNS,
302
+ last: 10
303
+ };
304
+ let cached = null;
305
+ function loadConfig(cwd) {
306
+ if (cached) return cached;
307
+ const paths = [
308
+ join(homedir(), '.tracerc')
309
+ ];
310
+ for (const path of paths){
311
+ if (existsSync(path)) {
312
+ try {
313
+ const user = JSON.parse(readFileSync(path, 'utf-8'));
314
+ const merged = _extends({}, DEFAULT, user);
315
+ cached = merged;
316
+ return cached;
317
+ } catch (unused) {
318
+ // Invalid config, use defaults
319
+ }
320
+ }
321
+ }
322
+ cached = DEFAULT;
323
+ return cached;
324
+ }
325
+ function getAIPatterns(config) {
326
+ const patterns = (config || loadConfig()).aiPatterns;
327
+ return {
328
+ emails: (patterns == null ? void 0 : patterns.emails) || DEFAULT.aiPatterns.emails,
329
+ messages: (patterns == null ? void 0 : patterns.messages) || DEFAULT.aiPatterns.messages
330
+ };
331
+ }
332
+
333
+ // GitHub API adapter — fetches commits with diffs from repositories
334
+ function asyncGeneratorStep$3(gen, resolve, reject, _next, _throw, key, arg) {
335
+ try {
336
+ var info = gen[key](arg);
337
+ var value = info.value;
338
+ } catch (error) {
339
+ reject(error);
340
+ return;
341
+ }
342
+ if (info.done) {
343
+ resolve(value);
344
+ } else {
345
+ Promise.resolve(value).then(_next, _throw);
346
+ }
347
+ }
348
+ function _async_to_generator$3(fn) {
349
+ return function() {
350
+ var self = this, args = arguments;
351
+ return new Promise(function(resolve, reject) {
352
+ var gen = fn.apply(self, args);
353
+ function _next(value) {
354
+ asyncGeneratorStep$3(gen, resolve, reject, _next, _throw, "next", value);
355
+ }
356
+ function _throw(err) {
357
+ asyncGeneratorStep$3(gen, resolve, reject, _next, _throw, "throw", err);
358
+ }
359
+ _next(undefined);
360
+ });
361
+ };
362
+ }
363
+ const GITHUB_API = 'https://api.github.com';
364
+ function fetchCommits$3(owner, repo, file, last = 10, token, useCache = true, onCacheHit, onCacheWrite) {
365
+ return _async_to_generator$3(function*() {
366
+ const repoKey = `${owner}/${repo}`;
367
+ const config = loadConfig();
368
+ // Check cache
369
+ if (useCache) {
370
+ const cached = getCached(repoKey, file, last);
371
+ if (cached) {
372
+ return cached;
373
+ }
374
+ }
375
+ const headers = {
376
+ 'Accept': 'application/vnd.github.v3+json'
377
+ };
378
+ if (token) headers['Authorization'] = `Bearer ${token}`;
379
+ // Fetch commits list
380
+ const listUrl = `${GITHUB_API}/repos/${owner}/${repo}/commits?path=${encodeURIComponent(file)}&per_page=${last}`;
381
+ const listRes = yield fetch(listUrl, {
382
+ headers
383
+ });
384
+ if (!listRes.ok) {
385
+ if (listRes.status === 403) {
386
+ const reset = listRes.headers.get('X-RateLimit-Reset');
387
+ const resetTime = reset ? new Date(parseInt(reset) * 1000).toLocaleTimeString() : 'unknown';
388
+ throw new Error(`Rate limit exceeded. Resets at ${resetTime}`);
389
+ }
390
+ if (listRes.status === 404) throw new Error(`Not found: ${owner}/${repo}/${file}`);
391
+ throw new Error(`GitHub API error: ${listRes.status}`);
392
+ }
393
+ const commitsList = yield listRes.json();
394
+ // Fetch diffs in parallel
395
+ const results = yield Promise.allSettled(commitsList.map((c)=>_async_to_generator$3(function*() {
396
+ const commitUrl = `${GITHUB_API}/repos/${owner}/${repo}/commits/${c.sha}`;
397
+ const commitRes = yield fetch(commitUrl, {
398
+ headers
399
+ });
400
+ if (!commitRes.ok) return null;
401
+ const detail = yield commitRes.json();
402
+ return parseCommit$2(detail, config);
403
+ })()));
404
+ const commits = results.map((r)=>r.status === 'fulfilled' ? r.value : null).filter((c)=>c !== null);
405
+ // Cache results
406
+ if (useCache && commits.length > 0) {
407
+ setCached(repoKey, file, last, commits);
408
+ }
409
+ return commits;
410
+ })();
411
+ }
412
+ function parseCommit$2(data, config) {
413
+ var _data_author, _data_commit_author, _data_author1, _data_commit_author1;
414
+ const lines = [];
415
+ const aiPatterns = getAIPatterns(config);
416
+ for (const file of data.files || []){
417
+ if (!file.patch) continue;
418
+ lines.push(...parseDiff(file.patch));
419
+ }
420
+ return {
421
+ hash: shortHash(data.sha),
422
+ message: firstLine(data.commit.message),
423
+ author: ((_data_author = data.author) == null ? void 0 : _data_author.login) || ((_data_commit_author = data.commit.author) == null ? void 0 : _data_commit_author.name) || 'Unknown',
424
+ authorType: detectAI((_data_author1 = data.author) == null ? void 0 : _data_author1.login, (_data_commit_author1 = data.commit.author) == null ? void 0 : _data_commit_author1.email, data.commit.message, aiPatterns),
425
+ time: formatRelativeTime(data.commit.author.date),
426
+ lines
427
+ };
428
+ }
429
+
430
+ // GitLab API adapter — fetches commits with diffs from repositories
431
+ function asyncGeneratorStep$2(gen, resolve, reject, _next, _throw, key, arg) {
432
+ try {
433
+ var info = gen[key](arg);
434
+ var value = info.value;
435
+ } catch (error) {
436
+ reject(error);
437
+ return;
438
+ }
439
+ if (info.done) {
440
+ resolve(value);
441
+ } else {
442
+ Promise.resolve(value).then(_next, _throw);
443
+ }
444
+ }
445
+ function _async_to_generator$2(fn) {
446
+ return function() {
447
+ var self = this, args = arguments;
448
+ return new Promise(function(resolve, reject) {
449
+ var gen = fn.apply(self, args);
450
+ function _next(value) {
451
+ asyncGeneratorStep$2(gen, resolve, reject, _next, _throw, "next", value);
452
+ }
453
+ function _throw(err) {
454
+ asyncGeneratorStep$2(gen, resolve, reject, _next, _throw, "throw", err);
455
+ }
456
+ _next(undefined);
457
+ });
458
+ };
459
+ }
460
+ const GITLAB_API = 'https://gitlab.com/api/v4';
461
+ function fetchCommits$2(owner, repo, file, last = 10, token, baseUrl) {
462
+ return _async_to_generator$2(function*() {
463
+ const api = GITLAB_API;
464
+ const projectId = encodeURIComponent(`${owner}/${repo}`);
465
+ const headers = {};
466
+ if (token) headers['PRIVATE-TOKEN'] = token;
467
+ // Fetch commits list
468
+ const listUrl = `${api}/projects/${projectId}/repository/commits?path=${encodeURIComponent(file)}&per_page=${last}`;
469
+ const listRes = yield fetch(listUrl, {
470
+ headers
471
+ });
472
+ if (!listRes.ok) {
473
+ if (listRes.status === 401) throw new Error('GitLab authentication failed');
474
+ if (listRes.status === 404) throw new Error(`Not found: ${owner}/${repo}/${file}`);
475
+ throw new Error(`GitLab API error: ${listRes.status}`);
476
+ }
477
+ const commitsList = yield listRes.json();
478
+ // Fetch diffs in parallel
479
+ const results = yield Promise.allSettled(commitsList.map((c)=>_async_to_generator$2(function*() {
480
+ const diffUrl = `${api}/projects/${projectId}/repository/commits/${c.id}/diff?per_page=100`;
481
+ const diffRes = yield fetch(diffUrl, {
482
+ headers
483
+ });
484
+ if (!diffRes.ok) return null;
485
+ const diff = yield diffRes.json();
486
+ return parseCommit$1(c, diff);
487
+ })()));
488
+ return results.map((r)=>r.status === 'fulfilled' ? r.value : null).filter((c)=>c !== null);
489
+ })();
490
+ }
491
+ function parseCommit$1(data, diffData) {
492
+ const lines = [];
493
+ for (const file of diffData){
494
+ if (!file.diff) continue;
495
+ lines.push(...parseDiff(file.diff));
496
+ }
497
+ return {
498
+ hash: shortHash(data.id),
499
+ message: firstLine(data.title),
500
+ author: data.author_name || 'Unknown',
501
+ authorType: detectAI(data.author_name, data.author_email, data.title),
502
+ time: formatRelativeTime(data.created_at),
503
+ lines
504
+ };
505
+ }
506
+
507
+ // Gitea API adapter — fetches commits with diffs from repositories
508
+ function asyncGeneratorStep$1(gen, resolve, reject, _next, _throw, key, arg) {
509
+ try {
510
+ var info = gen[key](arg);
511
+ var value = info.value;
512
+ } catch (error) {
513
+ reject(error);
514
+ return;
515
+ }
516
+ if (info.done) {
517
+ resolve(value);
518
+ } else {
519
+ Promise.resolve(value).then(_next, _throw);
520
+ }
521
+ }
522
+ function _async_to_generator$1(fn) {
523
+ return function() {
524
+ var self = this, args = arguments;
525
+ return new Promise(function(resolve, reject) {
526
+ var gen = fn.apply(self, args);
527
+ function _next(value) {
528
+ asyncGeneratorStep$1(gen, resolve, reject, _next, _throw, "next", value);
529
+ }
530
+ function _throw(err) {
531
+ asyncGeneratorStep$1(gen, resolve, reject, _next, _throw, "throw", err);
532
+ }
533
+ _next(undefined);
534
+ });
535
+ };
536
+ }
537
+ const GITEA_API = 'https://gitea.com/api/v1';
538
+ function fetchCommits$1(owner, repo, file, last = 10, token, baseUrl) {
539
+ return _async_to_generator$1(function*() {
540
+ const api = GITEA_API;
541
+ const headers = {
542
+ 'Accept': 'application/json'
543
+ };
544
+ if (token) headers['Authorization'] = `token ${token}`;
545
+ // Fetch commits list
546
+ const listUrl = `${api}/repos/${owner}/${repo}/commits?path=${encodeURIComponent(file)}&limit=${last}`;
547
+ const listRes = yield fetch(listUrl, {
548
+ headers
549
+ });
550
+ if (!listRes.ok) {
551
+ if (listRes.status === 401) throw new Error('Gitea authentication failed');
552
+ if (listRes.status === 404) throw new Error(`Not found: ${owner}/${repo}/${file}`);
553
+ throw new Error(`Gitea API error: ${listRes.status}`);
554
+ }
555
+ const commitsList = yield listRes.json();
556
+ // Fetch diffs in parallel
557
+ const results = yield Promise.allSettled(commitsList.map((c)=>_async_to_generator$1(function*() {
558
+ const diffUrl = `${api}/repos/${owner}/${repo}/git/commits/${c.sha}/diff`;
559
+ const diffRes = yield fetch(diffUrl, {
560
+ headers
561
+ });
562
+ if (!diffRes.ok) return null;
563
+ const diffText = yield diffRes.text();
564
+ return parseCommit(c, diffText);
565
+ })()));
566
+ return results.map((r)=>r.status === 'fulfilled' ? r.value : null).filter((c)=>c !== null);
567
+ })();
568
+ }
569
+ function parseCommit(data, diffText) {
570
+ var _data_author, _data_commit_author, _data_author1, _data_commit_author1;
571
+ return {
572
+ hash: shortHash(data.sha),
573
+ message: firstLine(data.commit.message),
574
+ author: ((_data_author = data.author) == null ? void 0 : _data_author.login) || ((_data_commit_author = data.commit.author) == null ? void 0 : _data_commit_author.name) || 'Unknown',
575
+ authorType: detectAI((_data_author1 = data.author) == null ? void 0 : _data_author1.login, (_data_commit_author1 = data.commit.author) == null ? void 0 : _data_commit_author1.email, data.commit.message),
576
+ time: formatRelativeTime(data.commit.author.date),
577
+ lines: parseDiff(diffText)
578
+ };
579
+ }
580
+
581
+ // Git host detection for CLI — auto-detects GitHub, GitLab, Gitea from repo strings
582
+ // Detect host from repo URL or owner/repo string
583
+ function detectHost(input) {
584
+ // GitHub: owner/repo
585
+ const githubMatch = input.match(/^([\w-]+)\/([\w.-]+)$/);
586
+ if (githubMatch) {
587
+ return {
588
+ host: 'github',
589
+ owner: githubMatch[1],
590
+ repo: githubMatch[2]
591
+ };
592
+ }
593
+ // GitLab URL: gitlab.com/owner/repo or gitlab.com/owner/repo.git
594
+ const gitlabMatch = input.match(/gitlab\.com\/([\w-]+)\/([\w.-]+)/);
595
+ if (gitlabMatch) {
596
+ return {
597
+ host: 'gitlab',
598
+ owner: gitlabMatch[1],
599
+ repo: gitlabMatch[2].replace('.git', '')
600
+ };
601
+ }
602
+ // Gitea URL: gitea.com/owner/repo or codeberg.org/owner/repo (runs Gitea)
603
+ const giteaMatch = input.match(/(?:gitea\.com|codeberg\.org|notabug\.org)\/([\w-]+)\/([\w.-]+)/);
604
+ if (giteaMatch) {
605
+ return {
606
+ host: 'gitea',
607
+ owner: giteaMatch[1],
608
+ repo: giteaMatch[2].replace('.git', '')
609
+ };
610
+ }
611
+ return null;
612
+ }
613
+
614
+ function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
615
+ try {
616
+ var info = gen[key](arg);
617
+ var value = info.value;
618
+ } catch (error) {
619
+ reject(error);
620
+ return;
621
+ }
622
+ if (info.done) {
623
+ resolve(value);
624
+ } else {
625
+ Promise.resolve(value).then(_next, _throw);
626
+ }
627
+ }
628
+ function _async_to_generator(fn) {
629
+ return function() {
630
+ var self = this, args = arguments;
631
+ return new Promise(function(resolve, reject) {
632
+ var gen = fn.apply(self, args);
633
+ function _next(value) {
634
+ asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
635
+ }
636
+ function _throw(err) {
637
+ asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
638
+ }
639
+ _next(undefined);
640
+ });
641
+ };
642
+ }
643
+ // Show cache info using getCacheInfo
644
+ function showCacheInfo() {
645
+ const entries = getCacheInfo();
646
+ if (entries.length === 0) {
647
+ console.error('[trace] No cache found');
648
+ return;
649
+ }
650
+ console.error(`[trace] Cache: ${entries.length} entries`);
651
+ for (const e of entries){
652
+ const age = Math.floor((Date.now() - e.timestamp) / 1000 / 60) // minutes
653
+ ;
654
+ console.error(` - ${e.repo}/${e.file} (${e.last} commits, ${age}m old)`);
655
+ }
656
+ }
657
+ const GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
658
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN;
659
+ const GITEA_TOKEN = process.env.GITEA_TOKEN;
660
+ const DEFAULT_LAST = 10;
661
+ const args = process.argv.slice(2);
662
+ function showHelp() {
663
+ console.log(`
664
+ trace v0.4.0 — Git history visualizer
665
+
666
+ Usage:
667
+ trace <file> Local git log
668
+ trace <repo> <file> Remote API (auto-detect host)
669
+ trace cache Show cache
670
+ trace cache clear Clear cache
671
+
672
+ Supported hosts:
673
+ GitHub owner/repo
674
+ GitLab gitlab.com/owner/repo
675
+ Gitea gitea.com/owner/repo, codeberg.org/owner/repo
676
+
677
+ Options:
678
+ --last <n> Commits to show (default: ${DEFAULT_LAST})
679
+ --json Output JSON
680
+ --output <f> Write to file
681
+
682
+ Examples:
683
+ trace src/App.tsx
684
+ trace src/App.tsx --last 5 --json
685
+ trace doanbactam/trace src/Trace.tsx
686
+ trace gitlab.com/gitlab-org/gitlab-shell README.md
687
+ trace codeberg.org/forgejo/forgejo README.md
688
+ trace src/App.tsx --output embed.html
689
+
690
+ Environment:
691
+ GITHUB_TOKEN GitHub token for API
692
+ GITLAB_TOKEN GitLab token for API
693
+ GITEA_TOKEN Gitea token for API
694
+ `);
695
+ }
696
+ function parseArgs() {
697
+ const options = {
698
+ last: String(DEFAULT_LAST),
699
+ json: false,
700
+ output: ''
701
+ };
702
+ for(let i = 0; i < args.length; i++){
703
+ const arg = args[i];
704
+ if (arg === '--help' || arg === '-h') {
705
+ showHelp();
706
+ process.exit(0);
707
+ }
708
+ if (arg.startsWith('--')) {
709
+ const key = arg.slice(2);
710
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
711
+ options[key] = args[i + 1];
712
+ i++;
713
+ } else {
714
+ options[key] = true;
715
+ }
716
+ }
717
+ }
718
+ return options;
719
+ }
720
+ function handleCacheCommand() {
721
+ return _async_to_generator(function*() {
722
+ const cacheArgs = args.slice(1);
723
+ if (cacheArgs.length === 0 || cacheArgs[0] === 'show' || cacheArgs[0] === 'info') {
724
+ showCacheInfo();
725
+ return;
726
+ }
727
+ if (cacheArgs[0] === 'clear') {
728
+ clearCache();
729
+ return;
730
+ }
731
+ console.error('Unknown cache command. Use: cache, cache clear');
732
+ process.exit(1);
733
+ })();
734
+ }
735
+ function main() {
736
+ return _async_to_generator(function*() {
737
+ if (args[0] === 'cache') {
738
+ yield handleCacheCommand();
739
+ return;
740
+ }
741
+ const options = parseArgs();
742
+ // Get positional args (excluding options and their values)
743
+ const positionalArgs = [];
744
+ for(let i = 0; i < args.length; i++){
745
+ const arg = args[i];
746
+ if (arg.startsWith('--')) {
747
+ i++; // skip value
748
+ continue;
749
+ }
750
+ positionalArgs.push(arg);
751
+ }
752
+ // Detect GitHub repo (owner/repo format - only one slash, no file extension like .ts/.js)
753
+ const repoArg = positionalArgs.find((a)=>{
754
+ const parts = a.split('/');
755
+ return parts.length === 2 && !parts[1].includes('.');
756
+ });
757
+ const fileArg = positionalArgs.find((a)=>a !== repoArg);
758
+ if (!fileArg && !repoArg) {
759
+ console.error('Error: Specify a file path or owner/repo');
760
+ showHelp();
761
+ process.exit(1);
762
+ }
763
+ const commits = yield fetchCommits(fileArg, repoArg, options);
764
+ if (options.json) {
765
+ const output = JSON.stringify(commits, null, 2);
766
+ writeOutput(options.output, output);
767
+ } else {
768
+ const html = generateHTML(commits);
769
+ writeOutput(options.output, html);
770
+ }
771
+ })();
772
+ }
773
+ function fetchCommits(fileArg, repoArg, options) {
774
+ return _async_to_generator(function*() {
775
+ const last = parseInt(options.last);
776
+ // Remote mode - auto-detect host
777
+ if (repoArg) {
778
+ const detected = detectHost(repoArg);
779
+ if (detected) {
780
+ const { host, owner, repo } = detected;
781
+ console.error(`[trace] Fetching from ${host}: ${owner}/${repo}/${fileArg}`);
782
+ switch(host){
783
+ case 'github':
784
+ return yield fetchCommits$3(owner, repo, fileArg, last, GITHUB_TOKEN);
785
+ case 'gitlab':
786
+ return yield fetchCommits$2(owner, repo, fileArg, last, GITLAB_TOKEN);
787
+ case 'gitea':
788
+ return yield fetchCommits$1(owner, repo, fileArg, last, GITEA_TOKEN);
789
+ }
790
+ }
791
+ // Fallback to GitHub for owner/repo format
792
+ const [owner, repo] = repoArg.split('/');
793
+ console.error(`[trace] Fetching from GitHub: ${owner}/${repo}/${fileArg}`);
794
+ return yield fetchCommits$3(owner, repo, fileArg, last, GITHUB_TOKEN);
795
+ }
796
+ // Local git mode
797
+ console.error(`[trace] Parsing local git: ${fileArg}`);
798
+ return yield parseGitLog(fileArg, last);
799
+ })();
800
+ }
801
+ function writeOutput(path, content) {
802
+ if (path) {
803
+ writeFileSync(path, content);
804
+ console.error(`[trace] Written to ${path}`);
805
+ } else {
806
+ console.log(content);
807
+ }
808
+ }
809
+ function generateHTML(commits) {
810
+ var _commits_;
811
+ return `<!DOCTYPE html>
812
+ <html lang="en">
813
+ <head>
814
+ <meta charset="UTF-8">
815
+ <title>Trace — ${((_commits_ = commits[0]) == null ? void 0 : _commits_.message.split(' ')[0]) || 'Git History'}</title>
816
+ <style>
817
+ * { box-sizing: border-box; margin: 0; padding: 0; }
818
+ body { font-family: 'JetBrains Mono', ui-monospace, monospace; background: #07090f; color: #e2e8f0; padding: 40px; min-height: 100vh; }
819
+ .trace-root { max-width: 1200px; margin: 0 auto; border-radius: 12px; overflow: hidden; }
820
+ pre { background: #0d1117; padding: 16px; border-radius: 8px; overflow-x: auto; }
821
+ </style>
822
+ </head>
823
+ <body>
824
+ <div class="trace-root" id="t"></div>
825
+ <script>
826
+ const commits = ${JSON.stringify(commits)};
827
+ let i = 0;
828
+ function render() {
829
+ const c = commits[i];
830
+ document.getElementById('t').innerHTML = \`
831
+ <div style="display:flex;height:600px;border:1px solid #1e293b;border-radius:8px">
832
+ <div style="width:250px;border-right:1px solid #1e293b;padding:16px;overflow-y:auto">
833
+ \${commits.map((c,j)=\`<div onclick="i=\${j}" style="padding:12px;cursor:pointer;border-left:2px solid transparent;\${i===j?'background:rgba(255,255,255,0.05);border-left-color:'+(c.authorType==='ai'?'#a78bfa':'#34d399'):'')">
834
+ <div style="display:flex;align-items:center;margin-bottom:4px">
835
+ <span style="width:8px;height:8px;border-radius:50%;background:\${c.authorType==='ai'?'#a78bfa':'#34d399'};margin-right:8px"></span>
836
+ <span style="font-size:13px">\${c.message.slice(0,30)}\${c.message.length>30?'...':''}</span>
837
+ </div>
838
+ <div style="font-size:11px;opacity:0.6">\${c.author} · \${c.time}</div>
839
+ <span style="font-size:9px;padding:2px 6px;border-radius:4px;background:\${c.authorType==='ai'?'rgba(167,139,250,0.15)':'rgba(52,211,153,0.15)'};color:\${c.authorType==='ai'?'#a78bfa':'#34d399'}">\${c.authorType}</span>
840
+ </div>\`).join('')}
841
+ </div>
842
+ <div style="flex:1;padding:16px;overflow-y:auto">
843
+ <h2 style="font-size:16px;margin:0 0 4px">\${c.message}</h2>
844
+ <div style="font-size:12px;opacity:0.6;margin-bottom:16px">\${c.hash} · \${c.author}</div>
845
+ <pre>\${c.lines.map(l=>\`<div style="\${l.type==='add'?'color:#34d399':l.type==='remove'?'color:#f87171':'opacity:0.5'}">\${l.content||' '}</div>\`).join('')}</pre>
846
+ </div>
847
+ </div>
848
+ \`;
849
+ }
850
+ render();
851
+ </script>
852
+ </body>
853
+ </html>`;
854
+ }
855
+ main().catch((err)=>{
856
+ console.error(`[trace] Error: ${err.message}`);
857
+ process.exit(1);
858
+ });