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.
- package/.tracerc.example +38 -0
- package/README.md +136 -0
- package/bun.lock +511 -0
- package/bunchee.config.ts +11 -0
- package/cli/index.ts +251 -0
- package/cli/parser.ts +76 -0
- package/cli/tsconfig.json +6 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +858 -0
- package/dist/config.cjs +66 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +63 -0
- package/dist/highlight/index.cjs +770 -0
- package/dist/highlight/index.d.ts +26 -0
- package/dist/highlight/index.js +766 -0
- package/dist/index.cjs +849 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +845 -0
- package/examples/demo/App.tsx +78 -0
- package/examples/demo/index.html +12 -0
- package/examples/demo/main.tsx +10 -0
- package/examples/demo/mockData.ts +170 -0
- package/examples/demo/styles.css +103 -0
- package/examples/demo/tsconfig.json +21 -0
- package/examples/demo/tsconfig.node.json +10 -0
- package/examples/demo/vite.config.ts +20 -0
- package/package.json +58 -0
- package/src/Trace.tsx +717 -0
- package/src/cache.ts +118 -0
- package/src/config.ts +51 -0
- package/src/entries/config.ts +7 -0
- package/src/entries/gitea.ts +4 -0
- package/src/entries/github.ts +5 -0
- package/src/entries/gitlab.ts +4 -0
- package/src/gitea.ts +58 -0
- package/src/github.ts +100 -0
- package/src/gitlab.ts +65 -0
- package/src/highlight/highlight.ts +119 -0
- package/src/highlight/index.ts +4 -0
- package/src/host.ts +32 -0
- package/src/index.ts +6 -0
- package/src/patterns.ts +6 -0
- package/src/shared.ts +108 -0
- package/src/themes.ts +98 -0
- package/src/types.ts +72 -0
- package/test/e2e.html +424 -0
- package/tsconfig.json +18 -0
- 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
|
+
});
|