@vladimirshefer/git-stats 0.0.1
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/dist/index.js +797 -0
- package/package.json +28 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
29
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
|
+
|
|
31
|
+
// src/index.ts
|
|
32
|
+
var index_exports = {};
|
|
33
|
+
__export(index_exports, {
|
|
34
|
+
distinctCount: () => distinctCount
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
var import_child_process2 = require("child_process");
|
|
38
|
+
|
|
39
|
+
// src/util/exec.ts
|
|
40
|
+
var import_child_process = require("child_process");
|
|
41
|
+
function execAsync(command, args = [], options = {}) {
|
|
42
|
+
return new Promise((resolve2, reject) => {
|
|
43
|
+
const child = (0, import_child_process.spawn)(command, args, { ...options, shell: true });
|
|
44
|
+
let stdout = [];
|
|
45
|
+
let stderr = [];
|
|
46
|
+
let stdoutBuffer = "";
|
|
47
|
+
let stderrBuffer = "";
|
|
48
|
+
child.stdout.on("data", (data) => {
|
|
49
|
+
stdoutBuffer += data.toString();
|
|
50
|
+
const lines = stdoutBuffer.split("\n");
|
|
51
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
52
|
+
stdout.push(lines[i]);
|
|
53
|
+
}
|
|
54
|
+
stdoutBuffer = lines[lines.length - 1];
|
|
55
|
+
});
|
|
56
|
+
child.stderr.on("data", (data) => {
|
|
57
|
+
stderrBuffer += data.toString();
|
|
58
|
+
const lines = stderrBuffer.split("\n");
|
|
59
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
60
|
+
stderr.push(lines[i]);
|
|
61
|
+
}
|
|
62
|
+
stderrBuffer = lines[lines.length - 1];
|
|
63
|
+
});
|
|
64
|
+
child.on("error", (err) => {
|
|
65
|
+
reject(err);
|
|
66
|
+
});
|
|
67
|
+
child.on("close", (code) => {
|
|
68
|
+
if (stdoutBuffer.length > 0) {
|
|
69
|
+
stdout.push(stdoutBuffer);
|
|
70
|
+
}
|
|
71
|
+
if (stderrBuffer.length > 0) {
|
|
72
|
+
stderr.push(stderrBuffer);
|
|
73
|
+
}
|
|
74
|
+
if (code === 0) {
|
|
75
|
+
resolve2({ stdout, stderr });
|
|
76
|
+
} else {
|
|
77
|
+
reject(new Error(`Command failed with code ${code}
|
|
78
|
+
${stderr.join("\n")}`));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/index.ts
|
|
85
|
+
var fs4 = __toESM(require("fs"));
|
|
86
|
+
var path4 = __toESM(require("path"));
|
|
87
|
+
|
|
88
|
+
// src/output/report_template.ts
|
|
89
|
+
var path = __toESM(require("path"));
|
|
90
|
+
var fs = __toESM(require("fs"));
|
|
91
|
+
|
|
92
|
+
// src/output/report_template.html
|
|
93
|
+
var report_template_default = `<!DOCTYPE html>
|
|
94
|
+
<!--suppress TypeScriptMissingConfigOption -->
|
|
95
|
+
<html lang="en">
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="UTF-8">
|
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
99
|
+
<title>Git Blame Statistics</title>
|
|
100
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
101
|
+
<script src="https://cdn.plot.ly/plotly-3.3.0.min.js" charset="utf-8"></script>
|
|
102
|
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
103
|
+
<!-- React 18 UMD builds -->
|
|
104
|
+
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
105
|
+
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
106
|
+
<!-- Babel Standalone for in-browser JSX transform -->
|
|
107
|
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
108
|
+
</head>
|
|
109
|
+
<body class="font-sans m-0 bg-gray-50 text-gray-900">
|
|
110
|
+
<div id="root"></div>
|
|
111
|
+
<script type="text/babel" data-presets="react">
|
|
112
|
+
const RAW_DATASET =
|
|
113
|
+
__DATASET_JSON__
|
|
114
|
+
|| [];
|
|
115
|
+
// Fixed schema as per pipeline: [author, days_bucket, lang, clusterPath, repoName, count]
|
|
116
|
+
const HEADER_LABELS = ["author", "days_bucket", "lang", "clusterPath", "repoName", "count"];
|
|
117
|
+
// -------- Client-side grouping/filtering engine --------
|
|
118
|
+
const KEY_INDEX = Object.fromEntries(HEADER_LABELS.map((k, i) => [k, i]));
|
|
119
|
+
const COUNT_IDX_FROM_END = 1; // last element is count
|
|
120
|
+
|
|
121
|
+
const allKeys = HEADER_LABELS.filter(k => k !== 'count');
|
|
122
|
+
const TOP_N = 20;
|
|
123
|
+
const BUCKET_COLORS = [
|
|
124
|
+
'rgba(214, 40, 40, 0.7)',
|
|
125
|
+
'rgba(247, 127, 0, 0.7)',
|
|
126
|
+
'rgba(252, 191, 73, 0.7)',
|
|
127
|
+
'rgba(168, 218, 142, 0.7)',
|
|
128
|
+
'rgba(75, 192, 192, 0.7)',
|
|
129
|
+
'rgba(54, 162, 235, 0.7)',
|
|
130
|
+
'rgba(153, 102, 255, 0.7)',
|
|
131
|
+
'rgba(201, 203, 207, 0.7)'
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function uniqueValues(arr, idx) {
|
|
135
|
+
const set = new Set(arr.map(r => r[idx]));
|
|
136
|
+
return Array.from(set);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pivot(
|
|
140
|
+
dataset,
|
|
141
|
+
primaryKeyIndex,
|
|
142
|
+
secondaryKeyIndex,
|
|
143
|
+
filterState
|
|
144
|
+
) {
|
|
145
|
+
const pivot = new Map();
|
|
146
|
+
const totals = new Map();
|
|
147
|
+
const secValuesSet = new Set();
|
|
148
|
+
for (const row of dataset) {
|
|
149
|
+
let ok = true;
|
|
150
|
+
for (const key of allKeys) {
|
|
151
|
+
const idx = KEY_INDEX[key];
|
|
152
|
+
const sel = filterState[key];
|
|
153
|
+
if (sel && !sel.has(String(row[idx]))) {
|
|
154
|
+
ok = false;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!ok) continue;
|
|
159
|
+
const pk = row[primaryKeyIndex];
|
|
160
|
+
const sk = row[secondaryKeyIndex];
|
|
161
|
+
const count = Number(row[row.length - COUNT_IDX_FROM_END]) || 0;
|
|
162
|
+
if (!pivot.has(pk)) pivot.set(pk, new Map());
|
|
163
|
+
pivot.get(pk).set(sk, (pivot.get(pk).get(sk) || 0) + count);
|
|
164
|
+
totals.set(pk, (totals.get(pk) || 0) + count);
|
|
165
|
+
secValuesSet.add(sk);
|
|
166
|
+
}
|
|
167
|
+
const primaryKeys = Array.from(pivot.keys()).sort((a, b) => (totals.get(b) || 0) - (totals.get(a) || 0));
|
|
168
|
+
const secondaryKeys = Array.from(secValuesSet).sort((a, b) => String(a).localeCompare(String(b)));
|
|
169
|
+
return {pivot, totals, primaryKeys, secondaryKeys};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- React Components ----------
|
|
173
|
+
function ChartView({labels, datasets}) {
|
|
174
|
+
const canvasRef = React.useRef(null);
|
|
175
|
+
const chartRef = React.useRef(null);
|
|
176
|
+
React.useEffect(() => {
|
|
177
|
+
const ctx = canvasRef.current.getContext('2d');
|
|
178
|
+
chartRef.current = new Chart(ctx, {
|
|
179
|
+
type: 'bar',
|
|
180
|
+
data: {labels: [], datasets: []},
|
|
181
|
+
options: {
|
|
182
|
+
indexAxis: 'y',
|
|
183
|
+
scales: {
|
|
184
|
+
x: {stacked: true, beginAtZero: true},
|
|
185
|
+
y: {stacked: true, ticks: {autoSkip: false}}
|
|
186
|
+
},
|
|
187
|
+
plugins: {
|
|
188
|
+
tooltip: {
|
|
189
|
+
callbacks: {
|
|
190
|
+
label: function (context) {
|
|
191
|
+
let label = context.dataset.label || '';
|
|
192
|
+
if (label) label += ': ';
|
|
193
|
+
if (context.parsed.x !== null) label += context.parsed.x.toLocaleString();
|
|
194
|
+
return label;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return () => {
|
|
202
|
+
chartRef.current && chartRef.current.destroy();
|
|
203
|
+
};
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
React.useEffect(() => {
|
|
207
|
+
if (!chartRef.current) return;
|
|
208
|
+
chartRef.current.data.labels = labels;
|
|
209
|
+
chartRef.current.data.datasets = datasets;
|
|
210
|
+
chartRef.current.update();
|
|
211
|
+
}, [labels, datasets]);
|
|
212
|
+
|
|
213
|
+
return <canvas ref={canvasRef}/>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function MultiSelect({label, values, selectedSet, onChange}) {
|
|
217
|
+
const selCount = selectedSet.size;
|
|
218
|
+
const total = values.length;
|
|
219
|
+
return (
|
|
220
|
+
<div>
|
|
221
|
+
<label className="block font-semibold mb-1.5">
|
|
222
|
+
{label}{' '}
|
|
223
|
+
<span className="text-gray-600 font-normal">({selCount}/{total})</span>
|
|
224
|
+
</label>
|
|
225
|
+
<select
|
|
226
|
+
multiple
|
|
227
|
+
size={Math.min(8, Math.max(3, values.length))}
|
|
228
|
+
className="w-full"
|
|
229
|
+
onChange={(e) => {
|
|
230
|
+
const chosen = Array.from(e.target.selectedOptions).map(o => o.value);
|
|
231
|
+
onChange(new Set(chosen));
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{values.map(v => {
|
|
235
|
+
const strVal = String(v);
|
|
236
|
+
return (
|
|
237
|
+
<option key={strVal} value={strVal} selected={selectedSet.has(strVal)}>
|
|
238
|
+
{strVal}
|
|
239
|
+
</option>
|
|
240
|
+
);
|
|
241
|
+
})}
|
|
242
|
+
</select>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function App() {
|
|
248
|
+
const {initialFilters, valueOptions} = React.useMemo(() => {
|
|
249
|
+
const filters = {};
|
|
250
|
+
const options = {};
|
|
251
|
+
for (const key of allKeys) {
|
|
252
|
+
const idx = KEY_INDEX[key];
|
|
253
|
+
const values = uniqueValues(RAW_DATASET, idx).sort((a, b) => {
|
|
254
|
+
if (key === 'days_bucket') return Number(a) - Number(b);
|
|
255
|
+
return String(a).localeCompare(String(b));
|
|
256
|
+
});
|
|
257
|
+
filters[key] = new Set(values.map(v => String(v)));
|
|
258
|
+
options[key] = values;
|
|
259
|
+
}
|
|
260
|
+
return {initialFilters: filters, valueOptions: options};
|
|
261
|
+
}, []);
|
|
262
|
+
const [filters, setFilters] = React.useState(initialFilters);
|
|
263
|
+
const [primaryKeyIndex, setPrimaryKeyIndex] = React.useState(0);
|
|
264
|
+
const [secondaryKeyIndex, setSecondaryKeyIndex] = React.useState(1);
|
|
265
|
+
|
|
266
|
+
const {chartLabels, datasets} = React.useMemo(() => {
|
|
267
|
+
const {
|
|
268
|
+
pivot: pv,
|
|
269
|
+
primaryKeys,
|
|
270
|
+
secondaryKeys
|
|
271
|
+
} = pivot(RAW_DATASET, primaryKeyIndex, secondaryKeyIndex, filters);
|
|
272
|
+
const chartPrimaryKeys = primaryKeys.slice(0, TOP_N);
|
|
273
|
+
const ds = secondaryKeys.map((sk, i) => ({
|
|
274
|
+
label: String(sk),
|
|
275
|
+
data: chartPrimaryKeys.map(pk => (pv.get(pk)?.get(sk)) || 0),
|
|
276
|
+
backgroundColor: BUCKET_COLORS[i % BUCKET_COLORS.length]
|
|
277
|
+
}));
|
|
278
|
+
return {chartLabels: chartPrimaryKeys, datasets: ds};
|
|
279
|
+
}, [filters, primaryKeyIndex, secondaryKeyIndex]);
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<div className="max-w-4xl mx-auto my-5 p-5 bg-white rounded-lg shadow-sm">
|
|
283
|
+
<h1 className="border-b border-gray-300 pb-2.5">Git Contribution Statistics</h1>
|
|
284
|
+
<div className="controls">
|
|
285
|
+
<h2 className="border-b border-gray-300 pb-2.5">Controls</h2>
|
|
286
|
+
<div className="flex gap-4 flex-wrap items-center">
|
|
287
|
+
<label>
|
|
288
|
+
Primary group:
|
|
289
|
+
<select value={primaryKeyIndex} onChange={e => setPrimaryKeyIndex(Number(e.target.value))}>
|
|
290
|
+
{allKeys.map((__, i) => (
|
|
291
|
+
<option key={i} value={i}>{HEADER_LABELS[i]}</option>
|
|
292
|
+
))}
|
|
293
|
+
</select>
|
|
294
|
+
</label>
|
|
295
|
+
<label>
|
|
296
|
+
Secondary group:
|
|
297
|
+
<select value={secondaryKeyIndex}
|
|
298
|
+
onChange={e => setSecondaryKeyIndex(Number(e.target.value))}>
|
|
299
|
+
{allKeys.map((__, i) => (
|
|
300
|
+
<option key={i} value={i}>{HEADER_LABELS[i]}</option>
|
|
301
|
+
))}
|
|
302
|
+
</select>
|
|
303
|
+
</label>
|
|
304
|
+
</div>
|
|
305
|
+
<div id="filters" className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-3">
|
|
306
|
+
{allKeys.map((key, idx) => (
|
|
307
|
+
<MultiSelect
|
|
308
|
+
key={idx}
|
|
309
|
+
label={HEADER_LABELS[idx]}
|
|
310
|
+
values={valueOptions[key]}
|
|
311
|
+
selectedSet={filters[key]}
|
|
312
|
+
onChange={(newSet) => setFilters(prev => ({...prev, [key]: newSet}))}
|
|
313
|
+
/>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
<div className="mt-5">
|
|
318
|
+
<h2 id="chartTitle" className="border-b border-gray-300 pb-2.5">Chart</h2>
|
|
319
|
+
<ChartView labels={chartLabels} datasets={datasets}/>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
326
|
+
root.render(<App/>);
|
|
327
|
+
</script>
|
|
328
|
+
</body>
|
|
329
|
+
</html>
|
|
330
|
+
`;
|
|
331
|
+
|
|
332
|
+
// src/output/report_template.ts
|
|
333
|
+
function generateHtmlReport(data, outputFile) {
|
|
334
|
+
const finalOutputPath = path.join(outputFile);
|
|
335
|
+
let htmlContent = report_template_default.split("__DATASET_JSON__");
|
|
336
|
+
fs.writeFileSync(finalOutputPath, htmlContent[0]);
|
|
337
|
+
fs.appendFileSync(finalOutputPath, "\n[\n");
|
|
338
|
+
for (let i = 0; i < data.length; i++) {
|
|
339
|
+
fs.appendFileSync(finalOutputPath, JSON.stringify(data[i]) + ",\n");
|
|
340
|
+
}
|
|
341
|
+
fs.appendFileSync(finalOutputPath, "\n]\n");
|
|
342
|
+
fs.appendFileSync(finalOutputPath, htmlContent[1]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/git.ts
|
|
346
|
+
var import_path = __toESM(require("path"));
|
|
347
|
+
var import_fs = __toESM(require("fs"));
|
|
348
|
+
async function executeGitBlamePorcelain(file, repoRoot, revisionBoundary, since) {
|
|
349
|
+
const args = ["blame", "--line-porcelain"];
|
|
350
|
+
if (since) {
|
|
351
|
+
args.push(`--since=${since}`);
|
|
352
|
+
}
|
|
353
|
+
if (revisionBoundary) {
|
|
354
|
+
args.push(revisionBoundary);
|
|
355
|
+
}
|
|
356
|
+
args.push("--", file);
|
|
357
|
+
const { stdout } = await execAsync("git", args, { cwd: repoRoot });
|
|
358
|
+
return stdout;
|
|
359
|
+
}
|
|
360
|
+
async function git_blame_porcelain(file, repoRoot, fields, revisionBoundary) {
|
|
361
|
+
const blameOutput = await executeGitBlamePorcelain(file, repoRoot, revisionBoundary);
|
|
362
|
+
return parsePorcelain(blameOutput, fields);
|
|
363
|
+
}
|
|
364
|
+
function parsePorcelain(blameOutput, fields) {
|
|
365
|
+
const userPos = fields.indexOf("author");
|
|
366
|
+
const commiterTimePos = fields.indexOf("committer-time");
|
|
367
|
+
const boundaryPos = fields.indexOf("boundary");
|
|
368
|
+
const commitPos = fields.indexOf("commit");
|
|
369
|
+
let emptyRow = [...fields];
|
|
370
|
+
if (commiterTimePos >= 0) {
|
|
371
|
+
emptyRow[commiterTimePos] = 0;
|
|
372
|
+
}
|
|
373
|
+
if (boundaryPos >= 0) {
|
|
374
|
+
emptyRow[boundaryPos] = 0;
|
|
375
|
+
}
|
|
376
|
+
let nextRow = [...emptyRow];
|
|
377
|
+
const result = [];
|
|
378
|
+
for (const line of blameOutput) {
|
|
379
|
+
if (line.startsWith(" ")) {
|
|
380
|
+
result.push([...nextRow]);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (commitPos >= 0) {
|
|
384
|
+
const firstSpace = line.indexOf(" ");
|
|
385
|
+
if (firstSpace === 40) {
|
|
386
|
+
const possibleHash = line.substring(0, firstSpace);
|
|
387
|
+
if (/^\^?[0-9a-f]{40}$/i.test(possibleHash)) {
|
|
388
|
+
nextRow = [...emptyRow];
|
|
389
|
+
nextRow[commitPos] = possibleHash.replace(/^\^/, "");
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (userPos >= 0 && line.startsWith("author ")) {
|
|
395
|
+
nextRow[userPos] = line.substring("author ".length).replace(/^<|>$/g, "");
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (commiterTimePos >= 0 && line.startsWith("committer-time ")) {
|
|
399
|
+
nextRow[commiterTimePos] = parseInt(line.substring("committer-time ".length), 10);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (boundaryPos >= 0 && line.startsWith("boundary")) {
|
|
403
|
+
nextRow[boundaryPos] = 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
function isGitRepo(dir) {
|
|
409
|
+
return import_fs.default.existsSync(import_path.default.join(dir, ".git"));
|
|
410
|
+
}
|
|
411
|
+
async function findRevision(repoRoot, commitsBack) {
|
|
412
|
+
let n = commitsBack;
|
|
413
|
+
try {
|
|
414
|
+
const { stdout } = await execAsync("git", [
|
|
415
|
+
"rev-list",
|
|
416
|
+
"--max-count=1",
|
|
417
|
+
"--skip=" + n,
|
|
418
|
+
"HEAD"
|
|
419
|
+
], { cwd: repoRoot });
|
|
420
|
+
return stdout.join("\n").trim();
|
|
421
|
+
} catch (e) {
|
|
422
|
+
if (e && (e.stack || e.message)) {
|
|
423
|
+
console.error(`Failed to compute ${n}-commit boundary for repo ${repoRoot}:`, e.message || e.stack);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return void 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/vfs.ts
|
|
430
|
+
var import_fs2 = __toESM(require("fs"));
|
|
431
|
+
var import_path2 = __toESM(require("path"));
|
|
432
|
+
var RealFileSystemImpl = class {
|
|
433
|
+
constructor(basePath) {
|
|
434
|
+
this.basePath = import_path2.default.normalize(basePath);
|
|
435
|
+
}
|
|
436
|
+
async read(filePath) {
|
|
437
|
+
return import_fs2.default.readFileSync(this.resolve(filePath), "utf8");
|
|
438
|
+
}
|
|
439
|
+
async write(filePath, content) {
|
|
440
|
+
let path1 = import_path2.default.resolve(this.resolve(filePath), "..");
|
|
441
|
+
import_fs2.default.mkdirSync(path1, { recursive: true });
|
|
442
|
+
import_fs2.default.writeFileSync(this.resolve(filePath), content);
|
|
443
|
+
}
|
|
444
|
+
async append(filePath, content) {
|
|
445
|
+
return new Promise((resolve2, reject) => {
|
|
446
|
+
import_fs2.default.appendFile(this.resolve(filePath), content, (err) => {
|
|
447
|
+
if (!err) {
|
|
448
|
+
resolve2(void 0);
|
|
449
|
+
} else {
|
|
450
|
+
reject(err);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
resolve(filePath) {
|
|
456
|
+
return import_path2.default.normalize(`${this.basePath}/${filePath}`);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// src/util/AsyncGeneratorUtil.ts
|
|
461
|
+
var AsyncGeneratorUtil = class {
|
|
462
|
+
static async *of(items) {
|
|
463
|
+
for (const item of items) {
|
|
464
|
+
yield item;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
static async *flatMap(items, mapper) {
|
|
468
|
+
for await (const item of items) {
|
|
469
|
+
yield* mapper(item);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
static async *union(sources) {
|
|
473
|
+
for (const source of sources) {
|
|
474
|
+
yield* source;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
static async *map(source, mapper) {
|
|
478
|
+
for await (const item of source) {
|
|
479
|
+
yield mapper(item);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
static async *peek(source, consumer) {
|
|
483
|
+
for await (const item of source) {
|
|
484
|
+
consumer(item);
|
|
485
|
+
yield item;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
static async collect(source) {
|
|
489
|
+
const result = [];
|
|
490
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
491
|
+
return new Promise((resolve2, reject) => {
|
|
492
|
+
function step() {
|
|
493
|
+
iterator.next().then(({ value, done }) => {
|
|
494
|
+
if (done) {
|
|
495
|
+
resolve2(result);
|
|
496
|
+
} else {
|
|
497
|
+
result.push(value);
|
|
498
|
+
step();
|
|
499
|
+
}
|
|
500
|
+
}).catch(reject);
|
|
501
|
+
}
|
|
502
|
+
step();
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
var AsyncIteratorWrapperImpl = class _AsyncIteratorWrapperImpl {
|
|
507
|
+
constructor(source) {
|
|
508
|
+
this.source = source;
|
|
509
|
+
}
|
|
510
|
+
get() {
|
|
511
|
+
return this.source;
|
|
512
|
+
}
|
|
513
|
+
map(mapper) {
|
|
514
|
+
return new _AsyncIteratorWrapperImpl(async function* (source, mapper2) {
|
|
515
|
+
for await (const item of source) {
|
|
516
|
+
yield mapper2(item);
|
|
517
|
+
}
|
|
518
|
+
}(this.source, mapper));
|
|
519
|
+
}
|
|
520
|
+
flatMap(mapper) {
|
|
521
|
+
return new _AsyncIteratorWrapperImpl(AsyncGeneratorUtil.flatMap(this.source, mapper));
|
|
522
|
+
}
|
|
523
|
+
async forEach(consumer) {
|
|
524
|
+
for await (const item of this.source) {
|
|
525
|
+
await consumer(item);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// src/util/file_tree_clustering.ts
|
|
531
|
+
function clusterFiles(files, clusterMaxSize, clusterMinSize) {
|
|
532
|
+
let fileInfos = files.map((it) => {
|
|
533
|
+
let arr = it.split("/");
|
|
534
|
+
return { arr, str: it };
|
|
535
|
+
});
|
|
536
|
+
let initialCluster = { path: [], files: fileInfos, isLeftovers: false, isUnclusterable: false };
|
|
537
|
+
let clusterGroups = [initialCluster];
|
|
538
|
+
let changes = true;
|
|
539
|
+
function mostFrequent(arr) {
|
|
540
|
+
const counts = {};
|
|
541
|
+
for (const item of arr) {
|
|
542
|
+
counts[item] = (counts[item] || 0) + 1;
|
|
543
|
+
}
|
|
544
|
+
let maxCount = 0;
|
|
545
|
+
let mostFrequentItem = arr[0];
|
|
546
|
+
for (const item in counts) {
|
|
547
|
+
if (counts[item] > maxCount) {
|
|
548
|
+
maxCount = counts[item];
|
|
549
|
+
mostFrequentItem = item;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return mostFrequentItem;
|
|
553
|
+
}
|
|
554
|
+
while (changes) {
|
|
555
|
+
changes = false;
|
|
556
|
+
clusterGroups = clusterGroups.flatMap((originalCluster) => {
|
|
557
|
+
if (originalCluster.files.length > clusterMaxSize && !originalCluster.isUnclusterable) {
|
|
558
|
+
let l = originalCluster.path.length;
|
|
559
|
+
let mf = mostFrequent(originalCluster.files.map((it) => it.arr[l] || "$$$notfound$$$"));
|
|
560
|
+
let newClusterFiles = [];
|
|
561
|
+
let remainingFiles = originalCluster.files.filter((it) => {
|
|
562
|
+
let nextPathSegment = it.arr[l];
|
|
563
|
+
if (nextPathSegment === mf) {
|
|
564
|
+
newClusterFiles.push(it);
|
|
565
|
+
return false;
|
|
566
|
+
} else {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
changes = true;
|
|
571
|
+
if (remainingFiles.length === 0) {
|
|
572
|
+
return [
|
|
573
|
+
{
|
|
574
|
+
path: originalCluster.path.concat([mf]),
|
|
575
|
+
files: newClusterFiles,
|
|
576
|
+
isLeftovers: false
|
|
577
|
+
}
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
if (newClusterFiles.length < clusterMinSize || remainingFiles.length < clusterMinSize) {
|
|
581
|
+
return [{ ...originalCluster, isUnclusterable: true }];
|
|
582
|
+
}
|
|
583
|
+
return [
|
|
584
|
+
{
|
|
585
|
+
path: originalCluster.path.concat([mf]),
|
|
586
|
+
files: newClusterFiles,
|
|
587
|
+
isLeftovers: false
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
path: originalCluster.path,
|
|
591
|
+
files: remainingFiles,
|
|
592
|
+
isLeftovers: true
|
|
593
|
+
}
|
|
594
|
+
];
|
|
595
|
+
} else {
|
|
596
|
+
return [originalCluster];
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return clusterGroups.map((cluster) => {
|
|
601
|
+
let files2 = cluster.files;
|
|
602
|
+
let path5 = cluster.path;
|
|
603
|
+
let files1 = files2.map((it) => it.str);
|
|
604
|
+
return {
|
|
605
|
+
path: path5.join("/"),
|
|
606
|
+
files: files1,
|
|
607
|
+
weight: files1.length,
|
|
608
|
+
isLeftovers: cluster.isLeftovers
|
|
609
|
+
};
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/index.ts
|
|
614
|
+
var sigintCaught = false;
|
|
615
|
+
function getDirectories(source) {
|
|
616
|
+
if (!fs4.existsSync(source) || !fs4.statSync(source).isDirectory()) return [];
|
|
617
|
+
const ignoredDirs = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
618
|
+
try {
|
|
619
|
+
return fs4.readdirSync(source, { withFileTypes: true }).filter((dirent) => dirent.isDirectory() && !ignoredDirs.has(dirent.name)).map((dirent) => path4.join(source, dirent.name));
|
|
620
|
+
} catch (error) {
|
|
621
|
+
console.error(`Could not read directory: ${source}`);
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async function* forEachRepoFile(repoPath, doProcessFile) {
|
|
626
|
+
console.error(`
|
|
627
|
+
Processing repository: ${repoPath || "."}`);
|
|
628
|
+
const originalCwd = process.cwd();
|
|
629
|
+
const discoveryPath = path4.resolve(originalCwd, repoPath);
|
|
630
|
+
if (!fs4.existsSync(discoveryPath)) {
|
|
631
|
+
console.error(`Error: Path does not exist: ${discoveryPath}. Skipping.`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const gitCommandPath = fs4.statSync(discoveryPath).isDirectory() ? discoveryPath : path4.dirname(discoveryPath);
|
|
635
|
+
let repoRoot;
|
|
636
|
+
try {
|
|
637
|
+
repoRoot = (0, import_child_process2.execSync)("git rev-parse --show-toplevel", { cwd: gitCommandPath, stdio: "pipe" }).toString().trim();
|
|
638
|
+
} catch (e) {
|
|
639
|
+
console.error(`Error: Could not find git repository at ${gitCommandPath}. Skipping.`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const repoName = path4.basename(repoRoot);
|
|
643
|
+
let revisionBoundary = await findRevision(repoRoot, 1e3);
|
|
644
|
+
const finalTargetPath = path4.relative(repoRoot, discoveryPath);
|
|
645
|
+
const { stdout: lsFilesOut } = await execAsync(
|
|
646
|
+
"git",
|
|
647
|
+
["ls-files", "--", finalTargetPath || "."],
|
|
648
|
+
{ cwd: repoRoot }
|
|
649
|
+
);
|
|
650
|
+
const files = lsFilesOut.filter((line) => line && line.length > 0);
|
|
651
|
+
let minClusterSize = Math.floor(Math.max(5, files.length / 1e3));
|
|
652
|
+
let maxClusterSize = Math.round(Math.max(20, minClusterSize * 2));
|
|
653
|
+
console.error(`Clustering ${files.length} into ${minClusterSize}..${maxClusterSize}+ sized chunks`);
|
|
654
|
+
const filesClustered = clusterFiles(
|
|
655
|
+
files,
|
|
656
|
+
maxClusterSize,
|
|
657
|
+
minClusterSize
|
|
658
|
+
);
|
|
659
|
+
console.error(filesClustered.map((it) => `${it.path}${it.isLeftovers ? "/*" : ""} (${it.weight})`));
|
|
660
|
+
let clusterPaths = filesClustered.map((it) => it.path);
|
|
661
|
+
console.error(`Found ${files.length} files to analyze in '${repoName}'...`);
|
|
662
|
+
let filesShuffled = [...files].sort(() => Math.random() - 0.5);
|
|
663
|
+
for (let i = 0; i < files.length; i++) {
|
|
664
|
+
if (sigintCaught) break;
|
|
665
|
+
const file = filesShuffled[i];
|
|
666
|
+
const progressMessage = `[${i + 1}/${files.length}] Analyzing: ${file}`;
|
|
667
|
+
process.stderr.write(progressMessage.padEnd(process.stderr.columns || 80, " ") + "\r");
|
|
668
|
+
try {
|
|
669
|
+
let clusterPath = clusterPaths.find((it) => file.startsWith(it)) ?? "$$$unknown$$$";
|
|
670
|
+
yield* (await doProcessFile(repoRoot, file, revisionBoundary)).map((it) => it.concat(clusterPath));
|
|
671
|
+
} catch (e) {
|
|
672
|
+
if (e.signal === "SIGINT") sigintCaught = true;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
process.stderr.write(" ".repeat(process.stderr.columns || 80) + "\r");
|
|
676
|
+
console.error(`Analysis complete for '${repoName}'.`);
|
|
677
|
+
}
|
|
678
|
+
function daysAgo(epoch) {
|
|
679
|
+
const now = Date.now();
|
|
680
|
+
const diff = now - epoch * 1e3;
|
|
681
|
+
return Math.floor(diff / (1e3 * 60 * 60 * 24));
|
|
682
|
+
}
|
|
683
|
+
function bucket(n, buckets) {
|
|
684
|
+
for (let i = 1; i < buckets.length; i++) {
|
|
685
|
+
if (n > buckets[i - 1] && n < buckets[i]) return buckets[i - 1];
|
|
686
|
+
}
|
|
687
|
+
return -1;
|
|
688
|
+
}
|
|
689
|
+
async function doProcessFile1(repoRoot, filePath, revisionBoundary) {
|
|
690
|
+
if (!filePath) return [];
|
|
691
|
+
const absPath = path4.join(repoRoot, filePath);
|
|
692
|
+
let stat = null;
|
|
693
|
+
try {
|
|
694
|
+
stat = fs4.statSync(absPath);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
console.error(`Fail get stats for file ${absPath}`, e.stack || e.message || e);
|
|
697
|
+
}
|
|
698
|
+
if (!stat || !stat.isFile() || stat.size === 0) return [];
|
|
699
|
+
const result = [];
|
|
700
|
+
for (const item of await git_blame_porcelain(filePath, repoRoot, ["author", "committer-time", "commit"], revisionBoundary + "..HEAD")) {
|
|
701
|
+
if (revisionBoundary === item[2]) {
|
|
702
|
+
item[0] = "?";
|
|
703
|
+
item[1] = 0;
|
|
704
|
+
item[2] = "0".repeat(40);
|
|
705
|
+
}
|
|
706
|
+
const lang = path4.extname(filePath) || "Other";
|
|
707
|
+
let days_bucket = bucket(daysAgo(item[1]), [0, 30, 300, 1e3, 1e6]);
|
|
708
|
+
if (days_bucket != -1) {
|
|
709
|
+
result.push([item[0], days_bucket, lang, filePath, repoRoot]);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return result;
|
|
713
|
+
}
|
|
714
|
+
async function* distinctCount(source) {
|
|
715
|
+
const map = /* @__PURE__ */ new Map();
|
|
716
|
+
for await (const row of source) {
|
|
717
|
+
const key = JSON.stringify(row);
|
|
718
|
+
if (map.has(key)) {
|
|
719
|
+
map.get(key).count += 1;
|
|
720
|
+
} else {
|
|
721
|
+
map.set(key, { row, count: 1 });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
for (const { row, count } of map.values()) {
|
|
725
|
+
yield [...row, count];
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
function getRepoPathsToProcess(inputPaths) {
|
|
729
|
+
let repoPathsToProcess = inputPaths.flatMap((it) => findRepositories(it, 3));
|
|
730
|
+
repoPathsToProcess = [...new Set(repoPathsToProcess)].sort();
|
|
731
|
+
if (repoPathsToProcess.length === 0) {
|
|
732
|
+
throw new Error("No git repositories found to analyze.");
|
|
733
|
+
}
|
|
734
|
+
console.error(`Found ${repoPathsToProcess.length} repositories to analyze:`);
|
|
735
|
+
repoPathsToProcess.forEach((p) => console.error(`- ${p || "."}`));
|
|
736
|
+
return repoPathsToProcess;
|
|
737
|
+
}
|
|
738
|
+
async function runScan(args) {
|
|
739
|
+
const tmpVfs = new RealFileSystemImpl("./.git-stats/");
|
|
740
|
+
process.on("SIGINT", () => {
|
|
741
|
+
if (sigintCaught) {
|
|
742
|
+
console.error("\nForcing exit.");
|
|
743
|
+
process.exit(130);
|
|
744
|
+
}
|
|
745
|
+
sigintCaught = true;
|
|
746
|
+
console.error("\nSignal received. Finishing current file then stopping. Press Ctrl+C again to exit immediately.");
|
|
747
|
+
});
|
|
748
|
+
const inputPaths = args && args.length > 0 ? args : ["."];
|
|
749
|
+
let repoPathsToProcess = getRepoPathsToProcess(inputPaths);
|
|
750
|
+
await tmpVfs.write("data.jsonl", "");
|
|
751
|
+
let dataSet = new AsyncIteratorWrapperImpl(AsyncGeneratorUtil.of(repoPathsToProcess)).flatMap((repoPath) => forEachRepoFile(repoPath, doProcessFile1)).map((it) => [it[0], it[1], it[2], it[5], path4.basename(it[4])]).get();
|
|
752
|
+
let aggregatedData1 = distinctCount(dataSet);
|
|
753
|
+
let aggregatedData = await AsyncGeneratorUtil.collect(aggregatedData1);
|
|
754
|
+
aggregatedData.forEach((it) => console.log(JSON.stringify(it)));
|
|
755
|
+
}
|
|
756
|
+
async function runHtml(args) {
|
|
757
|
+
const inputPath = args[0] || path4.resolve("./.git-stats/data.jsonl");
|
|
758
|
+
const outHtml = path4.resolve("./.git-stats/report.html");
|
|
759
|
+
if (!fs4.existsSync(inputPath)) {
|
|
760
|
+
console.error(`Input data file not found: ${inputPath}`);
|
|
761
|
+
process.exitCode = 1;
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const lines = fs4.readFileSync(inputPath, "utf8").split(/\r?\n/).filter(Boolean);
|
|
765
|
+
const aggregatedData = lines.map((line) => {
|
|
766
|
+
try {
|
|
767
|
+
return JSON.parse(line);
|
|
768
|
+
} catch {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
}).filter(Boolean);
|
|
772
|
+
generateHtmlReport(aggregatedData, outHtml);
|
|
773
|
+
console.error(`HTML report generated: ${outHtml}`);
|
|
774
|
+
}
|
|
775
|
+
function findRepositories(path5, depth) {
|
|
776
|
+
if (depth <= 0) return [];
|
|
777
|
+
if (!fs4.existsSync(path5)) throw new Error(`Path does not exist: ${path5}`);
|
|
778
|
+
if (!fs4.statSync(path5).isDirectory()) return [];
|
|
779
|
+
if (isGitRepo(path5)) return [path5];
|
|
780
|
+
let result = getDirectories(path5).flatMap((dir) => findRepositories(dir, depth - 1));
|
|
781
|
+
return [...new Set(result)].sort();
|
|
782
|
+
}
|
|
783
|
+
async function main() {
|
|
784
|
+
const argv = process.argv.slice(2);
|
|
785
|
+
const isHtml = argv[0] === "html";
|
|
786
|
+
const subArgs = isHtml ? argv.slice(1) : argv;
|
|
787
|
+
if (isHtml) {
|
|
788
|
+
await runHtml(subArgs);
|
|
789
|
+
} else {
|
|
790
|
+
await runScan(subArgs);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
main().catch(console.error);
|
|
794
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
795
|
+
0 && (module.exports = {
|
|
796
|
+
distinctCount
|
|
797
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vladimirshefer/git-stats",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A CLI to generate git blame stats for a repository",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-stats": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/**",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE*"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node build.mjs",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20.0.0",
|
|
24
|
+
"vitest": "^1.6.0",
|
|
25
|
+
"esbuild": "0.25.0",
|
|
26
|
+
"typescript": "^5.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|