@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.
Files changed (2) hide show
  1. package/dist/index.js +694 -364
  2. 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 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"));
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
- <!-- 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>
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 HEADER_LABELS = ["author", "days_bucket", "lang", "clusterPath", "repoName", "count"];
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(HEADER_LABELS.map((k, i) => [k, i]));
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
- primaryKeyIndex,
142
- secondaryKeyIndex,
143
- filterState
114
+ column1,
115
+ column2
144
116
  ) {
145
- const pivot = new Map();
146
- const totals = new Map();
117
+ const grouped2 = new Map();
147
118
  const secValuesSet = new Set();
148
119
  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];
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 (!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);
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 primaryKeys = Array.from(pivot.keys()).sort((a, b) => (totals.get(b) || 0) - (totals.get(a) || 0));
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 {pivot, totals, primaryKeys, secondaryKeys};
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 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
- }
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
- return () => {
202
- chartRef.current && chartRef.current.destroy();
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
- if (!chartRef.current) return;
208
- chartRef.current.data.labels = labels;
209
- chartRef.current.data.datasets = datasets;
210
- chartRef.current.update();
211
- }, [labels, datasets]);
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 <canvas ref={canvasRef}/>;
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
- <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
- }}
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
- {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>
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 (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;
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
- pivot: pv,
473
+ grouped2: pv,
269
474
  primaryKeys,
270
475
  secondaryKeys
271
- } = pivot(RAW_DATASET, primaryKeyIndex, secondaryKeyIndex, filters);
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
- {allKeys.map((__, i) => (
291
- <option key={i} value={i}>{HEADER_LABELS[i]}</option>
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
- {allKeys.map((__, i) => (
300
- <option key={i} value={i}>{HEADER_LABELS[i]}</option>
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
- {allKeys.map((key, idx) => (
511
+ {COLUMNS_IDX_ARRAY.map((__, idx) => (
307
512
  <MultiSelect
308
513
  key={idx}
309
- label={HEADER_LABELS[idx]}
310
- values={valueOptions[key]}
311
- selectedSet={filters[key]}
312
- onChange={(newSet) => setFilters(prev => ({...prev, [key]: newSet}))}
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-5">
318
- <h2 id="chartTitle" className="border-b border-gray-300 pb-2.5">Chart</h2>
319
- <ChartView labels={chartLabels} datasets={datasets}/>
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
- // 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
- };
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 _AsyncIteratorWrapperImpl {
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 new _AsyncIteratorWrapperImpl(async function* (source, mapper2) {
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 new _AsyncIteratorWrapperImpl(AsyncGeneratorUtil.flatMap(this.source, mapper));
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
- // 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;
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 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
- ];
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
- if (newClusterFiles.length < clusterMinSize || remainingFiles.length < clusterMinSize) {
581
- return [{ ...originalCluster, isUnclusterable: true }];
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
- return clusterGroups.map((cluster) => {
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 path5 = cluster.path;
914
+ let path4 = cluster.path;
603
915
  let files1 = files2.map((it) => it.str);
604
916
  return {
605
- path: path5.join("/"),
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(source) {
616
- if (!fs4.existsSync(source) || !fs4.statSync(source).isDirectory()) return [];
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 fs4.readdirSync(source, { withFileTypes: true }).filter((dirent) => dirent.isDirectory() && !ignoredDirs.has(dirent.name)).map((dirent) => path4.join(source, dirent.name));
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: ${source}`);
949
+ console.error(`Could not read directory: ${absoluteDirPath}`);
622
950
  return [];
623
951
  }
624
952
  }
625
- async function* forEachRepoFile(repoPath, doProcessFile) {
953
+ async function* forEachRepoFile(repoRelativePath) {
626
954
  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));
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}${it.isLeftovers ? "/*" : ""} (${it.weight})`));
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* (await doProcessFile(repoRoot, file, revisionBoundary)).map((it) => it.concat(clusterPath));
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 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);
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 = fs4.statSync(absPath);
992
+ stat = fs3.statSync(absoluteFilePath);
695
993
  } catch (e) {
696
- console.error(`Fail get stats for file ${absPath}`, e.stack || e.message || e);
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(filePath, repoRoot, ["author", "committer-time", "commit"], revisionBoundary + "..HEAD")) {
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 = path4.extname(filePath) || "Other";
707
- let days_bucket = bucket(daysAgo(item[1]), [0, 30, 300, 1e3, 1e6]);
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, filePath, repoRoot]);
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 = [...new Set(repoPathsToProcess)].sort();
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
- 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);
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 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}`);
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 = fs4.readFileSync(inputPath, "utf8").split(/\r?\n/).filter(Boolean);
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, outHtml);
773
- console.error(`HTML report generated: ${outHtml}`);
1060
+ generateHtmlReport(aggregatedData, absoluteOutHtml);
1061
+ console.error(`HTML report generated: ${absoluteOutHtml}`);
774
1062
  }
775
- function findRepositories(path5, depth) {
1063
+ function findRepositories(absolutePath, depth) {
776
1064
  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();
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
- 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);
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
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vladimirshefer/git-stats",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "A CLI to generate git blame stats for a repository",
5
5
  "main": "dist/index.js",
6
6
  "bin": {