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
package/cli/index.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI for trace — git history visualizer
|
|
3
|
+
|
|
4
|
+
import { parseGitLog } from './parser.js'
|
|
5
|
+
import { clearCache, getCacheInfo } from '../src/cache.js'
|
|
6
|
+
import { fetchCommits as fetchGitHubCommits } from '../src/github.js'
|
|
7
|
+
import { fetchCommits as fetchGitLabCommits } from '../src/gitlab.js'
|
|
8
|
+
import { fetchCommits as fetchGiteaCommits } from '../src/gitea.js'
|
|
9
|
+
import { detectHost } from '../src/host.js'
|
|
10
|
+
import { writeFileSync, readFileSync } from 'fs'
|
|
11
|
+
|
|
12
|
+
// Show cache info using getCacheInfo
|
|
13
|
+
function showCacheInfo() {
|
|
14
|
+
const entries = getCacheInfo()
|
|
15
|
+
|
|
16
|
+
if (entries.length === 0) {
|
|
17
|
+
console.error('[trace] No cache found')
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.error(`[trace] Cache: ${entries.length} entries`)
|
|
22
|
+
for (const e of entries) {
|
|
23
|
+
const age = Math.floor((Date.now() - e.timestamp) / 1000 / 60) // minutes
|
|
24
|
+
console.error(` - ${e.repo}/${e.file} (${e.last} commits, ${age}m old)`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN
|
|
29
|
+
const GITLAB_TOKEN = process.env.GITLAB_TOKEN
|
|
30
|
+
const GITEA_TOKEN = process.env.GITEA_TOKEN
|
|
31
|
+
const DEFAULT_LAST = 10
|
|
32
|
+
|
|
33
|
+
const args = process.argv.slice(2)
|
|
34
|
+
|
|
35
|
+
function showHelp() {
|
|
36
|
+
console.log(`
|
|
37
|
+
trace v0.4.0 — Git history visualizer
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
trace <file> Local git log
|
|
41
|
+
trace <repo> <file> Remote API (auto-detect host)
|
|
42
|
+
trace cache Show cache
|
|
43
|
+
trace cache clear Clear cache
|
|
44
|
+
|
|
45
|
+
Supported hosts:
|
|
46
|
+
GitHub owner/repo
|
|
47
|
+
GitLab gitlab.com/owner/repo
|
|
48
|
+
Gitea gitea.com/owner/repo, codeberg.org/owner/repo
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--last <n> Commits to show (default: ${DEFAULT_LAST})
|
|
52
|
+
--json Output JSON
|
|
53
|
+
--output <f> Write to file
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
trace src/App.tsx
|
|
57
|
+
trace src/App.tsx --last 5 --json
|
|
58
|
+
trace doanbactam/trace src/Trace.tsx
|
|
59
|
+
trace gitlab.com/gitlab-org/gitlab-shell README.md
|
|
60
|
+
trace codeberg.org/forgejo/forgejo README.md
|
|
61
|
+
trace src/App.tsx --output embed.html
|
|
62
|
+
|
|
63
|
+
Environment:
|
|
64
|
+
GITHUB_TOKEN GitHub token for API
|
|
65
|
+
GITLAB_TOKEN GitLab token for API
|
|
66
|
+
GITEA_TOKEN Gitea token for API
|
|
67
|
+
`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseArgs() {
|
|
71
|
+
const options: Record<string, string | boolean> = {
|
|
72
|
+
last: String(DEFAULT_LAST),
|
|
73
|
+
json: false,
|
|
74
|
+
output: ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < args.length; i++) {
|
|
78
|
+
const arg = args[i]
|
|
79
|
+
if (arg === '--help' || arg === '-h') {
|
|
80
|
+
showHelp()
|
|
81
|
+
process.exit(0)
|
|
82
|
+
}
|
|
83
|
+
if (arg.startsWith('--')) {
|
|
84
|
+
const key = arg.slice(2)
|
|
85
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
86
|
+
options[key] = args[i + 1]
|
|
87
|
+
i++
|
|
88
|
+
} else {
|
|
89
|
+
options[key] = true
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return options
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleCacheCommand() {
|
|
98
|
+
const cacheArgs = args.slice(1)
|
|
99
|
+
|
|
100
|
+
if (cacheArgs.length === 0 || cacheArgs[0] === 'show' || cacheArgs[0] === 'info') {
|
|
101
|
+
showCacheInfo()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (cacheArgs[0] === 'clear') {
|
|
106
|
+
clearCache()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.error('Unknown cache command. Use: cache, cache clear')
|
|
111
|
+
process.exit(1)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function main() {
|
|
115
|
+
if (args[0] === 'cache') {
|
|
116
|
+
await handleCacheCommand()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const options = parseArgs()
|
|
121
|
+
|
|
122
|
+
// Get positional args (excluding options and their values)
|
|
123
|
+
const positionalArgs: string[] = []
|
|
124
|
+
for (let i = 0; i < args.length; i++) {
|
|
125
|
+
const arg = args[i]
|
|
126
|
+
if (arg.startsWith('--')) {
|
|
127
|
+
i++ // skip value
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
positionalArgs.push(arg)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Detect GitHub repo (owner/repo format - only one slash, no file extension like .ts/.js)
|
|
134
|
+
const repoArg = positionalArgs.find(a => {
|
|
135
|
+
const parts = a.split('/')
|
|
136
|
+
return parts.length === 2 && !parts[1].includes('.')
|
|
137
|
+
})
|
|
138
|
+
const fileArg = positionalArgs.find(a => a !== repoArg)
|
|
139
|
+
|
|
140
|
+
if (!fileArg && !repoArg) {
|
|
141
|
+
console.error('Error: Specify a file path or owner/repo')
|
|
142
|
+
showHelp()
|
|
143
|
+
process.exit(1)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const commits = await fetchCommits(fileArg, repoArg, options)
|
|
147
|
+
|
|
148
|
+
if (options.json) {
|
|
149
|
+
const output = JSON.stringify(commits, null, 2)
|
|
150
|
+
writeOutput(options.output as string, output)
|
|
151
|
+
} else {
|
|
152
|
+
const html = generateHTML(commits)
|
|
153
|
+
writeOutput(options.output as string, html)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchCommits(
|
|
158
|
+
fileArg: string,
|
|
159
|
+
repoArg: string | undefined,
|
|
160
|
+
options: Record<string, string | boolean>
|
|
161
|
+
): Promise<any[]> {
|
|
162
|
+
const last = parseInt(options.last as string)
|
|
163
|
+
|
|
164
|
+
// Remote mode - auto-detect host
|
|
165
|
+
if (repoArg) {
|
|
166
|
+
const detected = detectHost(repoArg)
|
|
167
|
+
|
|
168
|
+
if (detected) {
|
|
169
|
+
const { host, owner, repo } = detected
|
|
170
|
+
console.error(`[trace] Fetching from ${host}: ${owner}/${repo}/${fileArg}`)
|
|
171
|
+
|
|
172
|
+
switch (host) {
|
|
173
|
+
case 'github':
|
|
174
|
+
return await fetchGitHubCommits(owner, repo, fileArg, last, GITHUB_TOKEN)
|
|
175
|
+
case 'gitlab':
|
|
176
|
+
return await fetchGitLabCommits(owner, repo, fileArg, last, GITLAB_TOKEN)
|
|
177
|
+
case 'gitea':
|
|
178
|
+
return await fetchGiteaCommits(owner, repo, fileArg, last, GITEA_TOKEN)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fallback to GitHub for owner/repo format
|
|
183
|
+
const [owner, repo] = repoArg.split('/')
|
|
184
|
+
console.error(`[trace] Fetching from GitHub: ${owner}/${repo}/${fileArg}`)
|
|
185
|
+
return await fetchGitHubCommits(owner, repo, fileArg, last, GITHUB_TOKEN)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Local git mode
|
|
189
|
+
console.error(`[trace] Parsing local git: ${fileArg}`)
|
|
190
|
+
return await parseGitLog(fileArg, last)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeOutput(path: string, content: string): void {
|
|
194
|
+
if (path) {
|
|
195
|
+
writeFileSync(path, content)
|
|
196
|
+
console.error(`[trace] Written to ${path}`)
|
|
197
|
+
} else {
|
|
198
|
+
console.log(content)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function generateHTML(commits: any[]): string {
|
|
203
|
+
return `<!DOCTYPE html>
|
|
204
|
+
<html lang="en">
|
|
205
|
+
<head>
|
|
206
|
+
<meta charset="UTF-8">
|
|
207
|
+
<title>Trace — ${commits[0]?.message.split(' ')[0] || 'Git History'}</title>
|
|
208
|
+
<style>
|
|
209
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
210
|
+
body { font-family: 'JetBrains Mono', ui-monospace, monospace; background: #07090f; color: #e2e8f0; padding: 40px; min-height: 100vh; }
|
|
211
|
+
.trace-root { max-width: 1200px; margin: 0 auto; border-radius: 12px; overflow: hidden; }
|
|
212
|
+
pre { background: #0d1117; padding: 16px; border-radius: 8px; overflow-x: auto; }
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<div class="trace-root" id="t"></div>
|
|
217
|
+
<script>
|
|
218
|
+
const commits = ${JSON.stringify(commits)};
|
|
219
|
+
let i = 0;
|
|
220
|
+
function render() {
|
|
221
|
+
const c = commits[i];
|
|
222
|
+
document.getElementById('t').innerHTML = \`
|
|
223
|
+
<div style="display:flex;height:600px;border:1px solid #1e293b;border-radius:8px">
|
|
224
|
+
<div style="width:250px;border-right:1px solid #1e293b;padding:16px;overflow-y:auto">
|
|
225
|
+
\${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'):'')">
|
|
226
|
+
<div style="display:flex;align-items:center;margin-bottom:4px">
|
|
227
|
+
<span style="width:8px;height:8px;border-radius:50%;background:\${c.authorType==='ai'?'#a78bfa':'#34d399'};margin-right:8px"></span>
|
|
228
|
+
<span style="font-size:13px">\${c.message.slice(0,30)}\${c.message.length>30?'...':''}</span>
|
|
229
|
+
</div>
|
|
230
|
+
<div style="font-size:11px;opacity:0.6">\${c.author} · \${c.time}</div>
|
|
231
|
+
<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>
|
|
232
|
+
</div>\`).join('')}
|
|
233
|
+
</div>
|
|
234
|
+
<div style="flex:1;padding:16px;overflow-y:auto">
|
|
235
|
+
<h2 style="font-size:16px;margin:0 0 4px">\${c.message}</h2>
|
|
236
|
+
<div style="font-size:12px;opacity:0.6;margin-bottom:16px">\${c.hash} · \${c.author}</div>
|
|
237
|
+
<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>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
\`;
|
|
241
|
+
}
|
|
242
|
+
render();
|
|
243
|
+
</script>
|
|
244
|
+
</body>
|
|
245
|
+
</html>`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
main().catch(err => {
|
|
249
|
+
console.error(`[trace] Error: ${err.message}`)
|
|
250
|
+
process.exit(1)
|
|
251
|
+
})
|
package/cli/parser.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { exec } from 'child_process'
|
|
2
|
+
import { promisify } from 'util'
|
|
3
|
+
import { parseDiff, detectAI } from '../src/shared.js'
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate file path to prevent command injection
|
|
9
|
+
* Rejects paths with shell metacharacters or absolute paths
|
|
10
|
+
*/
|
|
11
|
+
function validatePath(filePath: string): void {
|
|
12
|
+
// Reject shell metacharacters that could escape git quotes
|
|
13
|
+
const dangerous = /[\n\r`$\\;&|<>]/
|
|
14
|
+
if (dangerous.test(filePath)) {
|
|
15
|
+
throw new Error(`Invalid file path: contains potentially dangerous characters`)
|
|
16
|
+
}
|
|
17
|
+
// Reject absolute paths (should work relative to cwd)
|
|
18
|
+
if (filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)) {
|
|
19
|
+
throw new Error(`Invalid file path: use relative paths only`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function parseGitLog(filePath: string, last: number = 10): Promise<any[]> {
|
|
24
|
+
validatePath(filePath)
|
|
25
|
+
const format = '%H|%an|%ae|%s|%cr'
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execAsync(
|
|
29
|
+
`git log -${last} --follow -p "--format=${format}" -- "${filePath}"`,
|
|
30
|
+
{ cwd: process.cwd(), shell: true, windowsHide: true }
|
|
31
|
+
)
|
|
32
|
+
return parseGitLogOutput(stdout)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`Failed to parse git log: ${(error as Error).message}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseGitLogOutput(output: string): any[] {
|
|
39
|
+
const commits: any[] = []
|
|
40
|
+
const lines = output.split('\n')
|
|
41
|
+
let currentCommit: any = null
|
|
42
|
+
let inDiff = false
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (/^[a-f0-9]{40}\|/.test(line)) {
|
|
46
|
+
if (currentCommit?.lines) commits.push(currentCommit)
|
|
47
|
+
const [hash, author, email, message, time] = line.split('|')
|
|
48
|
+
currentCommit = {
|
|
49
|
+
hash: hash.slice(0, 7),
|
|
50
|
+
author,
|
|
51
|
+
message,
|
|
52
|
+
authorType: detectAI(undefined, email, message),
|
|
53
|
+
time,
|
|
54
|
+
lines: []
|
|
55
|
+
}
|
|
56
|
+
inDiff = false
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (line.startsWith('diff --git')) {
|
|
61
|
+
inDiff = true
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (inDiff && currentCommit) {
|
|
66
|
+
if (line.startsWith('@@') || line.startsWith('index') ||
|
|
67
|
+
line.startsWith('---') || line.startsWith('+++')) {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
currentCommit.lines.push(...parseDiff(line))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (currentCommit?.lines) commits.push(currentCommit)
|
|
75
|
+
return commits
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|