@vladimirshefer/git-stats 0.0.1 → 0.0.2
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 +694 -364
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,10 +6,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __getProtoOf = Object.getPrototypeOf;
|
|
8
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
9
|
var __copyProps = (to, from, except, desc) => {
|
|
14
10
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
11
|
for (let key of __getOwnPropNames(from))
|
|
@@ -26,64 +22,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
22
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
23
|
mod
|
|
28
24
|
));
|
|
29
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
25
|
|
|
31
26
|
// src/index.ts
|
|
32
|
-
var
|
|
33
|
-
|
|
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"));
|
|
27
|
+
var fs3 = __toESM(require("fs"));
|
|
28
|
+
var path3 = __toESM(require("path"));
|
|
87
29
|
|
|
88
30
|
// src/output/report_template.ts
|
|
89
31
|
var path = __toESM(require("path"));
|
|
@@ -100,25 +42,46 @@ var report_template_default = `<!DOCTYPE html>
|
|
|
100
42
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
101
43
|
<script src="https://cdn.plot.ly/plotly-3.3.0.min.js" charset="utf-8"></script>
|
|
102
44
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
103
|
-
<!--
|
|
104
|
-
<script crossorigin src="https://unpkg.com/
|
|
105
|
-
<script crossorigin src="https://unpkg.com/
|
|
45
|
+
<!-- Preact UMD build -->
|
|
46
|
+
<script crossorigin src="https://unpkg.com/preact@10/dist/preact.umd.js"></script>
|
|
47
|
+
<script crossorigin src="https://unpkg.com/preact@10/hooks/dist/hooks.umd.js"></script>
|
|
106
48
|
<!-- Babel Standalone for in-browser JSX transform -->
|
|
107
49
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
108
50
|
</head>
|
|
109
51
|
<body class="font-sans m-0 bg-gray-50 text-gray-900">
|
|
110
52
|
<div id="root"></div>
|
|
53
|
+
</body>
|
|
111
54
|
<script type="text/babel" data-presets="react">
|
|
55
|
+
// Preact compat aliases
|
|
56
|
+
const React = window.preact;
|
|
57
|
+
const ReactDOM = {
|
|
58
|
+
createRoot: (container) => ({
|
|
59
|
+
render: (element) => window.preact.render(element, container)
|
|
60
|
+
})
|
|
61
|
+
};
|
|
62
|
+
React.useRef = window.preactHooks.useRef;
|
|
63
|
+
React.useEffect = window.preactHooks.useEffect;
|
|
64
|
+
React.useState = window.preactHooks.useState;
|
|
65
|
+
React.useMemo = window.preactHooks.useMemo;
|
|
66
|
+
|
|
112
67
|
const RAW_DATASET =
|
|
113
68
|
__DATASET_JSON__
|
|
114
|
-
|| [];
|
|
69
|
+
|| [[1,2,3,4,5,6,7,8,9]];
|
|
115
70
|
// Fixed schema as per pipeline: [author, days_bucket, lang, clusterPath, repoName, count]
|
|
116
|
-
const
|
|
71
|
+
const RAW_DATASET_SCHEMA = ["author", "days_bucket", "lang", "clusterPath", "repoName", "count"];
|
|
72
|
+
const CLUSTER_COLUMN = RAW_DATASET_SCHEMA.indexOf("clusterPath");
|
|
73
|
+
const REPO_COLUMN = RAW_DATASET_SCHEMA.indexOf("repoName");
|
|
117
74
|
// -------- Client-side grouping/filtering engine --------
|
|
118
|
-
const KEY_INDEX = Object.fromEntries(
|
|
75
|
+
const KEY_INDEX = Object.fromEntries(RAW_DATASET_SCHEMA.map((k, i) => [k, i]));
|
|
76
|
+
const COLUMNS_AMOUNT = RAW_DATASET[0].length - 1;
|
|
77
|
+
const COLUMNS_IDX_ARRAY = Array(COLUMNS_AMOUNT).fill(-1).map((_, i) => i);
|
|
119
78
|
const COUNT_IDX_FROM_END = 1; // last element is count
|
|
79
|
+
const COLUMN_COMPARATORS = COLUMNS_IDX_ARRAY.map(idx => {
|
|
80
|
+
let isNumber = typeof RAW_DATASET?.[0]?.[idx] === "number";
|
|
81
|
+
return isNumber ? ((a, b) => (a || 0) - (b || 0)) : (a, b) => String(a).localeCompare(String(b));
|
|
82
|
+
})
|
|
83
|
+
const UNIQUE_VALUES = COLUMNS_IDX_ARRAY.map((idx) => uniqueValues(RAW_DATASET, idx).sort(COLUMN_COMPARATORS[idx]))
|
|
120
84
|
|
|
121
|
-
const allKeys = HEADER_LABELS.filter(k => k !== 'count');
|
|
122
85
|
const TOP_N = 20;
|
|
123
86
|
const BUCKET_COLORS = [
|
|
124
87
|
'rgba(214, 40, 40, 0.7)',
|
|
@@ -136,110 +99,356 @@ var report_template_default = `<!DOCTYPE html>
|
|
|
136
99
|
return Array.from(set);
|
|
137
100
|
}
|
|
138
101
|
|
|
102
|
+
function matchesFilters(row, filters) {
|
|
103
|
+
for (let idx = 0; idx < COLUMNS_AMOUNT; idx++) {
|
|
104
|
+
const sel = filters[idx];
|
|
105
|
+
if (sel && !sel.has(String(row[idx]))) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
139
112
|
function pivot(
|
|
140
113
|
dataset,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
filterState
|
|
114
|
+
column1,
|
|
115
|
+
column2
|
|
144
116
|
) {
|
|
145
|
-
const
|
|
146
|
-
const totals = new Map();
|
|
117
|
+
const grouped2 = new Map();
|
|
147
118
|
const secValuesSet = new Set();
|
|
148
119
|
for (const row of dataset) {
|
|
149
|
-
|
|
150
|
-
|
|
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];
|
|
120
|
+
const c1 = row[column1];
|
|
121
|
+
const c2 = row[column2];
|
|
161
122
|
const count = Number(row[row.length - COUNT_IDX_FROM_END]) || 0;
|
|
162
|
-
if (!
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
secValuesSet.add(sk);
|
|
123
|
+
if (!grouped2.has(c1)) grouped2.set(c1, new Map());
|
|
124
|
+
grouped2.get(c1).set(c2, (grouped2.get(c1).get(c2) || 0) + count);
|
|
125
|
+
secValuesSet.add(c2);
|
|
166
126
|
}
|
|
167
|
-
const
|
|
127
|
+
const primaryTotals = new Map();
|
|
128
|
+
for (const [c1, innerMap] of grouped2) {
|
|
129
|
+
let total = 0;
|
|
130
|
+
for (const val of innerMap.values()) total += val;
|
|
131
|
+
primaryTotals.set(c1, total);
|
|
132
|
+
}
|
|
133
|
+
const primaryKeys = Array.from(grouped2.keys()).sort((a, b) => (primaryTotals.get(b) || 0) - (primaryTotals.get(a) || 0));
|
|
168
134
|
const secondaryKeys = Array.from(secValuesSet).sort((a, b) => String(a).localeCompare(String(b)));
|
|
169
|
-
return {
|
|
135
|
+
return {grouped2, primaryKeys, secondaryKeys};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function computeColumnTotals(dataset, columnIdx) {
|
|
139
|
+
const value2total = new Map();
|
|
140
|
+
const otherColumnContributions = new Map(); // Map<key, Map<otherColumnIdx, Map<otherValue, count>>>
|
|
141
|
+
|
|
142
|
+
for (const row of dataset) {
|
|
143
|
+
const value = row[columnIdx];
|
|
144
|
+
const count = Number(row[row.length - 1]) || 0;
|
|
145
|
+
value2total.set(value, (value2total.get(value) || 0) + count);
|
|
146
|
+
|
|
147
|
+
// Track contributions from other columns
|
|
148
|
+
if (!otherColumnContributions.has(value)) {
|
|
149
|
+
otherColumnContributions.set(value, new Map());
|
|
150
|
+
}
|
|
151
|
+
const keyContribs = otherColumnContributions.get(value);
|
|
152
|
+
|
|
153
|
+
for (let otherIdx = 0; otherIdx < COLUMNS_AMOUNT; otherIdx++) {
|
|
154
|
+
if (otherIdx === columnIdx) continue;
|
|
155
|
+
|
|
156
|
+
if (!keyContribs.has(otherIdx)) {
|
|
157
|
+
keyContribs.set(otherIdx, new Map());
|
|
158
|
+
}
|
|
159
|
+
const otherColMap = keyContribs.get(otherIdx);
|
|
160
|
+
const otherValue = row[otherIdx];
|
|
161
|
+
otherColMap.set(otherValue, (otherColMap.get(otherValue) || 0) + count);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sorted = Array.from(value2total.entries()).sort((a, b) => b[1] - a[1]);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
keys: sorted.map(([k]) => k),
|
|
169
|
+
values: sorted.map(([, v]) => v),
|
|
170
|
+
totals: value2total,
|
|
171
|
+
contributions: otherColumnContributions
|
|
172
|
+
};
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
// ---------- React Components ----------
|
|
173
|
-
function
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
React.
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
176
|
+
function ColumnTotalCard({columnName, columnIdx, keys, values, totals, contributions}) {
|
|
177
|
+
const containerRef = React.useRef(null);
|
|
178
|
+
|
|
179
|
+
const {labels, hoverText} = React.useMemo(() => {
|
|
180
|
+
const labels = [];
|
|
181
|
+
const hoverText = [];
|
|
182
|
+
|
|
183
|
+
keys.forEach((k) => {
|
|
184
|
+
const total = totals.get(k);
|
|
185
|
+
const keyContribs = contributions.get(k);
|
|
186
|
+
const topContribs = [];
|
|
187
|
+
|
|
188
|
+
for (let otherIdx = 0; otherIdx < COLUMNS_AMOUNT; otherIdx++) {
|
|
189
|
+
if (otherIdx === columnIdx) continue;
|
|
190
|
+
const otherColMap = keyContribs?.get(otherIdx);
|
|
191
|
+
if (otherColMap) {
|
|
192
|
+
const top3 = Array.from(otherColMap.entries())
|
|
193
|
+
.sort((a, b) => b[1] - a[1])
|
|
194
|
+
.slice(0, 3)
|
|
195
|
+
.map(([val, cnt]) => \`\${val}(\${(cnt / total * 100.0).toFixed(1)}%)\`)
|
|
196
|
+
.join('<br>-');
|
|
197
|
+
if (top3) {
|
|
198
|
+
topContribs.push(\`\${RAW_DATASET_SCHEMA[otherIdx]}<br>-\` + top3);
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
}
|
|
202
|
+
|
|
203
|
+
const label = \`\${String(k)} (\${total})\`;
|
|
204
|
+
labels.push(label);
|
|
205
|
+
hoverText.push(topContribs.length > 0 ? \`\${label}<br>\${topContribs.join('<br>')}\` : label);
|
|
200
206
|
});
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
|
|
208
|
+
return {labels, hoverText};
|
|
209
|
+
}, [columnName, columnIdx, keys, totals, contributions]);
|
|
210
|
+
|
|
211
|
+
React.useEffect(() => {
|
|
212
|
+
const el = containerRef.current;
|
|
213
|
+
if (!el) return;
|
|
214
|
+
if (!labels.length) {
|
|
215
|
+
el.innerHTML = '<div class="text-gray-500 py-6 text-center">No data to display</div>';
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const trace = {
|
|
219
|
+
type: 'sunburst',
|
|
220
|
+
labels: labels,
|
|
221
|
+
parents: labels.map(() => ''),
|
|
222
|
+
values: values,
|
|
223
|
+
hovertext: hoverText,
|
|
224
|
+
hovertemplate: '%{hovertext}<extra></extra>',
|
|
225
|
+
branchvalues: 'total'
|
|
203
226
|
};
|
|
204
|
-
|
|
227
|
+
const layout = {
|
|
228
|
+
margin: {l: 0, r: 0, t: 10, b: 10},
|
|
229
|
+
sunburstcolorway: ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#06B6D4', '#8B5CF6', '#F43F5E'],
|
|
230
|
+
extendsunburstcolors: true,
|
|
231
|
+
height: 300
|
|
232
|
+
};
|
|
233
|
+
const config = {responsive: true, displayModeBar: false};
|
|
234
|
+
if (window.Plotly && window.Plotly.newPlot) {
|
|
235
|
+
window.Plotly.newPlot(el, [trace], layout, config);
|
|
236
|
+
}
|
|
237
|
+
}, [labels, hoverText, values]);
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
241
|
+
<h3 className="text-lg font-semibold mb-3 text-gray-800">{columnName} - Total</h3>
|
|
242
|
+
<div ref={containerRef} className="w-full"/>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------- Sunburst over repository paths (clusterPath) ----------
|
|
248
|
+
function buildSunburst(dataset, filterState) {
|
|
249
|
+
const filtered = dataset.filter(row => matchesFilters(row, filterState));
|
|
250
|
+
|
|
251
|
+
const PSEUDO_ROOT = "@";
|
|
252
|
+
// Aggregate counts per path and propagate sums up the tree
|
|
253
|
+
const leafSums = new Map(); // fullPath -> sum
|
|
254
|
+
const allPaths = new Set(); // includes all prefixes (no empty)
|
|
255
|
+
|
|
256
|
+
for (const row of filtered) {
|
|
257
|
+
const rawPath = String(row[REPO_COLUMN] + "/" + row[CLUSTER_COLUMN] ?? '').trim();
|
|
258
|
+
const cnt = Number(row[COLUMNS_AMOUNT]) || 0;
|
|
259
|
+
if (!rawPath) continue;
|
|
260
|
+
const segs = rawPath.split('/').filter(s => s && s !== '.');
|
|
261
|
+
if (segs.length === 0) continue;
|
|
262
|
+
const full = segs.join('/');
|
|
263
|
+
leafSums.set(full, (leafSums.get(full) || 0) + cnt);
|
|
264
|
+
// Collect all prefix nodes for structure
|
|
265
|
+
for (let i = 0; i < segs.length; i++) {
|
|
266
|
+
const p = segs.slice(0, i + 1).join('/');
|
|
267
|
+
allPaths.add(p);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (allPaths.size === 0) return {ids: [], labels: [], parents: [], values: []};
|
|
272
|
+
|
|
273
|
+
// Compute total values for every node as sum of its descendant leaves
|
|
274
|
+
const totals = new Map(Array.from(allPaths, p => [p, 0]));
|
|
275
|
+
for (const [leaf, v] of leafSums) {
|
|
276
|
+
const segs = leaf.split('/');
|
|
277
|
+
for (let i = 0; i < segs.length; i++) {
|
|
278
|
+
const p = segs.slice(0, i + 1).join('/');
|
|
279
|
+
totals.set(p, (totals.get(p) || 0) + v);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Build Plotly arrays
|
|
284
|
+
const ids = [];
|
|
285
|
+
const labels = [];
|
|
286
|
+
const parents = [];
|
|
287
|
+
const values = [];
|
|
288
|
+
|
|
289
|
+
// Ensure stable ordering: sort by path length then alphabetically
|
|
290
|
+
const ordered = Array.from(allPaths);
|
|
291
|
+
ordered.sort((a, b) => {
|
|
292
|
+
const da = a.split('/').length, db = b.split('/').length;
|
|
293
|
+
if (da !== db) return da - db;
|
|
294
|
+
return a.localeCompare(b);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
for (const id of ordered) {
|
|
298
|
+
const segs = id.split('/');
|
|
299
|
+
const label = segs[segs.length - 1] || id;
|
|
300
|
+
const parent = segs.length > 1 ? segs.slice(0, -1).join('/') : '';
|
|
301
|
+
ids.push(id);
|
|
302
|
+
labels.push(label);
|
|
303
|
+
parents.push(parent);
|
|
304
|
+
values.push(totals.get(id) || 0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {ids, labels, parents, values};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function SunburstPaths({dataset, filters}) {
|
|
311
|
+
const containerRef = React.useRef(null);
|
|
312
|
+
const data = React.useMemo(() => buildSunburst(dataset, filters), [dataset, filters]);
|
|
205
313
|
|
|
206
314
|
React.useEffect(() => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
315
|
+
const el = containerRef.current;
|
|
316
|
+
if (!el) return;
|
|
317
|
+
if (!data.ids.length) {
|
|
318
|
+
el.innerHTML = '<div class="text-gray-500 py-6 text-center">No path data to display</div>';
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const trace = {
|
|
322
|
+
type: 'sunburst',
|
|
323
|
+
ids: data.ids,
|
|
324
|
+
labels: data.labels,
|
|
325
|
+
parents: data.parents,
|
|
326
|
+
values: data.values,
|
|
327
|
+
branchvalues: 'total',
|
|
328
|
+
maxdepth: 3
|
|
329
|
+
};
|
|
330
|
+
const layout = {
|
|
331
|
+
margin: {l: 0, r: 0, t: 10, b: 10},
|
|
332
|
+
sunburstcolorway: ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#06B6D4', '#8B5CF6', '#F43F5E'],
|
|
333
|
+
extendsunburstcolors: true,
|
|
334
|
+
height: 400
|
|
335
|
+
};
|
|
336
|
+
const config = {responsive: true, displayModeBar: false};
|
|
337
|
+
// Use Plotly from global
|
|
338
|
+
if (window.Plotly && window.Plotly.newPlot) {
|
|
339
|
+
window.Plotly.newPlot(el, [trace], layout, config);
|
|
340
|
+
}
|
|
341
|
+
}, [data]);
|
|
212
342
|
|
|
213
|
-
return <
|
|
343
|
+
return <div ref={containerRef} className="w-full"/>;
|
|
214
344
|
}
|
|
215
345
|
|
|
216
346
|
function MultiSelect({label, values, selectedSet, onChange}) {
|
|
347
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
348
|
+
const [searchTerm, setSearchTerm] = React.useState('');
|
|
349
|
+
const dropdownRef = React.useRef(null);
|
|
350
|
+
|
|
351
|
+
React.useEffect(() => {
|
|
352
|
+
function handleClickOutside(event) {
|
|
353
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
|
354
|
+
setIsOpen(false);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
359
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
360
|
+
}, []);
|
|
361
|
+
|
|
362
|
+
const filteredValues = React.useMemo(() => {
|
|
363
|
+
if (!searchTerm) return values;
|
|
364
|
+
const lower = searchTerm.toLowerCase();
|
|
365
|
+
return values.filter(v => String(v).toLowerCase().includes(lower));
|
|
366
|
+
}, [values, searchTerm]);
|
|
367
|
+
|
|
217
368
|
const selCount = selectedSet.size;
|
|
218
369
|
const total = values.length;
|
|
370
|
+
|
|
371
|
+
const toggleValue = (val) => {
|
|
372
|
+
const newSet = new Set(selectedSet);
|
|
373
|
+
if (newSet.has(val)) {
|
|
374
|
+
newSet.delete(val);
|
|
375
|
+
} else {
|
|
376
|
+
newSet.add(val);
|
|
377
|
+
}
|
|
378
|
+
onChange(newSet);
|
|
379
|
+
};
|
|
380
|
+
|
|
219
381
|
return (
|
|
220
|
-
<div>
|
|
382
|
+
<div ref={dropdownRef} className="relative">
|
|
221
383
|
<label className="block font-semibold mb-1.5">
|
|
222
384
|
{label}{' '}
|
|
223
385
|
<span className="text-gray-600 font-normal">({selCount}/{total})</span>
|
|
224
386
|
</label>
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
}}
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
390
|
+
className="w-full px-3 py-2 text-left bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
233
391
|
>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
392
|
+
<span className="text-gray-700">
|
|
393
|
+
{selCount === 0 ? 'Select...' : selCount === total ? 'All selected' : \`\${selCount} selected\`}
|
|
394
|
+
</span>
|
|
395
|
+
<span className="float-right">\u25BC</span>
|
|
396
|
+
</button>
|
|
397
|
+
{isOpen && (
|
|
398
|
+
<div
|
|
399
|
+
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-hidden">
|
|
400
|
+
<div className="p-2 border-b border-gray-200">
|
|
401
|
+
<input
|
|
402
|
+
type="text"
|
|
403
|
+
placeholder="Search..."
|
|
404
|
+
value={searchTerm}
|
|
405
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
406
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
407
|
+
onClick={(e) => e.stopPropagation()}
|
|
408
|
+
/>
|
|
409
|
+
<div className="flex gap-2 mt-2">
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
onClick={() => onChange(new Set(values.map(String)))}
|
|
413
|
+
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
414
|
+
>
|
|
415
|
+
Select All
|
|
416
|
+
</button>
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
onClick={() => onChange(new Set())}
|
|
420
|
+
className="flex-1 px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
|
421
|
+
>
|
|
422
|
+
Unselect All
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
<div className="overflow-y-auto max-h-64">
|
|
427
|
+
{filteredValues.length === 0 ? (
|
|
428
|
+
<div className="px-3 py-2 text-gray-500 text-center">No matches found</div>
|
|
429
|
+
) : (
|
|
430
|
+
filteredValues.map(v => {
|
|
431
|
+
const val = String(v);
|
|
432
|
+
const isChecked = selectedSet.has(val);
|
|
433
|
+
return (
|
|
434
|
+
<label
|
|
435
|
+
key={val}
|
|
436
|
+
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
|
|
437
|
+
>
|
|
438
|
+
<input
|
|
439
|
+
type="checkbox"
|
|
440
|
+
checked={isChecked}
|
|
441
|
+
onChange={() => toggleValue(val)}
|
|
442
|
+
className="mr-2 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
|
443
|
+
/>
|
|
444
|
+
<span className="text-gray-900">{val}</span>
|
|
445
|
+
</label>
|
|
446
|
+
);
|
|
447
|
+
})
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
243
452
|
</div>
|
|
244
453
|
);
|
|
245
454
|
}
|
|
@@ -248,14 +457,9 @@ var report_template_default = `<!DOCTYPE html>
|
|
|
248
457
|
const {initialFilters, valueOptions} = React.useMemo(() => {
|
|
249
458
|
const filters = {};
|
|
250
459
|
const options = {};
|
|
251
|
-
for (
|
|
252
|
-
|
|
253
|
-
|
|
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;
|
|
460
|
+
for (let idx = 0; idx < COLUMNS_AMOUNT; idx++) {
|
|
461
|
+
filters[idx] = new Set(UNIQUE_VALUES[idx].map(v => String(v)));
|
|
462
|
+
options[idx] = UNIQUE_VALUES[idx];
|
|
259
463
|
}
|
|
260
464
|
return {initialFilters: filters, valueOptions: options};
|
|
261
465
|
}, []);
|
|
@@ -264,11 +468,12 @@ var report_template_default = `<!DOCTYPE html>
|
|
|
264
468
|
const [secondaryKeyIndex, setSecondaryKeyIndex] = React.useState(1);
|
|
265
469
|
|
|
266
470
|
const {chartLabels, datasets} = React.useMemo(() => {
|
|
471
|
+
const filteredDataset = RAW_DATASET.filter(row => matchesFilters(row, filters));
|
|
267
472
|
const {
|
|
268
|
-
|
|
473
|
+
grouped2: pv,
|
|
269
474
|
primaryKeys,
|
|
270
475
|
secondaryKeys
|
|
271
|
-
} = pivot(
|
|
476
|
+
} = pivot(filteredDataset, primaryKeyIndex, secondaryKeyIndex);
|
|
272
477
|
const chartPrimaryKeys = primaryKeys.slice(0, TOP_N);
|
|
273
478
|
const ds = secondaryKeys.map((sk, i) => ({
|
|
274
479
|
label: String(sk),
|
|
@@ -287,8 +492,8 @@ var report_template_default = `<!DOCTYPE html>
|
|
|
287
492
|
<label>
|
|
288
493
|
Primary group:
|
|
289
494
|
<select value={primaryKeyIndex} onChange={e => setPrimaryKeyIndex(Number(e.target.value))}>
|
|
290
|
-
{
|
|
291
|
-
<option key={i} value={i}>{
|
|
495
|
+
{COLUMNS_IDX_ARRAY.map((__, i) => (
|
|
496
|
+
<option key={i} value={i}>{RAW_DATASET_SCHEMA[i]}</option>
|
|
292
497
|
))}
|
|
293
498
|
</select>
|
|
294
499
|
</label>
|
|
@@ -296,27 +501,48 @@ var report_template_default = `<!DOCTYPE html>
|
|
|
296
501
|
Secondary group:
|
|
297
502
|
<select value={secondaryKeyIndex}
|
|
298
503
|
onChange={e => setSecondaryKeyIndex(Number(e.target.value))}>
|
|
299
|
-
{
|
|
300
|
-
<option key={i} value={i}>{
|
|
504
|
+
{COLUMNS_IDX_ARRAY.map((__, i) => (
|
|
505
|
+
<option key={i} value={i}>{RAW_DATASET_SCHEMA[i]}</option>
|
|
301
506
|
))}
|
|
302
507
|
</select>
|
|
303
508
|
</label>
|
|
304
509
|
</div>
|
|
305
510
|
<div id="filters" className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-3">
|
|
306
|
-
{
|
|
511
|
+
{COLUMNS_IDX_ARRAY.map((__, idx) => (
|
|
307
512
|
<MultiSelect
|
|
308
513
|
key={idx}
|
|
309
|
-
label={
|
|
310
|
-
values={valueOptions[
|
|
311
|
-
selectedSet={filters[
|
|
312
|
-
onChange={(newSet) => setFilters(prev => ({...prev, [
|
|
514
|
+
label={RAW_DATASET_SCHEMA[idx]}
|
|
515
|
+
values={valueOptions[idx]}
|
|
516
|
+
selectedSet={filters[idx]}
|
|
517
|
+
onChange={(newSet) => setFilters(prev => ({...prev, [idx]: newSet}))}
|
|
313
518
|
/>
|
|
314
519
|
))}
|
|
315
520
|
</div>
|
|
316
521
|
</div>
|
|
317
|
-
<div className="mt-
|
|
318
|
-
<h2
|
|
319
|
-
<
|
|
522
|
+
<div className="mt-8">
|
|
523
|
+
<h2 className="border-b border-gray-300 pb-2.5">Column Totals</h2>
|
|
524
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
|
525
|
+
{COLUMNS_IDX_ARRAY.map((__, idx) => {
|
|
526
|
+
const filteredDataset = RAW_DATASET.filter(row => matchesFilters(row, filters));
|
|
527
|
+
const {keys, values, totals, contributions} = computeColumnTotals(filteredDataset, idx);
|
|
528
|
+
return (
|
|
529
|
+
<ColumnTotalCard
|
|
530
|
+
key={idx}
|
|
531
|
+
columnName={RAW_DATASET_SCHEMA[idx]}
|
|
532
|
+
columnIdx={idx}
|
|
533
|
+
keys={keys}
|
|
534
|
+
values={values}
|
|
535
|
+
totals={totals}
|
|
536
|
+
contributions={contributions}
|
|
537
|
+
/>
|
|
538
|
+
);
|
|
539
|
+
})}
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
<div className="mt-8">
|
|
543
|
+
<h2 className="border-b border-gray-300 pb-2.5">Repository Paths Sunburst</h2>
|
|
544
|
+
<p className="text-sm text-gray-600 mt-2 mb-3">Breakdown by folder structure based on <code>clusterPath</code> within current filters.</p>
|
|
545
|
+
<SunburstPaths dataset={RAW_DATASET} filters={filters}/>
|
|
320
546
|
</div>
|
|
321
547
|
</div>
|
|
322
548
|
);
|
|
@@ -325,7 +551,6 @@ var report_template_default = `<!DOCTYPE html>
|
|
|
325
551
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
326
552
|
root.render(<App/>);
|
|
327
553
|
</script>
|
|
328
|
-
</body>
|
|
329
554
|
</html>
|
|
330
555
|
`;
|
|
331
556
|
|
|
@@ -345,6 +570,53 @@ function generateHtmlReport(data, outputFile) {
|
|
|
345
570
|
// src/git.ts
|
|
346
571
|
var import_path = __toESM(require("path"));
|
|
347
572
|
var import_fs = __toESM(require("fs"));
|
|
573
|
+
|
|
574
|
+
// src/util/exec.ts
|
|
575
|
+
var import_child_process = require("child_process");
|
|
576
|
+
function execAsync(command, args = [], options = {}) {
|
|
577
|
+
return new Promise((resolve2, reject) => {
|
|
578
|
+
const child = (0, import_child_process.spawn)(command, args, { ...options });
|
|
579
|
+
let stdout = [];
|
|
580
|
+
let stderr = [];
|
|
581
|
+
let stdoutBuffer = "";
|
|
582
|
+
let stderrBuffer = "";
|
|
583
|
+
child.stdout.on("data", (data) => {
|
|
584
|
+
stdoutBuffer += data.toString();
|
|
585
|
+
const lines = stdoutBuffer.split("\n");
|
|
586
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
587
|
+
stdout.push(lines[i]);
|
|
588
|
+
}
|
|
589
|
+
stdoutBuffer = lines[lines.length - 1];
|
|
590
|
+
});
|
|
591
|
+
child.stderr.on("data", (data) => {
|
|
592
|
+
stderrBuffer += data.toString();
|
|
593
|
+
const lines = stderrBuffer.split("\n");
|
|
594
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
595
|
+
stderr.push(lines[i]);
|
|
596
|
+
}
|
|
597
|
+
stderrBuffer = lines[lines.length - 1];
|
|
598
|
+
});
|
|
599
|
+
child.on("error", (err) => {
|
|
600
|
+
reject(err);
|
|
601
|
+
});
|
|
602
|
+
child.on("close", (code) => {
|
|
603
|
+
if (stdoutBuffer.length > 0) {
|
|
604
|
+
stdout.push(stdoutBuffer);
|
|
605
|
+
}
|
|
606
|
+
if (stderrBuffer.length > 0) {
|
|
607
|
+
stderr.push(stderrBuffer);
|
|
608
|
+
}
|
|
609
|
+
if (code === 0) {
|
|
610
|
+
resolve2({ stdout, stderr });
|
|
611
|
+
} else {
|
|
612
|
+
reject(new Error(`Command ${command} ${JSON.stringify(args)} failed with code ${code}
|
|
613
|
+
${stderr.join("\n")}`));
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/git.ts
|
|
348
620
|
async function executeGitBlamePorcelain(file, repoRoot, revisionBoundary, since) {
|
|
349
621
|
const args = ["blame", "--line-porcelain"];
|
|
350
622
|
if (since) {
|
|
@@ -425,37 +697,14 @@ async function findRevision(repoRoot, commitsBack) {
|
|
|
425
697
|
}
|
|
426
698
|
return void 0;
|
|
427
699
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
};
|
|
700
|
+
async function git_ls_files(repoRootPath, subdirPath) {
|
|
701
|
+
const { stdout: lsFilesOut } = await execAsync(
|
|
702
|
+
"git",
|
|
703
|
+
["ls-files", "--", subdirPath || "."],
|
|
704
|
+
{ cwd: repoRootPath }
|
|
705
|
+
);
|
|
706
|
+
return lsFilesOut.filter((line) => line && line.length > 0);
|
|
707
|
+
}
|
|
459
708
|
|
|
460
709
|
// src/util/AsyncGeneratorUtil.ts
|
|
461
710
|
var AsyncGeneratorUtil = class {
|
|
@@ -503,7 +752,7 @@ var AsyncGeneratorUtil = class {
|
|
|
503
752
|
});
|
|
504
753
|
}
|
|
505
754
|
};
|
|
506
|
-
var AsyncIteratorWrapperImpl = class
|
|
755
|
+
var AsyncIteratorWrapperImpl = class {
|
|
507
756
|
constructor(source) {
|
|
508
757
|
this.source = source;
|
|
509
758
|
}
|
|
@@ -511,14 +760,14 @@ var AsyncIteratorWrapperImpl = class _AsyncIteratorWrapperImpl {
|
|
|
511
760
|
return this.source;
|
|
512
761
|
}
|
|
513
762
|
map(mapper) {
|
|
514
|
-
return
|
|
763
|
+
return streamOf(async function* (source, mapper2) {
|
|
515
764
|
for await (const item of source) {
|
|
516
765
|
yield mapper2(item);
|
|
517
766
|
}
|
|
518
767
|
}(this.source, mapper));
|
|
519
768
|
}
|
|
520
769
|
flatMap(mapper) {
|
|
521
|
-
return
|
|
770
|
+
return streamOf(AsyncGeneratorUtil.flatMap(this.source, mapper));
|
|
522
771
|
}
|
|
523
772
|
async forEach(consumer) {
|
|
524
773
|
for await (const item of this.source) {
|
|
@@ -526,137 +775,197 @@ var AsyncIteratorWrapperImpl = class _AsyncIteratorWrapperImpl {
|
|
|
526
775
|
}
|
|
527
776
|
}
|
|
528
777
|
};
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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;
|
|
778
|
+
function streamOf(source) {
|
|
779
|
+
return new AsyncIteratorWrapperImpl(source);
|
|
780
|
+
}
|
|
781
|
+
var stream;
|
|
782
|
+
((stream2) => {
|
|
783
|
+
function ofArrayPromise(p) {
|
|
784
|
+
async function* __ofArrayPromise(p2) {
|
|
785
|
+
const items = await p2;
|
|
786
|
+
for (const item of items) {
|
|
787
|
+
yield item;
|
|
550
788
|
}
|
|
551
789
|
}
|
|
552
|
-
return
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
];
|
|
790
|
+
return streamOf(__ofArrayPromise(p));
|
|
791
|
+
}
|
|
792
|
+
stream2.ofArrayPromise = ofArrayPromise;
|
|
793
|
+
})(stream || (stream = {}));
|
|
794
|
+
|
|
795
|
+
// src/util/file_tree_clustering.ts
|
|
796
|
+
var graph;
|
|
797
|
+
((graph2) => {
|
|
798
|
+
function buildGraph(files) {
|
|
799
|
+
const result = {
|
|
800
|
+
path: [],
|
|
801
|
+
children: {},
|
|
802
|
+
value: [],
|
|
803
|
+
size: 0
|
|
804
|
+
};
|
|
805
|
+
files.forEach((file) => {
|
|
806
|
+
let targetDir = result;
|
|
807
|
+
file.arr.slice(0, -1).forEach((pathSegment) => {
|
|
808
|
+
targetDir.size++;
|
|
809
|
+
if (!Object.prototype.hasOwnProperty.call(targetDir.children, pathSegment)) {
|
|
810
|
+
targetDir.children[pathSegment] = {
|
|
811
|
+
path: targetDir.path.concat([pathSegment]),
|
|
812
|
+
children: {},
|
|
813
|
+
value: [],
|
|
814
|
+
size: 0
|
|
815
|
+
};
|
|
579
816
|
}
|
|
580
|
-
|
|
581
|
-
|
|
817
|
+
targetDir = targetDir.children[pathSegment];
|
|
818
|
+
});
|
|
819
|
+
targetDir.value.push(file);
|
|
820
|
+
});
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
823
|
+
graph2.buildGraph = buildGraph;
|
|
824
|
+
function flatten(graphNode) {
|
|
825
|
+
if (Object.keys(graphNode.children).length === 0) {
|
|
826
|
+
return graphNode.value;
|
|
827
|
+
}
|
|
828
|
+
let fileInfos = Object.values(graphNode.children).flatMap((child) => flatten(child));
|
|
829
|
+
return fileInfos.concat(graphNode.value);
|
|
830
|
+
}
|
|
831
|
+
function bubbleMicroLeftovers(graphNode, clusterMaxSize, clusterMinSize) {
|
|
832
|
+
if (Object.keys(graphNode.children).length === 0) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
if (graphNode.size < clusterMaxSize) {
|
|
836
|
+
let allFiles = flatten(graphNode);
|
|
837
|
+
graphNode.children = {};
|
|
838
|
+
graphNode.value = allFiles;
|
|
839
|
+
graphNode.size = allFiles.length;
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
let result = false;
|
|
843
|
+
Object.entries(graphNode.children).forEach(([k, child]) => {
|
|
844
|
+
if (child.value.length < clusterMinSize) {
|
|
845
|
+
graphNode.value.push(...child.value);
|
|
846
|
+
graphNode.size += child.value.length;
|
|
847
|
+
child.size -= child.value.length;
|
|
848
|
+
child.value = [];
|
|
849
|
+
if (child.size === 0) {
|
|
850
|
+
delete graphNode.children[k];
|
|
851
|
+
console.error("delete", graphNode.path, k);
|
|
852
|
+
result = true;
|
|
582
853
|
}
|
|
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
854
|
}
|
|
598
855
|
});
|
|
856
|
+
return result;
|
|
599
857
|
}
|
|
600
|
-
|
|
858
|
+
graph2.bubbleMicroLeftovers = bubbleMicroLeftovers;
|
|
859
|
+
function unpackSmallest(graphNode, clusterMaxSize, clusterMinSize) {
|
|
860
|
+
let childrenSortedAsc = Object.entries(graphNode.children).sort((a, b) => a[1].size - b[1].size);
|
|
861
|
+
if (childrenSortedAsc.length === 0) {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
let [k, child] = childrenSortedAsc[0];
|
|
865
|
+
let canFit = child.size + graphNode.value.length <= clusterMaxSize;
|
|
866
|
+
let cannotIsolate = graphNode.value.length < clusterMinSize;
|
|
867
|
+
if (canFit && cannotIsolate) {
|
|
868
|
+
graphNode.value.push(...flatten(child));
|
|
869
|
+
graphNode.size++;
|
|
870
|
+
delete graphNode.children[k];
|
|
871
|
+
console.error("delete", graphNode.path, k);
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
function bubbleMicroLeftoversRecursive(graphNode, clusterMaxSize, clusterMinSize) {
|
|
877
|
+
let changed = Object.values(graphNode.children).map((child) => {
|
|
878
|
+
bubbleMicroLeftoversRecursive(child, clusterMaxSize, clusterMinSize);
|
|
879
|
+
}).find(Boolean) || false;
|
|
880
|
+
changed = unpackSmallest(graphNode, clusterMaxSize, clusterMinSize) || changed;
|
|
881
|
+
changed = bubbleMicroLeftovers(graphNode, clusterMaxSize, clusterMinSize) || changed;
|
|
882
|
+
return changed;
|
|
883
|
+
}
|
|
884
|
+
graph2.bubbleMicroLeftoversRecursive = bubbleMicroLeftoversRecursive;
|
|
885
|
+
function collect(graphNode) {
|
|
886
|
+
let result = [];
|
|
887
|
+
function collectRecursive(node) {
|
|
888
|
+
result.push(node);
|
|
889
|
+
Object.values(node.children).forEach((child) => {
|
|
890
|
+
collectRecursive(child);
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
collectRecursive(graphNode);
|
|
894
|
+
return result;
|
|
895
|
+
}
|
|
896
|
+
graph2.collect = collect;
|
|
897
|
+
})(graph || (graph = {}));
|
|
898
|
+
function clusterFiles(files, clusterMaxSize, clusterMinSize) {
|
|
899
|
+
let fileInfos = files.sort().map((it) => {
|
|
900
|
+
let arr = it.split("/");
|
|
901
|
+
return { arr, str: it };
|
|
902
|
+
});
|
|
903
|
+
let graphNode = graph.buildGraph(fileInfos);
|
|
904
|
+
while (graph.bubbleMicroLeftoversRecursive(graphNode, clusterMaxSize, clusterMinSize)) {
|
|
905
|
+
}
|
|
906
|
+
let subclusters = graph.collect(graphNode).filter((it) => it.value.length > 0).sort((a, b) => b.path.join("/").localeCompare(a.path.join("/"))).map((it) => ({
|
|
907
|
+
path: it.path,
|
|
908
|
+
files: it.value,
|
|
909
|
+
isLeftovers: it.size > clusterMinSize,
|
|
910
|
+
isUnclusterable: it.size <= clusterMinSize
|
|
911
|
+
}));
|
|
912
|
+
return subclusters.map((cluster) => {
|
|
601
913
|
let files2 = cluster.files;
|
|
602
|
-
let
|
|
914
|
+
let path4 = cluster.path;
|
|
603
915
|
let files1 = files2.map((it) => it.str);
|
|
604
916
|
return {
|
|
605
|
-
path:
|
|
606
|
-
files: files1,
|
|
917
|
+
path: path4.join("/"),
|
|
918
|
+
files: files1.sort(),
|
|
607
919
|
weight: files1.length,
|
|
608
920
|
isLeftovers: cluster.isLeftovers
|
|
609
921
|
};
|
|
610
922
|
});
|
|
611
923
|
}
|
|
612
924
|
|
|
925
|
+
// src/util/dataset.ts
|
|
926
|
+
async function* distinctCount(source) {
|
|
927
|
+
const map = /* @__PURE__ */ new Map();
|
|
928
|
+
for await (const row of source) {
|
|
929
|
+
const key = JSON.stringify(row);
|
|
930
|
+
if (map.has(key)) {
|
|
931
|
+
map.get(key).count += 1;
|
|
932
|
+
} else {
|
|
933
|
+
map.set(key, { row, count: 1 });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
for (const { row, count } of map.values()) {
|
|
937
|
+
yield [...row, count];
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
613
941
|
// src/index.ts
|
|
614
942
|
var sigintCaught = false;
|
|
615
|
-
function getDirectories(
|
|
616
|
-
if (!
|
|
943
|
+
function getDirectories(absoluteDirPath) {
|
|
944
|
+
if (!fs3.existsSync(absoluteDirPath) || !fs3.statSync(absoluteDirPath).isDirectory()) return [];
|
|
617
945
|
const ignoredDirs = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
618
946
|
try {
|
|
619
|
-
return
|
|
947
|
+
return fs3.readdirSync(absoluteDirPath, { withFileTypes: true }).filter((dirent) => dirent.isDirectory() && !ignoredDirs.has(dirent.name)).map((dirent) => path3.join(absoluteDirPath, dirent.name));
|
|
620
948
|
} catch (error) {
|
|
621
|
-
console.error(`Could not read directory: ${
|
|
949
|
+
console.error(`Could not read directory: ${absoluteDirPath}`);
|
|
622
950
|
return [];
|
|
623
951
|
}
|
|
624
952
|
}
|
|
625
|
-
async function* forEachRepoFile(
|
|
953
|
+
async function* forEachRepoFile(repoRelativePath) {
|
|
626
954
|
console.error(`
|
|
627
|
-
Processing repository: ${
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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));
|
|
955
|
+
Processing repository: ${repoRelativePath || "."}`);
|
|
956
|
+
const absoluteRepoPath = path3.resolve(process.cwd(), repoRelativePath);
|
|
957
|
+
const repoName = path3.basename(absoluteRepoPath);
|
|
958
|
+
let revisionBoundary = await findRevision(absoluteRepoPath, 5e3);
|
|
959
|
+
const files = await git_ls_files(absoluteRepoPath, ".");
|
|
960
|
+
let minClusterSize = Math.floor(Math.max(2, files.length / 100));
|
|
961
|
+
let maxClusterSize = Math.round(Math.max(20, files.length / 30));
|
|
653
962
|
console.error(`Clustering ${files.length} into ${minClusterSize}..${maxClusterSize}+ sized chunks`);
|
|
654
963
|
const filesClustered = clusterFiles(
|
|
655
964
|
files,
|
|
656
965
|
maxClusterSize,
|
|
657
966
|
minClusterSize
|
|
658
967
|
);
|
|
659
|
-
console.error(filesClustered.map((it) => `${it.path}
|
|
968
|
+
console.error(filesClustered.map((it) => `${it.path} (${it.weight})`));
|
|
660
969
|
let clusterPaths = filesClustered.map((it) => it.path);
|
|
661
970
|
console.error(`Found ${files.length} files to analyze in '${repoName}'...`);
|
|
662
971
|
let filesShuffled = [...files].sort(() => Math.random() - 0.5);
|
|
@@ -667,7 +976,7 @@ Processing repository: ${repoPath || "."}`);
|
|
|
667
976
|
process.stderr.write(progressMessage.padEnd(process.stderr.columns || 80, " ") + "\r");
|
|
668
977
|
try {
|
|
669
978
|
let clusterPath = clusterPaths.find((it) => file.startsWith(it)) ?? "$$$unknown$$$";
|
|
670
|
-
yield
|
|
979
|
+
yield [absoluteRepoPath, file, revisionBoundary, clusterPath];
|
|
671
980
|
} catch (e) {
|
|
672
981
|
if (e.signal === "SIGINT") sigintCaught = true;
|
|
673
982
|
}
|
|
@@ -675,68 +984,51 @@ Processing repository: ${repoPath || "."}`);
|
|
|
675
984
|
process.stderr.write(" ".repeat(process.stderr.columns || 80) + "\r");
|
|
676
985
|
console.error(`Analysis complete for '${repoName}'.`);
|
|
677
986
|
}
|
|
678
|
-
function
|
|
679
|
-
|
|
680
|
-
const
|
|
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);
|
|
987
|
+
async function doProcessFile(absoluteRepoRoot, repoRelativeFilePath, revisionBoundary) {
|
|
988
|
+
if (!repoRelativeFilePath) return [];
|
|
989
|
+
const absoluteFilePath = path3.join(absoluteRepoRoot, repoRelativeFilePath);
|
|
692
990
|
let stat = null;
|
|
693
991
|
try {
|
|
694
|
-
stat =
|
|
992
|
+
stat = fs3.statSync(absoluteFilePath);
|
|
695
993
|
} catch (e) {
|
|
696
|
-
console.error(`Fail get stats for file ${
|
|
994
|
+
console.error(`Fail get stats for file ${absoluteFilePath}`, e.stack || e.message || e);
|
|
697
995
|
}
|
|
698
996
|
if (!stat || !stat.isFile() || stat.size === 0) return [];
|
|
699
997
|
const result = [];
|
|
700
|
-
for (const item of await git_blame_porcelain(
|
|
998
|
+
for (const item of await git_blame_porcelain(repoRelativeFilePath, absoluteRepoRoot, ["author", "committer-time", "commit"], !!revisionBoundary ? revisionBoundary + "..HEAD" : void 0)) {
|
|
701
999
|
if (revisionBoundary === item[2]) {
|
|
702
1000
|
item[0] = "?";
|
|
703
1001
|
item[1] = 0;
|
|
704
1002
|
item[2] = "0".repeat(40);
|
|
705
1003
|
}
|
|
706
|
-
const lang =
|
|
707
|
-
let days_bucket =
|
|
1004
|
+
const lang = path3.extname(repoRelativeFilePath) || "Other";
|
|
1005
|
+
let days_bucket = util.yyyyMM(item[1]);
|
|
708
1006
|
if (days_bucket != -1) {
|
|
709
|
-
result.push([item[0], days_bucket, lang,
|
|
1007
|
+
result.push([item[0], days_bucket, lang, repoRelativeFilePath, absoluteRepoRoot]);
|
|
710
1008
|
}
|
|
711
1009
|
}
|
|
712
1010
|
return result;
|
|
713
1011
|
}
|
|
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
1012
|
function getRepoPathsToProcess(inputPaths) {
|
|
729
1013
|
let repoPathsToProcess = inputPaths.flatMap((it) => findRepositories(it, 3));
|
|
730
|
-
repoPathsToProcess =
|
|
1014
|
+
repoPathsToProcess = util.distinct(repoPathsToProcess).sort();
|
|
731
1015
|
if (repoPathsToProcess.length === 0) {
|
|
732
1016
|
throw new Error("No git repositories found to analyze.");
|
|
733
1017
|
}
|
|
734
1018
|
console.error(`Found ${repoPathsToProcess.length} repositories to analyze:`);
|
|
735
|
-
repoPathsToProcess.forEach((p) => console.error(`- ${p
|
|
1019
|
+
repoPathsToProcess.forEach((p) => console.error(`- ${p ?? "."}`));
|
|
736
1020
|
return repoPathsToProcess;
|
|
737
1021
|
}
|
|
1022
|
+
function runScan1(args) {
|
|
1023
|
+
const inputPaths = args && args.length > 0 ? args : ["."];
|
|
1024
|
+
let repoPathsToProcess = getRepoPathsToProcess(inputPaths);
|
|
1025
|
+
let dataSet = streamOf(AsyncGeneratorUtil.of(repoPathsToProcess)).flatMap((repoRelativePath) => forEachRepoFile(repoRelativePath)).flatMap((fileInfo) => {
|
|
1026
|
+
let linesInfo = stream.ofArrayPromise(doProcessFile(fileInfo[0], fileInfo[1], fileInfo[2]));
|
|
1027
|
+
return linesInfo.map((it) => it.concat(fileInfo[3])).get();
|
|
1028
|
+
}).map((it) => [it[0], it[1], it[2], it[5], path3.basename(it[4])]).get();
|
|
1029
|
+
return distinctCount(dataSet);
|
|
1030
|
+
}
|
|
738
1031
|
async function runScan(args) {
|
|
739
|
-
const tmpVfs = new RealFileSystemImpl("./.git-stats/");
|
|
740
1032
|
process.on("SIGINT", () => {
|
|
741
1033
|
if (sigintCaught) {
|
|
742
1034
|
console.error("\nForcing exit.");
|
|
@@ -745,23 +1037,19 @@ async function runScan(args) {
|
|
|
745
1037
|
sigintCaught = true;
|
|
746
1038
|
console.error("\nSignal received. Finishing current file then stopping. Press Ctrl+C again to exit immediately.");
|
|
747
1039
|
});
|
|
748
|
-
|
|
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);
|
|
1040
|
+
let aggregatedData1 = runScan1(args);
|
|
753
1041
|
let aggregatedData = await AsyncGeneratorUtil.collect(aggregatedData1);
|
|
754
1042
|
aggregatedData.forEach((it) => console.log(JSON.stringify(it)));
|
|
755
1043
|
}
|
|
756
1044
|
async function runHtml(args) {
|
|
757
|
-
const
|
|
758
|
-
const
|
|
759
|
-
if (!
|
|
760
|
-
console.error(`Input data file not found: ${
|
|
1045
|
+
const absoluteInputPath = args[0] || path3.resolve("./.git-stats/data.jsonl");
|
|
1046
|
+
const absoluteOutHtml = path3.resolve("./.git-stats/report.html");
|
|
1047
|
+
if (!fs3.existsSync(absoluteInputPath)) {
|
|
1048
|
+
console.error(`Input data file not found: ${absoluteInputPath}`);
|
|
761
1049
|
process.exitCode = 1;
|
|
762
1050
|
return;
|
|
763
1051
|
}
|
|
764
|
-
const lines =
|
|
1052
|
+
const lines = fs3.readFileSync(absoluteInputPath, "utf8").split(/\r?\n/).filter(Boolean);
|
|
765
1053
|
const aggregatedData = lines.map((line) => {
|
|
766
1054
|
try {
|
|
767
1055
|
return JSON.parse(line);
|
|
@@ -769,29 +1057,71 @@ async function runHtml(args) {
|
|
|
769
1057
|
return null;
|
|
770
1058
|
}
|
|
771
1059
|
}).filter(Boolean);
|
|
772
|
-
generateHtmlReport(aggregatedData,
|
|
773
|
-
console.error(`HTML report generated: ${
|
|
1060
|
+
generateHtmlReport(aggregatedData, absoluteOutHtml);
|
|
1061
|
+
console.error(`HTML report generated: ${absoluteOutHtml}`);
|
|
774
1062
|
}
|
|
775
|
-
function findRepositories(
|
|
1063
|
+
function findRepositories(absolutePath, depth) {
|
|
776
1064
|
if (depth <= 0) return [];
|
|
777
|
-
if (!
|
|
778
|
-
if (!
|
|
779
|
-
if (isGitRepo(
|
|
780
|
-
let result = getDirectories(
|
|
781
|
-
return
|
|
1065
|
+
if (!fs3.existsSync(absolutePath)) throw new Error(`Path does not exist: ${absolutePath}`);
|
|
1066
|
+
if (!fs3.statSync(absolutePath).isDirectory()) return [];
|
|
1067
|
+
if (isGitRepo(absolutePath)) return [absolutePath];
|
|
1068
|
+
let result = getDirectories(absolutePath).flatMap((dir) => findRepositories(dir, depth - 1));
|
|
1069
|
+
return util.distinct(result).sort();
|
|
782
1070
|
}
|
|
783
1071
|
async function main() {
|
|
784
1072
|
const argv = process.argv.slice(2);
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1073
|
+
let subcommand = argv[0];
|
|
1074
|
+
let subcommandsMenu = {
|
|
1075
|
+
"html": {
|
|
1076
|
+
description: "Generates an HTML report from the aggregated data.",
|
|
1077
|
+
usage: "git-stats html [input-data-file]"
|
|
1078
|
+
},
|
|
1079
|
+
"scan": {
|
|
1080
|
+
description: "Scans a directory tree for Git repositories and generates aggregated data.",
|
|
1081
|
+
usage: "git-stats scan [input-dir] > {output-file}.jsonl"
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
if (subcommand === "scan") {
|
|
1085
|
+
await runScan(argv.slice(1));
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (subcommand === "html") {
|
|
1089
|
+
await runHtml(argv.slice(1));
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
console.error(`Usage: git-stats <subcommand> [args]
|
|
1093
|
+
|
|
1094
|
+
Available subcommands:`);
|
|
1095
|
+
for (const [name, { description, usage }] of Object.entries(subcommandsMenu)) {
|
|
1096
|
+
console.error(`- ${name}: ${description}
|
|
1097
|
+
Usage: ${usage}`);
|
|
791
1098
|
}
|
|
792
1099
|
}
|
|
1100
|
+
var util;
|
|
1101
|
+
((util2) => {
|
|
1102
|
+
function distinct(arr) {
|
|
1103
|
+
return [...new Set(arr)];
|
|
1104
|
+
}
|
|
1105
|
+
util2.distinct = distinct;
|
|
1106
|
+
function daysAgo(epoch) {
|
|
1107
|
+
const now = Date.now();
|
|
1108
|
+
const diff = now - epoch * 1e3;
|
|
1109
|
+
return Math.floor(diff / (1e3 * 60 * 60 * 24));
|
|
1110
|
+
}
|
|
1111
|
+
util2.daysAgo = daysAgo;
|
|
1112
|
+
function bucket(n, buckets) {
|
|
1113
|
+
for (let i = 1; i < buckets.length; i++) {
|
|
1114
|
+
if (n > buckets[i - 1] && n < buckets[i]) return buckets[i - 1];
|
|
1115
|
+
}
|
|
1116
|
+
return -1;
|
|
1117
|
+
}
|
|
1118
|
+
util2.bucket = bucket;
|
|
1119
|
+
function yyyyMM(epoch) {
|
|
1120
|
+
const date = new Date(epoch * 1e3);
|
|
1121
|
+
let yyyyStr = date.getFullYear().toString();
|
|
1122
|
+
let MMStr = (date.getMonth() + 1 / 4).toString().padStart(1, "0");
|
|
1123
|
+
return parseInt(yyyyStr) * 10 + parseInt(MMStr);
|
|
1124
|
+
}
|
|
1125
|
+
util2.yyyyMM = yyyyMM;
|
|
1126
|
+
})(util || (util = {}));
|
|
793
1127
|
main().catch(console.error);
|
|
794
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
795
|
-
0 && (module.exports = {
|
|
796
|
-
distinctCount
|
|
797
|
-
});
|