tokenlean 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/README.md +248 -0
- package/bin/tl-api.mjs +515 -0
- package/bin/tl-blame.mjs +345 -0
- package/bin/tl-complexity.mjs +514 -0
- package/bin/tl-component.mjs +274 -0
- package/bin/tl-config.mjs +135 -0
- package/bin/tl-context.mjs +156 -0
- package/bin/tl-coverage.mjs +456 -0
- package/bin/tl-deps.mjs +474 -0
- package/bin/tl-diff.mjs +183 -0
- package/bin/tl-entry.mjs +256 -0
- package/bin/tl-env.mjs +376 -0
- package/bin/tl-exports.mjs +583 -0
- package/bin/tl-flow.mjs +324 -0
- package/bin/tl-history.mjs +289 -0
- package/bin/tl-hotspots.mjs +321 -0
- package/bin/tl-impact.mjs +345 -0
- package/bin/tl-prompt.mjs +175 -0
- package/bin/tl-related.mjs +227 -0
- package/bin/tl-routes.mjs +627 -0
- package/bin/tl-search.mjs +123 -0
- package/bin/tl-structure.mjs +161 -0
- package/bin/tl-symbols.mjs +430 -0
- package/bin/tl-todo.mjs +341 -0
- package/bin/tl-types.mjs +441 -0
- package/bin/tl-unused.mjs +494 -0
- package/package.json +55 -0
- package/src/config.mjs +271 -0
- package/src/output.mjs +251 -0
- package/src/project.mjs +277 -0
package/bin/tl-flow.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tl-flow - Show call graph for a function
|
|
5
|
+
*
|
|
6
|
+
* Traces what calls a function and what it calls.
|
|
7
|
+
* Helps understand code paths without reading entire files.
|
|
8
|
+
*
|
|
9
|
+
* Usage: tl-flow <function-name> [file]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Prompt info for tl-prompt
|
|
13
|
+
if (process.argv.includes('--prompt')) {
|
|
14
|
+
console.log(JSON.stringify({
|
|
15
|
+
name: 'tl-flow',
|
|
16
|
+
desc: 'Call graph: what calls this, what it calls',
|
|
17
|
+
when: 'before-read',
|
|
18
|
+
example: 'tl-flow handleSubmit'
|
|
19
|
+
}));
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
import { execSync } from 'child_process';
|
|
24
|
+
import { existsSync, readFileSync } from 'fs';
|
|
25
|
+
import { basename, dirname, relative, resolve, extname } from 'path';
|
|
26
|
+
import {
|
|
27
|
+
createOutput,
|
|
28
|
+
parseCommonArgs,
|
|
29
|
+
shellEscape,
|
|
30
|
+
COMMON_OPTIONS_HELP
|
|
31
|
+
} from '../src/output.mjs';
|
|
32
|
+
import { findProjectRoot } from '../src/project.mjs';
|
|
33
|
+
|
|
34
|
+
const HELP = `
|
|
35
|
+
tl-flow - Show call graph for a function
|
|
36
|
+
|
|
37
|
+
Usage: tl-flow <function-name> [options]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--file, -f <file> Limit search to specific file
|
|
41
|
+
--depth N, -d N Max depth for call tree (default: 2)
|
|
42
|
+
--callers Show only what calls this function
|
|
43
|
+
--callees Show only what this function calls
|
|
44
|
+
${COMMON_OPTIONS_HELP}
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
tl-flow handleSubmit # Find handleSubmit and trace calls
|
|
48
|
+
tl-flow loadConfig -f src/ # Search only in src/
|
|
49
|
+
tl-flow parseArgs --callers # Only show what calls parseArgs
|
|
50
|
+
tl-flow render --depth 3 # Deeper call tree
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
// ─────────────────────────────────────────────────────────────
|
|
54
|
+
// Function Finding
|
|
55
|
+
// ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function findFunctionDefinitions(name, projectRoot, limitPath) {
|
|
58
|
+
const searchPath = limitPath || projectRoot;
|
|
59
|
+
const definitions = [];
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Pattern to find function definitions
|
|
63
|
+
const patterns = [
|
|
64
|
+
`function ${name}\\s*\\(`, // function name(
|
|
65
|
+
`(const|let|var)\\s+${name}\\s*=`, // const name =
|
|
66
|
+
`${name}\\s*:\\s*\\(`, // name: ( (object method)
|
|
67
|
+
`${name}\\s*\\([^)]*\\)\\s*\\{`, // name() { (class method)
|
|
68
|
+
`async\\s+${name}\\s*\\(`, // async name(
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const pattern = `(${patterns.join('|')})`;
|
|
72
|
+
const cmd = `rg -n -g "*.{ts,tsx,js,jsx,mjs}" --no-heading -e "${shellEscape(pattern)}" "${shellEscape(searchPath)}" 2>/dev/null || true`;
|
|
73
|
+
|
|
74
|
+
const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
75
|
+
|
|
76
|
+
for (const line of result.trim().split('\n')) {
|
|
77
|
+
if (!line) continue;
|
|
78
|
+
const match = line.match(/^([^:]+):(\d+):(.*)$/);
|
|
79
|
+
if (!match) continue;
|
|
80
|
+
|
|
81
|
+
const [, file, lineNum, content] = match;
|
|
82
|
+
if (file.includes('node_modules')) continue;
|
|
83
|
+
if (file.includes('.test.') || file.includes('.spec.')) continue;
|
|
84
|
+
|
|
85
|
+
definitions.push({
|
|
86
|
+
file,
|
|
87
|
+
line: parseInt(lineNum, 10),
|
|
88
|
+
content: content.trim()
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// rg error
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return definitions;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function findCallers(name, projectRoot, excludeFile) {
|
|
99
|
+
const callers = [];
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Find calls to the function
|
|
103
|
+
const pattern = `${name}\\s*\\(`;
|
|
104
|
+
const cmd = `rg -n -g "*.{ts,tsx,js,jsx,mjs}" --no-heading -e "${shellEscape(pattern)}" "${shellEscape(projectRoot)}" 2>/dev/null || true`;
|
|
105
|
+
|
|
106
|
+
const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
107
|
+
|
|
108
|
+
for (const line of result.trim().split('\n')) {
|
|
109
|
+
if (!line) continue;
|
|
110
|
+
const match = line.match(/^([^:]+):(\d+):(.*)$/);
|
|
111
|
+
if (!match) continue;
|
|
112
|
+
|
|
113
|
+
const [, file, lineNum, content] = match;
|
|
114
|
+
if (file.includes('node_modules')) continue;
|
|
115
|
+
|
|
116
|
+
// Skip the definition itself
|
|
117
|
+
if (content.includes(`function ${name}`) ||
|
|
118
|
+
content.includes(`const ${name}`) ||
|
|
119
|
+
content.includes(`let ${name}`) ||
|
|
120
|
+
content.includes(`var ${name}`)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Find which function contains this call
|
|
125
|
+
const containingFn = findContainingFunction(file, parseInt(lineNum, 10));
|
|
126
|
+
|
|
127
|
+
callers.push({
|
|
128
|
+
file,
|
|
129
|
+
line: parseInt(lineNum, 10),
|
|
130
|
+
content: content.trim(),
|
|
131
|
+
caller: containingFn
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
// rg error
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Dedupe by file+caller
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
return callers.filter(c => {
|
|
141
|
+
const key = `${c.file}:${c.caller || 'top'}`;
|
|
142
|
+
if (seen.has(key)) return false;
|
|
143
|
+
seen.add(key);
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function findContainingFunction(file, lineNum) {
|
|
149
|
+
try {
|
|
150
|
+
const content = readFileSync(file, 'utf-8');
|
|
151
|
+
const lines = content.split('\n');
|
|
152
|
+
|
|
153
|
+
// Look backwards for function definition
|
|
154
|
+
for (let i = lineNum - 1; i >= 0; i--) {
|
|
155
|
+
const line = lines[i];
|
|
156
|
+
|
|
157
|
+
// Match function definitions
|
|
158
|
+
const fnMatch = line.match(/(?:function|async function)\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(|(\w+)\s*\([^)]*\)\s*\{/);
|
|
159
|
+
if (fnMatch) {
|
|
160
|
+
return fnMatch[1] || fnMatch[2] || fnMatch[3];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Match class method
|
|
164
|
+
const methodMatch = line.match(/^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/);
|
|
165
|
+
if (methodMatch && !line.includes('if') && !line.includes('while') && !line.includes('for')) {
|
|
166
|
+
return methodMatch[1];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// Can't read file
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function findCallees(file, fnName, lineNum) {
|
|
177
|
+
const callees = [];
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const content = readFileSync(file, 'utf-8');
|
|
181
|
+
const lines = content.split('\n');
|
|
182
|
+
|
|
183
|
+
// Find the function body
|
|
184
|
+
let braceCount = 0;
|
|
185
|
+
let inFunction = false;
|
|
186
|
+
let startLine = lineNum - 1;
|
|
187
|
+
|
|
188
|
+
// Find start of function
|
|
189
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
190
|
+
const line = lines[i];
|
|
191
|
+
|
|
192
|
+
if (!inFunction && line.includes('{')) {
|
|
193
|
+
inFunction = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (inFunction) {
|
|
197
|
+
braceCount += (line.match(/\{/g) || []).length;
|
|
198
|
+
braceCount -= (line.match(/\}/g) || []).length;
|
|
199
|
+
|
|
200
|
+
// Find function calls in this line
|
|
201
|
+
const callMatches = line.matchAll(/(\w+)\s*\(/g);
|
|
202
|
+
for (const match of callMatches) {
|
|
203
|
+
const callee = match[1];
|
|
204
|
+
// Skip common keywords
|
|
205
|
+
if (['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'new'].includes(callee)) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Skip the function's own name (recursion is fine but don't list as primary callee)
|
|
209
|
+
if (callee === fnName) continue;
|
|
210
|
+
|
|
211
|
+
if (!callees.includes(callee)) {
|
|
212
|
+
callees.push(callee);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (braceCount === 0) break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
// Can't read file
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return callees;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────────
|
|
227
|
+
// Main
|
|
228
|
+
// ─────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const args = process.argv.slice(2);
|
|
231
|
+
const options = parseCommonArgs(args);
|
|
232
|
+
|
|
233
|
+
if (options.help) {
|
|
234
|
+
console.log(HELP);
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Parse tool-specific options
|
|
239
|
+
let targetFile = null;
|
|
240
|
+
let depth = 2;
|
|
241
|
+
let showCallers = true;
|
|
242
|
+
let showCallees = true;
|
|
243
|
+
|
|
244
|
+
const remaining = [];
|
|
245
|
+
for (let i = 0; i < options.remaining.length; i++) {
|
|
246
|
+
const arg = options.remaining[i];
|
|
247
|
+
if ((arg === '--file' || arg === '-f') && options.remaining[i + 1]) {
|
|
248
|
+
targetFile = options.remaining[++i];
|
|
249
|
+
} else if ((arg === '--depth' || arg === '-d') && options.remaining[i + 1]) {
|
|
250
|
+
depth = parseInt(options.remaining[++i], 10);
|
|
251
|
+
} else if (arg === '--callers') {
|
|
252
|
+
showCallees = false;
|
|
253
|
+
} else if (arg === '--callees') {
|
|
254
|
+
showCallers = false;
|
|
255
|
+
} else if (!arg.startsWith('-')) {
|
|
256
|
+
remaining.push(arg);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fnName = remaining[0];
|
|
261
|
+
|
|
262
|
+
if (!fnName) {
|
|
263
|
+
console.log(HELP);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const projectRoot = findProjectRoot();
|
|
268
|
+
const out = createOutput(options);
|
|
269
|
+
|
|
270
|
+
out.header(`\n🔀 Call flow: ${fnName}`);
|
|
271
|
+
|
|
272
|
+
// Find function definitions
|
|
273
|
+
const definitions = findFunctionDefinitions(fnName, projectRoot, targetFile);
|
|
274
|
+
|
|
275
|
+
if (definitions.length === 0) {
|
|
276
|
+
out.add(`\n No definition found for "${fnName}"`);
|
|
277
|
+
out.print();
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Show definitions
|
|
282
|
+
out.add('\n📍 Defined in:');
|
|
283
|
+
for (const def of definitions.slice(0, 5)) {
|
|
284
|
+
const rel = relative(projectRoot, def.file);
|
|
285
|
+
out.add(` ${rel}:${def.line}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Show callers
|
|
289
|
+
if (showCallers) {
|
|
290
|
+
const callers = findCallers(fnName, projectRoot);
|
|
291
|
+
|
|
292
|
+
if (callers.length > 0) {
|
|
293
|
+
out.add('\n⬅️ Called by:');
|
|
294
|
+
for (const caller of callers.slice(0, 10)) {
|
|
295
|
+
const rel = relative(projectRoot, caller.file);
|
|
296
|
+
const from = caller.caller ? ` (in ${caller.caller})` : '';
|
|
297
|
+
out.add(` ${rel}:${caller.line}${from}`);
|
|
298
|
+
}
|
|
299
|
+
if (callers.length > 10) {
|
|
300
|
+
out.add(` ... and ${callers.length - 10} more`);
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
out.add('\n⬅️ Called by: (no callers found)');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Show callees
|
|
308
|
+
if (showCallees && definitions.length > 0) {
|
|
309
|
+
const def = definitions[0];
|
|
310
|
+
const callees = findCallees(def.file, fnName, def.line);
|
|
311
|
+
|
|
312
|
+
if (callees.length > 0) {
|
|
313
|
+
out.add('\n➡️ Calls:');
|
|
314
|
+
out.add(` ${callees.slice(0, 15).join(', ')}`);
|
|
315
|
+
if (callees.length > 15) {
|
|
316
|
+
out.add(` ... and ${callees.length - 15} more`);
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
out.add('\n➡️ Calls: (no function calls found)');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
out.add('');
|
|
324
|
+
out.print();
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tl-history - Recent changes to a file, summarized
|
|
5
|
+
*
|
|
6
|
+
* Shows commit history for a file without full diffs - just commit messages,
|
|
7
|
+
* authors, and dates. Perfect for understanding how a file has evolved.
|
|
8
|
+
*
|
|
9
|
+
* Usage: tl-history <file> [--limit N]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Prompt info for tl-prompt
|
|
13
|
+
if (process.argv.includes('--prompt')) {
|
|
14
|
+
console.log(JSON.stringify({
|
|
15
|
+
name: 'tl-history',
|
|
16
|
+
desc: 'Recent changes to a file (commits only)',
|
|
17
|
+
when: 'before-read',
|
|
18
|
+
example: 'tl-history src/api.ts'
|
|
19
|
+
}));
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
import { existsSync } from 'fs';
|
|
24
|
+
import { spawnSync } from 'child_process';
|
|
25
|
+
import { basename, relative } from 'path';
|
|
26
|
+
import {
|
|
27
|
+
createOutput,
|
|
28
|
+
parseCommonArgs,
|
|
29
|
+
COMMON_OPTIONS_HELP
|
|
30
|
+
} from '../src/output.mjs';
|
|
31
|
+
import { findProjectRoot } from '../src/project.mjs';
|
|
32
|
+
|
|
33
|
+
const HELP = `
|
|
34
|
+
tl-history - Recent changes to a file, summarized
|
|
35
|
+
|
|
36
|
+
Usage: tl-history <file> [options]
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--limit N, -n N Number of commits to show (default: 20)
|
|
40
|
+
--since <date> Only commits after date (e.g., "2 weeks ago", "2024-01-01")
|
|
41
|
+
--author <name> Filter by author
|
|
42
|
+
--stat Show file change stats (+/- lines)
|
|
43
|
+
--oneline Ultra-compact one-line format
|
|
44
|
+
${COMMON_OPTIONS_HELP}
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
tl-history src/api.ts # Recent commits
|
|
48
|
+
tl-history src/api.ts -n 50 # Last 50 commits
|
|
49
|
+
tl-history src/api.ts --since "1 month ago"
|
|
50
|
+
tl-history src/api.ts --stat # With change stats
|
|
51
|
+
tl-history src/api.ts --oneline # Compact format
|
|
52
|
+
|
|
53
|
+
Output includes:
|
|
54
|
+
- Commit hash (short)
|
|
55
|
+
- Date
|
|
56
|
+
- Author
|
|
57
|
+
- Commit message
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
// ─────────────────────────────────────────────────────────────
|
|
61
|
+
// Git Operations
|
|
62
|
+
// ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function getFileHistory(filePath, options = {}) {
|
|
65
|
+
const {
|
|
66
|
+
limit = 20,
|
|
67
|
+
since = null,
|
|
68
|
+
author = null,
|
|
69
|
+
stat = false
|
|
70
|
+
} = options;
|
|
71
|
+
|
|
72
|
+
const args = ['log', `--max-count=${limit}`, '--pretty=format:%H|%h|%ai|%an|%s'];
|
|
73
|
+
|
|
74
|
+
if (since) {
|
|
75
|
+
args.push(`--since=${since}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (author) {
|
|
79
|
+
args.push(`--author=${author}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (stat) {
|
|
83
|
+
args.push('--numstat');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
args.push('--follow'); // Follow file renames
|
|
87
|
+
args.push('--', filePath);
|
|
88
|
+
|
|
89
|
+
const result = spawnSync('git', args, {
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
maxBuffer: 10 * 1024 * 1024
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (result.error) {
|
|
95
|
+
throw result.error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (result.status !== 0 && result.stderr) {
|
|
99
|
+
if (result.stderr.includes('not a git repository')) {
|
|
100
|
+
throw new Error('Not in a git repository');
|
|
101
|
+
}
|
|
102
|
+
throw new Error(result.stderr);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return parseGitLog(result.stdout || '', stat);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseGitLog(output, includeStat) {
|
|
109
|
+
const commits = [];
|
|
110
|
+
const lines = output.trim().split('\n');
|
|
111
|
+
|
|
112
|
+
let i = 0;
|
|
113
|
+
while (i < lines.length) {
|
|
114
|
+
const line = lines[i];
|
|
115
|
+
if (!line) {
|
|
116
|
+
i++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Parse commit line: hash|shortHash|date|author|message
|
|
121
|
+
const parts = line.split('|');
|
|
122
|
+
if (parts.length >= 5) {
|
|
123
|
+
const commit = {
|
|
124
|
+
hash: parts[0],
|
|
125
|
+
shortHash: parts[1],
|
|
126
|
+
date: parts[2],
|
|
127
|
+
author: parts[3],
|
|
128
|
+
message: parts.slice(4).join('|'), // Message might contain |
|
|
129
|
+
stats: null
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
i++;
|
|
133
|
+
|
|
134
|
+
// If stat mode, parse numstat lines
|
|
135
|
+
if (includeStat) {
|
|
136
|
+
let added = 0;
|
|
137
|
+
let deleted = 0;
|
|
138
|
+
|
|
139
|
+
while (i < lines.length && lines[i] && !lines[i].includes('|')) {
|
|
140
|
+
const statLine = lines[i].trim();
|
|
141
|
+
if (statLine) {
|
|
142
|
+
const statParts = statLine.split('\t');
|
|
143
|
+
if (statParts.length >= 2) {
|
|
144
|
+
const a = parseInt(statParts[0], 10) || 0;
|
|
145
|
+
const d = parseInt(statParts[1], 10) || 0;
|
|
146
|
+
added += a;
|
|
147
|
+
deleted += d;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (added > 0 || deleted > 0) {
|
|
154
|
+
commit.stats = { added, deleted };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
commits.push(commit);
|
|
159
|
+
} else {
|
|
160
|
+
i++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return commits;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatDate(isoDate) {
|
|
168
|
+
// Convert "2024-01-15 14:30:45 +0000" to "Jan 15"
|
|
169
|
+
const date = new Date(isoDate);
|
|
170
|
+
const now = new Date();
|
|
171
|
+
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
|
172
|
+
|
|
173
|
+
if (diffDays === 0) return 'today';
|
|
174
|
+
if (diffDays === 1) return 'yesterday';
|
|
175
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
176
|
+
|
|
177
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
178
|
+
const month = months[date.getMonth()];
|
|
179
|
+
const day = date.getDate();
|
|
180
|
+
|
|
181
|
+
if (date.getFullYear() === now.getFullYear()) {
|
|
182
|
+
return `${month} ${day}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return `${month} ${day}, ${date.getFullYear()}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatStats(stats) {
|
|
189
|
+
if (!stats) return '';
|
|
190
|
+
const { added, deleted } = stats;
|
|
191
|
+
const parts = [];
|
|
192
|
+
if (added > 0) parts.push(`+${added}`);
|
|
193
|
+
if (deleted > 0) parts.push(`-${deleted}`);
|
|
194
|
+
return parts.length > 0 ? ` (${parts.join('/')})` : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─────────────────────────────────────────────────────────────
|
|
198
|
+
// Main
|
|
199
|
+
// ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const args = process.argv.slice(2);
|
|
202
|
+
const options = parseCommonArgs(args);
|
|
203
|
+
|
|
204
|
+
// Parse custom options
|
|
205
|
+
let limit = 20;
|
|
206
|
+
let since = null;
|
|
207
|
+
let author = null;
|
|
208
|
+
let showStat = false;
|
|
209
|
+
let oneline = false;
|
|
210
|
+
|
|
211
|
+
const remaining = [];
|
|
212
|
+
for (let i = 0; i < options.remaining.length; i++) {
|
|
213
|
+
const arg = options.remaining[i];
|
|
214
|
+
|
|
215
|
+
if (arg === '--limit' || arg === '-n') {
|
|
216
|
+
limit = parseInt(options.remaining[++i], 10) || 20;
|
|
217
|
+
} else if (arg === '--since') {
|
|
218
|
+
since = options.remaining[++i];
|
|
219
|
+
} else if (arg === '--author') {
|
|
220
|
+
author = options.remaining[++i];
|
|
221
|
+
} else if (arg === '--stat') {
|
|
222
|
+
showStat = true;
|
|
223
|
+
} else if (arg === '--oneline') {
|
|
224
|
+
oneline = true;
|
|
225
|
+
} else if (!arg.startsWith('-')) {
|
|
226
|
+
remaining.push(arg);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const filePath = remaining[0];
|
|
231
|
+
|
|
232
|
+
if (options.help || !filePath) {
|
|
233
|
+
console.log(HELP);
|
|
234
|
+
process.exit(options.help ? 0 : 1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!existsSync(filePath)) {
|
|
238
|
+
console.error(`File not found: ${filePath}`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const projectRoot = findProjectRoot();
|
|
243
|
+
const relPath = relative(projectRoot, filePath);
|
|
244
|
+
const out = createOutput(options);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const commits = getFileHistory(filePath, { limit, since, author, stat: showStat });
|
|
248
|
+
|
|
249
|
+
if (commits.length === 0) {
|
|
250
|
+
console.log('No commits found for this file');
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Set JSON data
|
|
255
|
+
out.setData('file', relPath);
|
|
256
|
+
out.setData('commits', commits);
|
|
257
|
+
out.setData('totalCommits', commits.length);
|
|
258
|
+
|
|
259
|
+
// Format output
|
|
260
|
+
out.header(`📜 ${relPath} - ${commits.length} recent commits`);
|
|
261
|
+
out.blank();
|
|
262
|
+
|
|
263
|
+
for (const commit of commits) {
|
|
264
|
+
if (oneline) {
|
|
265
|
+
const statsStr = formatStats(commit.stats);
|
|
266
|
+
out.add(`${commit.shortHash} ${formatDate(commit.date)} ${commit.message}${statsStr}`);
|
|
267
|
+
} else {
|
|
268
|
+
const statsStr = formatStats(commit.stats);
|
|
269
|
+
out.add(`${commit.shortHash} ${formatDate(commit.date).padEnd(12)} ${commit.author}`);
|
|
270
|
+
out.add(` ${commit.message}${statsStr}`);
|
|
271
|
+
out.blank();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Summary
|
|
276
|
+
if (!options.quiet && !oneline) {
|
|
277
|
+
const authors = new Set(commits.map(c => c.author));
|
|
278
|
+
const oldestDate = commits[commits.length - 1]?.date;
|
|
279
|
+
const oldest = oldestDate ? formatDate(oldestDate) : '';
|
|
280
|
+
|
|
281
|
+
out.add(`---`);
|
|
282
|
+
out.add(`${commits.length} commits by ${authors.size} author(s), oldest: ${oldest}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
out.print();
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.error(`Error: ${error.message}`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|