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
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,6 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "ESNext",
4
+ "moduleResolution": "bundler"
5
+ }
6
+ }
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node